Skip to content

fix(cursor): structured edit tools convert to valid apply_patch calls (#1017) - #1036

Draft
ZachDreamZ wants to merge 5 commits into
lidge-jun:devfrom
ZachDreamZ:fix/1017-cursor-structured-edit
Draft

fix(cursor): structured edit tools convert to valid apply_patch calls (#1017)#1036
ZachDreamZ wants to merge 5 commits into
lidge-jun:devfrom
ZachDreamZ:fix/1017-cursor-structured-edit

Conversation

@ZachDreamZ

@ZachDreamZ ZachDreamZ commented Aug 5, 2026

Copy link
Copy Markdown

Summary

The Cursor adapter advertises Codex's apply_patch freeform tool (a {"input": "*** Begin Patch ..."} body) to Cursor models, while simultaneously rejecting Cursor-native write/delete tools so edits cannot bypass Codex approval. Cursor-trained models such as cursor/grok-4.5 cannot emit Codex's freeform patch grammar, so every file edit produced malformed patch text (missing + prefixes, invalid hunks) that the Codex client rejected locally after the request had already returned HTTP 200 — making the Cursor route unusable for repository changes (#1017).

This PR adds the missing provider-compatible edit path (option 2 from the issue):

  • When a Cursor request advertises the freeform apply_patch tool, the adapter now also advertises edit_file and multi_edit — exact-match replacement tools shaped like Cursor's native Edit/MultiEdit tools.
  • Completed edit_file / multi_edit calls are converted server-side into a valid Codex apply_patch payload (*** Begin Patch envelope, *** Update File: section, @@ hunks with -/+ line prefixes) and relayed to the bridge as an apply_patch custom tool call, so Codex approval/sandbox policy still applies.
  • Malformed structured calls (non-JSON args, missing file_path/old_string/new_string, empty old_string, invalid edits[]) are dropped with a clear error instead of relaying invalid patch text — the previously silent 200-then-local-rejection failure mode.
  • The tool budget pins the synthetic tools alongside apply_patch (never truncated away), the guidance note tells models to prefer them, and the Cursor-native write/delete refusal message points to them.
  • Explicit tool_choice selections are never widened: a forced apply_patch choice does not gain sibling tools.

Verification

Commands run (Windows 11, bun 1.3.14):

  • bun run typecheck — pass
  • bun run lint:gui — pass
  • bun run privacy:scan — "Privacy scan passed"
  • bun run doctor:gui:if-changed — skip (no gui/ changes)
  • bun test tests/cursor-structured-edit.test.ts — 16 pass / 0 fail (new regression suite)
  • bun test tests/cursor-tool-definitions.test.ts tests/cursor-tool-choice.test.ts tests/cursor-protobuf-events.test.ts tests/cursor-tool-arg-decoding.test.ts tests/cursor-request-builder.test.ts tests/cursor-tool-continuation.test.ts tests/cursor-native-exec.test.ts tests/cursor-native-exec-policy.test.ts — 166 pass / 0 fail
  • bun test tests/cursor-live-transport.test.ts tests/cursor-live-smoke-gate.test.ts tests/cursor-hardening.test.ts tests/cursor-message-mapper.test.ts tests/cursor-tool-finalize-race.test.ts tests/cursor-desktop-exec.test.ts — 72 pass / 0 fail

New tests cover: tool advertisement conditions (auto vs forced/allowed tool_choice, namespaced apply_patch), budget pinning, guidance wording, single/multi-line replacement payloads, deletion hunks, Cursor-style arg aliases, multi-edit payloads, malformed-call rejection, and both protobuf event paths (interactionUpdate and stateless native-exec).

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Summary by CodeRabbit

  • New Features
    • Added support for Cursor’s structured edit_file and multi_edit tools.
    • Automatically converts valid structured edits into supported patch operations.
    • Added guidance encouraging structured editing for file changes.
  • Bug Fixes
    • Improved validation and error reporting for malformed edit requests.
    • Structured editing now works consistently across supported tool-call flows.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

@github-actions github-actions Bot added the bug Something isn't working label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Cursor now advertises edit_file and multi_edit when apply_patch is available. The adapter validates and converts these calls into Codex apply_patch payloads across stateless and stateful event paths. Budgeting, guidance, native-exec messages, and tests were updated.

Changes

Cursor structured edit support

Layer / File(s) Summary
Structured edit catalog and budgeting
src/adapters/cursor/tool-definitions.ts, src/adapters/cursor/request-builder.ts, tests/cursor-structured-edit.test.ts
Adds strict schemas and synthetic tool definitions for edit_file and multi_edit. Applies tool-choice filtering, transport budgeting, omission reporting, and updated guidance.
Structured edit translation and event handling
src/adapters/cursor/protobuf-events.ts, src/adapters/cursor/native-exec-fs.ts, tests/cursor-structured-edit.test.ts
Validates structured-edit arguments and converts replacements and deletions into apply_patch input. Handles stateless and stateful events, conversion errors, and native-exec refusal guidance.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Cursor
  participant RequestBuilder
  participant ProtobufEvents
  participant CodexApplyPatch
  Cursor->>RequestBuilder: request tool catalog
  RequestBuilder->>Cursor: advertise edit_file and multi_edit
  Cursor->>ProtobufEvents: send structured edit call
  ProtobufEvents->>CodexApplyPatch: emit translated patch input
Loading

Possibly related PRs

  • lidge-jun/opencodex#402: Updates overlapping Cursor native filesystem-execution refusal guidance for apply_patch.

Suggested reviewers: lidge-jun, wibias, ingwannu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Cursor structured edit tools convert into valid Codex apply_patch calls.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/adapters/cursor/native-exec-fs.ts`:
- Around line 42-44: Update codexNativeMutationRefusal to accept structured-edit
availability and only mention edit_file/multi_edit when those tools are
advertised; otherwise direct the model to the available apply_patch alternative.
Thread the same per-request availability state used by
buildCursorToolGuidanceSystemNote through both refusal call sites, preserving
operation-specific messaging.

In `@src/adapters/cursor/protobuf-events.ts`:
- Around line 328-346: Update replacementHunk in
src/adapters/cursor/protobuf-events.ts (lines 328-346) to compare the normalized
results from patchLines(oldString) and patchLines(newString), returning an
actionable error when they are identical before constructing the hunk. Add a
malformed-input test in tests/cursor-structured-edit.test.ts (lines 215-223)
asserting that a trailing-newline-only edit returns a truthy error from
translateStructuredEditCall.
- Around line 386-395: Give addReplacement a dedicated result type representing
either an error or a bare hunk, rather than StructuredEditTranslation’s complete
apply_patch payload; update its callers and both hunk push sites to use
consistent error !== undefined narrowing. Adjust replacementHunk’s return union
to explicitly define error?: undefined and hunk?: undefined so this narrowing
remains type-safe, while preserving StructuredEditTranslation for the final
wrapped payload.
- Around line 335-346: Update replacementHunk and the related structured-edit
generation near its call site to preserve unique-match semantics: before
emitting a context-free @@ hunk, ensure oldString occurs exactly once in the
target content, or require unambiguous context/position information. Reject
ambiguous matches instead of allowing seek_sequence to select the first
occurrence.

In `@src/adapters/cursor/tool-definitions.ts`:
- Around line 205-225: Prevent collisions in cursorStructuredEditTools by
excluding synthetic edit_file and multi_edit definitions when the request’s
non-namespaced client tools already use those names. Carry the per-request
synthetic-name set through request construction into commitToolCall, and make
protobuf-events translation apply only to names in that set. Add coverage for
both tool catalog construction and call translation, including client-owned name
collisions.

In `@tests/cursor-structured-edit.test.ts`:
- Line 157: Remove optional chaining from the expect calls in the structured
edit translation tests at the assertions around lines 157, 174, 176, 181, and
200. Assert directly on the result, and use toEqual instead of toMatchObject
where the complete translation result is being validated, matching the existing
strict assertion pattern.
- Around line 215-223: The malformed structured-edit validation in
translateStructuredEditCall must reject trailing-newline-only changes instead of
generating a no-op patch. Update the patchLines logic used by
CURSOR_EDIT_FILE_TOOL so removing or adding only a final newline produces an
error, while preserving valid structured edits and existing malformed-input
handling.
- Around line 228-232: The stateful protobuf event tests use identity tool-name
mappings and do not cover renamed wire tools or multi_edit behavior. In the
stateful tests around createCursorProtobufEventState, add focused cases using
the exact non-identity name from cursorToolWireName and verify translation to
apply_patch, plus coverage for multi_edit loop and per-entry validation; also
change the repeated optional-chaining assertion to direct
expect(...).toEqual(...).
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2e16250d-e031-44b8-a16e-f5b911b31dc2

📥 Commits

Reviewing files that changed from the base of the PR and between 064686b and e1e1732.

📒 Files selected for processing (5)
  • src/adapters/cursor/native-exec-fs.ts
  • src/adapters/cursor/protobuf-events.ts
  • src/adapters/cursor/request-builder.ts
  • src/adapters/cursor/tool-definitions.ts
  • tests/cursor-structured-edit.test.ts

Comment thread src/adapters/cursor/native-exec-fs.ts Outdated
Comment on lines 42 to 44
function codexNativeMutationRefusal(operation: "write" | "delete"): string {
return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available. Use the apply_patch tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed.`;
return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available. Use the structured edit tools (\`edit_file\` / \`multi_edit\`) or the \`apply_patch\` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed.`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The refusal message names edit_file / multi_edit even when they are not advertised.

codexNativeMutationRefusal receives only operation. It has no view of the advertised catalog, so it names the structured edit tools unconditionally.

Those tools are not always advertised:

  • src/adapters/cursor/tool-definitions.ts line 210 returns [] for any object toolChoice. tests/cursor-structured-edit.test.ts lines 117-120 assert exactly that: with { name: "apply_patch" } the budget keeps apply_patch alone.
  • src/adapters/cursor/request-builder.ts line 92 can also drop them at the byte ceiling.

In those requests the model attempts a native write, reads this refusal, and calls edit_file — a name absent from its catalog. That contradicts the instruction this same PR injects at tool-definitions.ts line 514: "Use the current tool catalog as ground truth and call only those exact names with their listed argument keys." The turn is spent on a hallucinated call before the model reaches the valid apply_patch alternative at the end of the sentence.

buildCursorToolGuidanceSystemNote already gates every clause on real availability (hasApplyPatch, hasBareExec, structuredEditNames.length > 0). Apply the same rule here.

🐛 Proposed fix: gate the structured-tool sentence on availability
-function codexNativeMutationRefusal(operation: "write" | "delete"): string {
-  return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available. Use the structured edit tools (\`edit_file\` / \`multi_edit\`) or the \`apply_patch\` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed.`;
-}
+function codexNativeMutationRefusal(
+  operation: "write" | "delete",
+  // Structured edit tools are suppressed for an explicit tool choice and can be dropped by the
+  // byte budget, so never name a tool the model's catalog does not list (`#1017`).
+  structuredEditAvailable = false,
+): string {
+  const preferred = structuredEditAvailable
+    ? "Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool"
+    : "Use the `apply_patch` tool";
+  return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available. ${preferred} for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed.`;
+}

Thread the flag from the same per-request state that decides advertisement, then pass it at the two call sites (lines 93 and 142).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/cursor/native-exec-fs.ts` around lines 42 - 44, Update
codexNativeMutationRefusal to accept structured-edit availability and only
mention edit_file/multi_edit when those tools are advertised; otherwise direct
the model to the available apply_patch alternative. Thread the same per-request
availability state used by buildCursorToolGuidanceSystemNote through both
refusal call sites, preserving operation-specific messaging.

Comment on lines +328 to +346
/** Split a replacement into patch lines, ignoring one trailing newline (line-based patch semantics). */
function patchLines(text: string): string[] {
const lines = text.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}

/** One `@@` hunk replacing `oldString` with `newString`. */
function replacementHunk(oldString: string, newString: string): { hunk: string } | { error: string } {
if (oldString.length === 0) {
return {
error:
"structured edit requires a non-empty old_string to locate the replacement; for new files or insertions without existing text, call apply_patch with an `*** Add File` / context hunk or use the shell bridge",
};
}
const removed = patchLines(oldString).map(line => `-${line}`);
const added = patchLines(newString).map(line => `+${line}`);
return { hunk: ["@@", ...removed, ...added].join("\n") };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A trailing-newline-only edit becomes a silent no-op. patchLines pops one trailing empty element from old_string and from new_string independently, so the difference between them is discarded rather than normalized. old_string: "export {};\n" with new_string: "export {};" produces the hunk @@ / -export {}; / +export {};. The Codex client changes nothing, and the model reads success. Every other malformed input in this feature returns an actionable error; only this one fails quietly.

  • src/adapters/cursor/protobuf-events.ts#L328-L346: in replacementHunk, compare the two collapsed line lists before building the hunk and return an error when they are identical, so the call is rejected instead of emitted as a no-op.
  • tests/cursor-structured-edit.test.ts#L215-L223: add a case to the malformed matrix asserting translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, JSON.stringify({ file_path: "src/f.ts", old_string: "export {};\n", new_string: "export {};" }))?.error is truthy.
📍 Affects 2 files
  • src/adapters/cursor/protobuf-events.ts#L328-L346 (this comment)
  • tests/cursor-structured-edit.test.ts#L215-L223
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/cursor/protobuf-events.ts` around lines 328 - 346, Update
replacementHunk in src/adapters/cursor/protobuf-events.ts (lines 328-346) to
compare the normalized results from patchLines(oldString) and
patchLines(newString), returning an actionable error when they are identical
before constructing the hunk. Add a malformed-input test in
tests/cursor-structured-edit.test.ts (lines 215-223) asserting that a
trailing-newline-only edit returns a truthy error from
translateStructuredEditCall.

Comment on lines +335 to +346
/** One `@@` hunk replacing `oldString` with `newString`. */
function replacementHunk(oldString: string, newString: string): { hunk: string } | { error: string } {
if (oldString.length === 0) {
return {
error:
"structured edit requires a non-empty old_string to locate the replacement; for new files or insertions without existing text, call apply_patch with an `*** Add File` / context hunk or use the shell bridge",
};
}
const removed = patchLines(oldString).map(line => `-${line}`);
const added = patchLines(newString).map(line => `+${line}`);
return { hunk: ["@@", ...removed, ...added].join("\n") };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Codex CLI apply_patch patch format specification: are @@ hunks with no context lines valid, and how does it resolve a non-unique match in an Update File section?

💡 Result:

The Codex CLI apply_patch format is a custom, file-oriented diff specification designed for LLM-based code editing [1][2][3]. Regarding your specific questions: 1. Are @@ hunks with no context lines valid? Yes, hunks with no context lines are syntactically valid within the apply_patch format [1][4][5]. The grammar defines a hunk as beginning with @@ (optionally followed by a header) and containing one or more HunkLine entries, which are lines prefixed with +, -, or (space) [1][2][6]. While context lines (prefixed with space) are standard practice for locating changes, they are not strictly required by the grammar if the modification can be unambiguously applied [1][6]. 2. How does it resolve a non-unique match in an Update File section? The native apply_patch logic generally operates on a "strict match" basis first [7]. In its original implementation and common derivative tools, the system attempts to find the specific sequence of context and modification lines within the target file [8][9]. When a match is ambiguous (non-unique), standard apply_patch implementations typically do not "guess" [9]. Instead: * If the patch contains sufficient context to narrow the location, it succeeds. * If the context provided matches multiple locations or no location, the operation is considered failed, and an error is returned (e.g., NoMatch or LineDriftError) [9][10]. * Many modern, "tolerant" implementations of this format (often used to improve success rates for LLM agents) employ additional strategies like fuzzy matching or searching within a limited window (e.g., ±20 lines) of the original position [9]. Even with these, these systems are explicitly designed to avoid applying ambiguous patches, requiring the caller to resolve the ambiguity or providing a clear error rather than applying the patch to the wrong location [9]. For developers, the official guidance emphasizes that the model should provide sufficient context to make edits unambiguous [2]. If a system uses a "tolerant" wrapper, it may attempt to score candidates using algorithms like Longest Common Subsequence (LCS) to find the most plausible match, but these are typically secondary rescue mechanisms rather than the core resolution behavior [11].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
sed -n '320,430p' src/adapters/cursor/protobuf-events.ts
printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '!node_modules' 'replacementHunk|apply_patch|Update File|old_string|structured edit' .
printf '%s\n' '--- repository metadata ---'
rg -n 'openai/codex|codex' package.json bun.lockb bun.lock README.md src 2>/dev/null || true

Repository: lidge-jun/opencodex

Length of output: 50376


🌐 Web query:

OpenAI Codex apply_patch_tool.rs parser.rs hunk matching duplicate old lines Update File exact source

💡 Result:

In the OpenAI Codex apply_patch implementation, the handling of duplicate code or ambiguous context is managed through specific instructions for hunk construction in apply_patch_tool.rs and apply_patch_tool_instructions.md [1][2]. To handle cases where lines are duplicated or 3 lines of context are insufficient to uniquely identify a location in a file, the following mechanisms are used: 1. Use of the @@ Operator: When 3 lines of context do not uniquely identify a code snippet, the @@ operator can be used to specify the class or function to which the snippet belongs [1][2]. 2. Multiple @@ Statements: If a code block is repeated so frequently within a class or function that a single @@ statement is still insufficient, you can provide multiple @@ statements to jump to the correct context [2]. 3. Avoiding Duplicate Context: The instructions explicitly state that if a change is within 3 lines of a previous change, the parser/tool should not duplicate the first change's context_after lines in the second change's context_before lines [1][2]. The parser.rs module is responsible for parsing these hunks and validating the patch structure [3][4]. It processes UpdateFileChunk structures, where change_context (the content following @@) is used to locate the modification site [3][5]. The grammar, defined in tool_apply_patch.lark, formally supports these context markers and hunk lines, allowing for the flexible, multi-step navigation required for complex files [6]. Historically, apply_patch maintained separate batch and streaming parsers, but these were unified into a single StreamingPatchParser to ensure consistent execution behavior and to eliminate discrepancies in how patches were processed [7].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target implementation ---'
sed -n '330,355p;400,420p' src/adapters/cursor/protobuf-events.ts
printf '%s\n' '--- focused references ---'
rg -n -C 4 'replacementHunk|oldString|newString|Update File|Begin Patch|apply_patch' src/adapters/cursor test tests 2>/dev/null | head -n 400 || true
printf '%s\n' '--- Codex-related dependency or source pin references ---'
rg -n -C 2 'openai/codex|codex-rs|apply.patch|apply_patch' package.json bun.lock* src/adapters/cursor README.md 2>/dev/null | head -n 300 || true

Repository: lidge-jun/opencodex

Length of output: 49716


🏁 Script executed:

#!/bin/bash
set -eu
base='https://raw.githubusercontent.com/openai/codex'
for path in \
  'main/codex-rs/apply-patch/src/parser.rs' \
  'main/codex-rs/apply-patch/src/lib.rs' \
  'main/codex-rs/apply-patch/apply_patch_tool_instructions.md' \
  'main/codex-rs/tools/src/apply_patch_tool.rs'
do
  printf '\n--- %s ---\n' "$path"
  curl -fsSL "$base/$path" | rg -n -C 5 'change_context|context_before|context_after|find|match|ambig|UpdateFileChunk|@@|NoMatch|multiple|unique' | head -n 500 || true
done

Repository: lidge-jun/opencodex

Length of output: 29361


🌐 Web query:

site:github.com/openai/codex "find_context" "change_context" apply-patch

💡 Result:

In the context of the Codex ecosystem's tooling, find_context and change_context are internal terms and components related to the apply_patch tool, which is a specialized utility used for applying file edits [1][2][3][4]. apply_patch is a command-line tool used to modify files via a structured, file-oriented patch format [3][4]. It operates by accepting a patch envelope containing specific file operations (Add, Delete, or Update) [3][4]. Within the apply_patch implementation (specifically in the Rust-based parser): - change_context: This refers to a field within an UpdateFileChunk structure [2][5]. It represents a line of code (such as a class, method, or function definition) used as a context anchor to locate the exact position in a file where an edit should be applied [2][5]. - Parsing/find_context: The process of locating this anchor in the source file is effectively the find_context operation. The parser identifies the change_context line to narrow down where the subsequent replacement lines (old_lines/new_lines) should be applied [2][6]. Recent developments have aimed to improve how the tool handles this context, such as adding support for multiple, sequential context lines to more accurately pinpoint edit locations [1]. Prior to such updates, the parser was limited to a single context header per hunk [6]. Note that these terms are internal implementation details of the apply_patch tool and are not standard generic shell commands [1][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- current Codex replacement algorithm ---'
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/codex-rs/apply-patch/src/lib.rs | sed -n '700,805p'
printf '%s\n' '--- seek_sequence implementation references ---'
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/codex-rs/apply-patch/src/lib.rs | rg -n -C 8 'seek_sequence|found =|Failed to find expected|ambig|multiple'
printf '%s\n' '--- pinned historical implementation ---'
for file in codex-rs/apply-patch/src/lib.rs codex-rs/apply-patch/src/parser.rs; do
  printf '\n--- %s ---\n' "$file"
  curl -fsSL "https://raw.githubusercontent.com/openai/codex/35aaa5d9/$file" | rg -n -C 8 'seek_sequence|change_context|old_lines|Failed to find expected|@@' | head -n 500
done

Repository: lidge-jun/opencodex

Length of output: 39743


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import urllib.request
urls = [
    "https://raw.githubusercontent.com/openai/codex/main/codex-rs/apply-patch/src/lib.rs",
    "https://raw.githubusercontent.com/openai/codex/35aaa5d9/codex-rs/apply-patch/src/lib.rs",
]
for url in urls:
    text = urllib.request.urlopen(url).read().decode()
    print("\n---", url, "---")
    lines = text.splitlines()
    for start, end in [(715, 800), (1, 30)]:
        print(f"[{start}:{end}]")
        for i in range(start, min(end, len(lines))):
            print(f"{i+1}:{lines[i]}")
PY

Repository: lidge-jun/opencodex

Length of output: 2443


🏁 Script executed:

#!/bin/bash
set -eu
for ref in main 35aaa5d9; do
  printf '\n--- Codex %s seek_sequence.rs ---\n' "$ref"
  curl -fsSL "https://raw.githubusercontent.com/openai/codex/$ref/codex-rs/apply-patch/src/seek_sequence.rs" | sed -n '1,260p'
done

Repository: lidge-jun/opencodex

Length of output: 11873


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- project test coverage for duplicate old_string behavior ---'
sed -n '136,230p' tests/cursor-structured-edit.test.ts
printf '%s\n' '--- patch line normalization ---'
sed -n '300,335p' src/adapters/cursor/protobuf-events.ts

Repository: lidge-jun/opencodex

Length of output: 5815


Preserve unique-match semantics for structured edits

Codex accepts @@ with no context lines, and *** Update File can contain this hunk. However, seek_sequence returns the first matching old_lines at or after the search index. It does not check uniqueness. A duplicated old_string can therefore modify the wrong occurrence without an error. Require a unique match, or provide an unambiguous context or position before emitting the patch at src/adapters/cursor/protobuf-events.ts:345 and :414.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/cursor/protobuf-events.ts` around lines 335 - 346, Update
replacementHunk and the related structured-edit generation near its call site to
preserve unique-match semantics: before emitting a context-free @@ hunk, ensure
oldString occurs exactly once in the target content, or require unambiguous
context/position information. Reject ambiguous matches instead of allowing
seek_sequence to select the first occurrence.

Comment on lines +386 to +395
const addReplacement = (record: Record<string, unknown>): StructuredEditTranslation => {
const oldString = firstStringArg(record, ["old_string", "oldString", "oldtext", "old_text"]);
const newString = firstStringArg(record, ["new_string", "newString", "newtext", "new_text"]);
if (oldString === undefined || newString === undefined) {
return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
}
const hunk = replacementHunk(oldString, newString);
if ("error" in hunk) return { error: hunk.error };
return { patch: hunk.hunk };
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Give addReplacement its own return type; StructuredEditTranslation.patch now means two different things.

StructuredEditTranslation is exported (line 356) and documented at lines 348-355 as a complete apply_patch payload: *** Begin Patch envelope, file header, hunks, *** End Patch. Line 394 reuses the same type to return a single bare @@ hunk. Line 407 then pushes that value into hunks, and line 414 wraps it.

The type therefore carries a payload that is valid at line 414 and invalid as an apply_patch input at line 394. Any future caller that reads a { patch } result from a helper in this file and relays it directly emits a hunk with no envelope, which the Codex client rejects. The two narrowing idioms in the same flow make this harder to notice: line 393 uses "error" in hunk and line 406 uses editResult.error !== undefined.

♻️ Proposed refactor: separate the hunk result from the payload result
+type HunkResult = { hunk: string; error?: undefined } | { error: string; hunk?: undefined };
+
   const hunks: string[] = [];
-  const addReplacement = (record: Record<string, unknown>): StructuredEditTranslation => {
+  const addReplacement = (record: Record<string, unknown>): HunkResult => {
     const oldString = firstStringArg(record, ["old_string", "oldString", "oldtext", "old_text"]);
     const newString = firstStringArg(record, ["new_string", "newString", "newtext", "new_text"]);
     if (oldString === undefined || newString === undefined) {
       return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
     }
-    const hunk = replacementHunk(oldString, newString);
-    if ("error" in hunk) return { error: hunk.error };
-    return { patch: hunk.hunk };
+    return replacementHunk(oldString, newString);
   };

Then update the two push sites to the same idiom:

       const editResult = addReplacement(edit as Record<string, unknown>);
       if (editResult.error !== undefined) return editResult;
-      hunks.push(editResult.patch);
+      hunks.push(editResult.hunk);
     const editResult = addReplacement(args);
     if (editResult.error !== undefined) return editResult;
-    hunks.push(editResult.patch);
+    hunks.push(editResult.hunk);

replacementHunk (line 336) then needs error?: undefined / hunk?: undefined on its return union so the !== undefined narrowing works.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/cursor/protobuf-events.ts` around lines 386 - 395, Give
addReplacement a dedicated result type representing either an error or a bare
hunk, rather than StructuredEditTranslation’s complete apply_patch payload;
update its callers and both hunk push sites to use consistent error !==
undefined narrowing. Adjust replacementHunk’s return union to explicitly define
error?: undefined and hunk?: undefined so this narrowing remains type-safe,
while preserving StructuredEditTranslation for the final wrapped payload.

Comment on lines +205 to +225
export function cursorStructuredEditTools(
tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
toolChoice?: OcxRequestOptions["toolChoice"],
): OcxTool[] {
if (!cursorRequestAdvertisesApplyPatch(tools, toolChoice)) return [];
if (toolChoice && toolChoice !== "auto" && toolChoice !== "required") return [];
return [
{
name: CURSOR_EDIT_FILE_TOOL,
description:
"Replace one block of exact text in a file. OpenCodex converts the replacement into a Codex apply_patch change, which the Codex client applies with its normal approval and sandbox policy. old_string must match the current file content exactly.",
parameters: { ...CURSOR_EDIT_FILE_INPUT_SCHEMA },
},
{
name: CURSOR_MULTI_EDIT_TOOL,
description:
"Apply several ordered exact-text replacements to one file. OpenCodex converts the edits into a single Codex apply_patch change, which the Codex client applies with its normal approval and sandbox policy. Each old_string must match the current file content exactly.",
parameters: { ...CURSOR_MULTI_EDIT_INPUT_SCHEMA },
},
];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify no existing dedupe by wire name protects the synthetic structured edit tools,
# and check whether any client-facing catalog path already filters duplicate names.
set -euo pipefail

fd -e ts . src/adapters/cursor --exec echo {} 

echo "=== cursorStructuredEditTools call sites ==="
rg -nP -C6 '\bcursorStructuredEditTools\s*\(' src tests

echo "=== dedupe-by-name logic in cursor adapter ==="
rg -nP -C4 'new Set\(.*cursorToolWireName|dedupe|uniqueBy|filter\(.*wireName' src/adapters/cursor

echo "=== buildCursorToolDefinitions definition ==="
ast-grep run --pattern 'export function buildCursorToolDefinitions($$$) { $$$ }' --lang typescript src/adapters/cursor/tool-definitions.ts

echo "=== isCursorStructuredEditToolName consumers ==="
rg -nP -C5 '\bisCursorStructuredEditToolName\s*\(' src

Repository: lidge-jun/opencodex

Length of output: 7818


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== tool generation and budget path ==="
sed -n '180,240p' src/adapters/cursor/tool-definitions.ts
sed -n '50,90p' src/adapters/cursor/request-builder.ts

echo "=== catalog serialization ==="
rg -n -C8 'function buildCursorToolDefinitions|buildCursorToolDefinitions|McpToolDefinition' src/adapters/cursor/tool-definitions.ts src/adapters/cursor

echo "=== structured-edit translation ==="
sed -n '470,540p' src/adapters/cursor/protobuf-events.ts
rg -n -C6 'translateStructuredEditCall|isCursorStructuredEditToolName|commitToolCall' src/adapters/cursor/protobuf-events.ts src/adapters/cursor

echo "=== collision-related tests and helpers ==="
rg -n -C8 'edit_file|multi_edit|applyCursorToolBudget|cursorToolWireName|duplicate' tests src/adapters/cursor

Repository: lidge-jun/opencodex

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

tool_defs = Path("src/adapters/cursor/tool-definitions.ts").read_text()
request_builder = Path("src/adapters/cursor/request-builder.ts").read_text()
events = Path("src/adapters/cursor/protobuf-events.ts").read_text()

print("=== deterministic collision model ===")
# Model the relevant expressions without importing or executing repository code.
catalog = [
    {"name": "apply_patch", "namespace": None, "freeform": True},
    {"name": "edit_file", "namespace": None, "freeform": False},
]
synthetic = ["edit_file", "multi_edit"] if (
    any(t["name"] == "apply_patch" and not t["namespace"] and t["freeform"] for t in catalog)
) else []
base_eligible = catalog
eligible = base_eligible + [{"name": n} for n in synthetic]
print("eligible wire names:", [t["name"] for t in eligible])
print("duplicate wire names:", sorted({n for n in [t["name"] for t in eligible] if [t["name"] for t in eligible].count(n) > 1}))

print("=== source invariants ===")
print("request builder concatenates baseEligible and synthetic:",
      "const eligible = [...baseEligible, ...synthetic]" in request_builder)
print("synthetic names are fixed:",
      all(name in tool_defs for name in ("CURSOR_EDIT_FILE_TOOL", "CURSOR_MULTI_EDIT_TOOL")))
print("translation checks structured name before translation:",
      "isCursorStructuredEditToolName(open.name)" in events)
print("translation has request-owned-name guard:",
      bool(re.search(r"isCursorStructuredEditToolName\(open\.name\).*?(?:\n.*?){0,12}(?:namespace|catalog|synthetic|owned)', events, re.S)))
PY

Repository: lidge-jun/opencodex

Length of output: 509


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== exact translation block ==="
nl -ba src/adapters/cursor/protobuf-events.ts | sed -n '490,530p'

echo "=== exact tool-definition serializer ==="
nl -ba src/adapters/cursor/tool-definitions.ts | sed -n '520,590p'

echo "=== relevant type/state definitions ==="
rg -n -C10 'CursorProtobufEventState|interface CursorProtobufEventState|type CursorProtobufEventState|open\.name|customTool' src/adapters/cursor/protobuf-events.ts src/adapters/cursor/types.ts

Repository: lidge-jun/opencodex

Length of output: 229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

def numbered(path, start, end):
    lines = Path(path).read_text().splitlines()
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{path}:{i}: {lines[i-1]}")

numbered("src/adapters/cursor/protobuf-events.ts", 500, 525)
numbered("src/adapters/cursor/tool-definitions.ts", 545, 580)
PY

Repository: lidge-jun/opencodex

Length of output: 6071


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== translation implementation ==="
rg -n -C18 'export function translateStructuredEditCall|function translateStructuredEditCall|isCursorStructuredEditToolName' src/adapters/cursor/protobuf-events.ts src/adapters/cursor/tool-definitions.ts

echo "=== event state and call registration ==="
rg -n -C14 'createCursorProtobufEventState|clientToolNames|toolSchemas|openToolCalls|McpToolCall' src/adapters/cursor/protobuf-events.ts tests/cursor-structured-edit.test.ts

echo "=== request budget implementation ==="
sed -n '60,100p' src/adapters/cursor/request-builder.ts

echo "=== collision test coverage ==="
rg -n -C5 'clientOwned|collision|duplicate.*edit|edit_file.*apply_patch|multi_edit.*apply_patch' tests/cursor-structured-edit.test.ts src/adapters/cursor

Repository: lidge-jun/opencodex

Length of output: 50375


Prevent synthetic tool-name collisions.

When a non-namespaced client tool is named edit_file or multi_edit, cursorStructuredEditTools emits a duplicate name. request-builder.ts:73 preserves both entries, and buildCursorToolDefinitions serializes both without deduplication. protobuf-events.ts:515 then translates calls for either name, so the client tool can be dropped or redirected to apply_patch.

Skip synthetic names owned by the client. Pass the per-request synthetic-name set to commitToolCall so only synthetic tools are translated. Add tests for both catalog construction and call translation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/cursor/tool-definitions.ts` around lines 205 - 225, Prevent
collisions in cursorStructuredEditTools by excluding synthetic edit_file and
multi_edit definitions when the request’s non-namespaced client tools already
use those names. Carry the per-request synthetic-name set through request
construction into commitToolCall, and make protobuf-events translation apply
only to names in that set. Add coverage for both tool catalog construction and
call translation, including client-owned name collisions.

Comment thread tests/cursor-structured-edit.test.ts Outdated
old_string: "line1\nline2",
new_string: "line1\nchanged\nline2",
});
expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, args))?.toMatchObject({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the optional chaining after expect(...); it hides the intent and invites a real false pass.

These lines call expect(value)?.toMatchObject(...). expect() always returns a matcher object, so ?. never short-circuits and the assertions do run today. The pattern is still worth correcting for two reasons:

  1. It reads as if the author meant to guard a possibly-undefined value. translateStructuredEditCall genuinely returns StructuredEditTranslation | undefined, so a reader reasonably assumes the guard is load-bearing. It is not.
  2. If the pattern is later copied onto the value instead of the matcher — expect(result?.patch).toBe(...) with result undefined — the assertion silently compares undefined and passes. The strict form at line 139 (expect(...).toEqual({ patch: ... })) has no such hazard, and lines 157, 174, 176, 181, and 200 should match it.
💚 Proposed fix: assert directly
-    expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, args))?.toMatchObject({
+    expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, args)).toEqual({
       patch: [
         "*** Begin Patch",
         "*** Update File: src/b.ts",

Apply the same change at lines 174, 176, 181, and 200. Prefer toEqual over toMatchObject where the full result is asserted, so an unexpected error key cannot slip through.

Also applies to: 174-176, 181-181, 200-200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cursor-structured-edit.test.ts` at line 157, Remove optional chaining
from the expect calls in the structured edit translation tests at the assertions
around lines 157, 174, 176, 181, and 200. Assert directly on the result, and use
toEqual instead of toMatchObject where the complete translation result is being
validated, matching the existing strict assertion pattern.

Comment on lines +215 to +223
test("rejects malformed structured edit calls instead of relaying invalid patch text", () => {
expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, "not json")?.error).toBeTruthy();
expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, JSON.stringify({ file_path: "src/f.ts" }))?.error).toBeTruthy();
expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, JSON.stringify({ file_path: "", old_string: "a", new_string: "b" }))?.error).toBeTruthy();
expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, JSON.stringify({ file_path: "src/f.ts", old_string: "", new_string: "b" }))?.error).toBeTruthy();
expect(translateStructuredEditCall(CURSOR_MULTI_EDIT_TOOL, JSON.stringify({ file_path: "src/f.ts", edits: [] }))?.error).toBeTruthy();
expect(translateStructuredEditCall(CURSOR_MULTI_EDIT_TOOL, JSON.stringify({ file_path: "src/f.ts", edits: [{ old_string: "a" }] }))?.error).toBeTruthy();
expect(translateStructuredEditCall("exec_command", JSON.stringify({ cmd: "echo hi" }))).toBeUndefined();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add the trailing-newline case to the malformed-input matrix.

The matrix covers invalid JSON, a missing replacement, an empty file_path, an empty old_string, an empty edits array, an incomplete edit entry, and a non-structured tool. One case is missing, and it is the one that fails silently rather than loudly:

// A trailing-newline-only change: patchLines drops the trailing newline on both sides,
// so the current implementation emits an identical -/+ pair instead of an error.
expect(translateStructuredEditCall(
  CURSOR_EDIT_FILE_TOOL,
  JSON.stringify({ file_path: "src/f.ts", old_string: "export {};\n", new_string: "export {};" }),
)?.error).toBeTruthy();

This test fails against the current patchLines behavior in src/adapters/cursor/protobuf-events.ts lines 329-333, which is the point: it pins the fix proposed there. Every other malformed input produces an error the model can act on; this one produces a no-op patch the model reads as success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cursor-structured-edit.test.ts` around lines 215 - 223, The malformed
structured-edit validation in translateStructuredEditCall must reject
trailing-newline-only changes instead of generating a no-op patch. Update the
patchLines logic used by CURSOR_EDIT_FILE_TOOL so removing or adding only a
final newline produces an error, while preserving valid structured edits and
existing malformed-input handling.

Comment on lines +228 to +232
const state = createCursorProtobufEventState({
clientToolNames: [CURSOR_EDIT_FILE_TOOL, "apply_patch"],
toolSchemas: new Map([[CURSOR_EDIT_FILE_TOOL, CURSOR_EDIT_FILE_INPUT_SCHEMA]]),
cursorToolNameMap: new Map([[CURSOR_EDIT_FILE_TOOL, CURSOR_EDIT_FILE_TOOL]]),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The identity cursorToolNameMap makes both stateful tests blind to the wire-name translation bug.

Line 231 and line 269 set cursorToolNameMap: new Map([[CURSOR_EDIT_FILE_TOOL, CURSOR_EDIT_FILE_TOOL]]) — an identity mapping. With identity, the Cursor wire name and the Responses name are the same string, so translateStructuredEditCall(open.name, ...) at src/adapters/cursor/protobuf-events.ts line 515 succeeds even though it passes the wrong namespace. The mismatch with the stateless path (which maps the name first at line 438) is invisible.

Add a stateful case with a non-identity mapping, so the two paths are held to the same contract:

test("stateful path converts a renamed edit_file wire tool", () => {
  const wireName = "mcp_opencodex-responses_edit_file";
  const state = createCursorProtobufEventState({
    clientToolNames: [wireName, "apply_patch"],
    toolSchemas: new Map([[wireName, CURSOR_EDIT_FILE_INPUT_SCHEMA]]),
    cursorToolNameMap: new Map([[wireName, CURSOR_EDIT_FILE_TOOL]]),
  });
  const toolCall = mcpToolCall(wireName, { file_path: "src/a.ts", old_string: "old", new_string: "new" });
  const events = mapCursorProtobufServerMessage(interaction({
    case: "toolCallCompleted",
    value: create(ToolCallCompletedUpdateSchema, { callId: "call_3", modelCallId: "model_3", toolCall }),
  }), state);
  expect(events[0]).toEqual({ type: "tool_call_start", id: "call_3", name: "apply_patch" });
});

Confirm the exact wire-name form against cursorToolWireName before pinning the string.

Two smaller points in the same range:

  • Line 275 repeats the expect(...)?.toEqual(...) pattern. Use expect(...).toEqual(...).
  • multi_edit has no stateful or native-exec coverage. Only edit_file is driven through the event paths, and multi_edit is the branch with the loop and the per-entry validation at protobuf-events.ts lines 401-408.

Based on path instructions: "A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem."

Also applies to: 265-278

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cursor-structured-edit.test.ts` around lines 228 - 232, The stateful
protobuf event tests use identity tool-name mappings and do not cover renamed
wire tools or multi_edit behavior. In the stateful tests around
createCursorProtobufEventState, add focused cases using the exact non-identity
name from cursorToolWireName and verify translation to apply_patch, plus
coverage for multi_edit loop and per-entry validation; also change the repeated
optional-chaining assertion to direct expect(...).toEqual(...).

Source: Path instructions

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e1e17328b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +72 to +73
const synthetic = cursorStructuredEditTools(catalog, toolChoice);
const eligible = [...baseEligible, ...synthetic];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve existing edit_file tools before adding synthetic ones

When the incoming Responses catalog already contains a bare edit_file or multi_edit function together with freeform apply_patch, this appends synthetic tools with the same Cursor wire names, so Cursor sees duplicate definitions and any completed call for the real tool is later treated as a structured edit and converted to apply_patch (or dropped as malformed). Please skip these synthetic tools when their wire names already exist, or give the synthetic bridge non-colliding names, so existing client tool calls remain reachable.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Comment on lines +405 to +407
const editResult = addReplacement(edit as Record<string, unknown>);
if (editResult.error !== undefined) return editResult;
hunks.push(editResult.patch);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve multi_edit ordering when converting patches

For a multi_edit whose entries are valid sequential edits but are not sorted by original file position, or where a later old_string is introduced by an earlier new_string, appending each edit as one hunk in a single apply_patch makes Codex match the original file in hunk order and the call fails to find expected lines. Please either apply/sort the replacements before building the patch, or reject unsupported ordered edits instead of advertising this as ordered multi_edit behavior.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Comment thread src/adapters/cursor/protobuf-events.ts Outdated
Comment on lines +343 to +345
const removed = patchLines(oldString).map(line => `-${line}`);
const added = patchLines(newString).map(line => `+${line}`);
return { hunk: ["@@", ...removed, ...added].join("\n") };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve no-newline structured replacements

When edit_file or multi_edit replaces the final contents of a file that lacks a trailing newline, this conversion emits ordinary + patch lines, and Codex applies those as newline-terminated lines; for example new_string: "bar" becomes bar\n instead of the exact requested bytes. Since the structured edit schema promises exact text replacements including line breaks, please preserve EOF-newline state or reject/route edits that cannot be represented as this line-based patch.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

@Wibias

Wibias commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Hey, thanks for this PR. Please test run that on a local dev server so we dont break something else by accident since cursor tool calls are pretty fragile. After you can give us some validation that this fixes the problem and doesnt implement new one's we will review it. Thanks.

@Wibias
Wibias marked this pull request as draft August 5, 2026 01:35
@ZachDreamZ

Copy link
Copy Markdown
Author

Validation run, as requested:

Local dev server (built from this branch)

  • Booted the proxy from the checkout with an isolated \OPENCODEX_HOME\ on port 10101 (the production 10100 instance was left untouched; it stayed healthy throughout).
  • \GET /v1/models\ -> 200 with the provider catalog.
  • \POST /v1/responses\ with \opencode-free/deepseek-v4-flash-free\ -> 200, \status: completed, output \DEV-OK, full usage reported. So the new code boots and serves real requests without regressing the non-Cursor path.

Cursor tool-call path (no live Cursor credential available on this machine, so validated at the transport level)

The new regression suite simulates Cursor server frames through the real protobuf event pipeline and asserts the exact wire output:

  • \�dit_file\ / \multi_edit\ calls emitted as \�pply_patch\ with a valid *** Begin Patch\ payload (verified byte-for-byte);
  • the native-exec \mcpArgs\ path (\planMcpArgsHandling) produces the same translated call;
  • malformed calls (bad JSON, missing fields, empty \old_string, empty \�dits[]) are dropped with a clear error instead of being relayed for local rejection after HTTP 200;
  • tool-budget pinning keeps the synthetic tools alongside \�pply_patch, explicit tool_choice is never widened, and a pre-existing bare \�dit_file\ in the client catalog is not shadowed.

Gates

  • \�un run typecheck\ / \�un run lint:gui\ / \�un run privacy:scan\ all pass.
  • \�un test tests/cursor-structured-edit.test.ts\ -> 18 pass (new).
  • 238 related cursor/bridge tests across 14 files -> all pass.

If you have a Cursor session handy, a live \cursor/grok-4.5\ edit trial would be the strongest confirmation — happy to iterate on anything it surfaces.

@Wibias

Wibias commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

[GD] Verdict: gated

TLDR

  • PR: fix(cursor): structured edit tools convert to valid apply_patch calls (#1017) #1036fix(cursor): structured edit tools convert to valid apply_patch calls (#1017)
  • Head: e80c4061 on dev (mergeStateStatus: UNSTABLE, mergeable: MERGEABLE, draft)
  • Decision: gated — the PR is a GitHub draft, 11 bot review threads are unresolved, and required CI has never run on this head (fork-PR runs await maintainer approval)
  • Usefulness: high — directly addresses [Bug]: Cursor adapter consistently emits invalid Codex apply_patch payloads #1017 and the older grok-4.5 don‘t know how to use apply_patch tool #372: cursor/grok-4.5 cannot emit Codex's freeform patch grammar, and this adds Cursor-shaped edit_file / multi_edit tools converted server-side into valid apply_patch payloads, with malformed calls dropped instead of relayed
  • Bugs: 5 concrete findings verified at head — trailing-newline-only edits silently no-op (patchLines in protobuf-events.ts); context-free @@ hunks can edit the wrong duplicate occurrence; multi_edit does not preserve ordered/sequential edit semantics; EOF-without-newline replacements are emitted newline-terminated; client-owned bare edit_file / multi_edit calls are still hijacked into apply_patch (catalog collision fixed in 2b737025, call-translation ownership not)
  • Security: none — no auth/credential surface changed; payloads relay over the existing apply_patch channel; bun run privacy:scan passes
  • Spec / standards: one overclaim in the PR body ("never truncated away" — the byte ceiling can still drop the pinned tools) plus the schema-vs-translation contract gaps above; no docs-site/ update for a user-facing behavior change
  • Reviews: 8 CodeRabbit + 3 Codex connector threads, all unresolved; 3 verified fully valid, 1 partially fixed (collision), 3 real limitations needing a decision or documentation, 2 test/refactor gaps; the human validation exchange (Wibias to author) is only partially fulfilled — no live Cursor session edit trial yet
  • Base / CI: head is 53 commits behind dev with no overlapping file changes (owner action: update from latest base). Required CI (Cross-platform CI, React Doctor) is action_required — no matrix job has run on this head
  • Gate: draft + unresolved review threads + CI not run
  • Owner actions (foreign PR): after fixing the verified findings, update from latest dev, get CI approved and green, and complete a live cursor/grok-4.5 edit trial (the validation Wibias requested)
  • Bottom line: useful and worth landing, but not merge-ready — the author should fix the trailing-newline no-op, the translation ownership guard, and either implement or explicitly document the multi_edit ordering and EOF-newline limits, then update from dev and get CI green before marking ready.
Full verdict

Semantic propagation

  • Concepts audited: structured-edit tool advertisement (cursorStructuredEditTools, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA); structured-edit call translation (translateStructuredEditCall, replacementHunk, patchLines); wire-name mapping (normalizeCursorWireName, responsesToolNameFromCursorWire, cursorToolNameMap); budget pinning (toolPriority priority 1); guidance note (buildCursorToolGuidanceSystemNote); native-mutation refusal (codexNativeMutationRefusal); tool-choice narrowing; synthetic-vs-client-owned name ownership.
  • Authoritative sources: tool-definitions.ts (schemas + advertisement + guidance), protobuf-events.ts (translation + event paths), request-builder.ts (budget), native-exec-fs.ts (refusal), live-transport.ts + protobuf-request.ts (state construction, clientToolDefs, guidance wiring).
  • Producers and consumers checked: applyCursorToolBudget to request.tools to live-transport (clientToolDefs via buildCursorToolDefinitions) and protobuf-request (guidance note, built from the same cursorToolsForActivePrompt set); translateStructuredEditCall consumed by commitToolCall (stateful interactionUpdate) and mapSyntheticMcpExecToToolEvents (stateless + native-exec mcpArgs via planMcpArgsHandling); the new exports are imported only inside the cursor adapter and its tests — no external consumers.
  • Public/derived representations checked: the emitted apply_patch custom tool call { "input": "*** Begin Patch ... *** End Patch" } — shape verified byte-for-byte in tests and matches the [architecture][memory] Make 32 concurrent tool-recall sessions protocol-safe and memory-bounded #820 invariant (freeform { input } restored as custom_tool_call); guidance and refusal text are the other public representations.
  • Material variant partitions checked: edit_file vs multi_edit; auto / required vs forced / allow-listed tool_choice; bare vs namespaced apply_patch; collision vs no-collision catalogs; stateful vs stateless vs native-exec paths; malformed vs valid calls; single vs multi-line replacements; deletion (new_string: "").
  • Positive and negative assertions checked: no synthetic tools without an advertised bare freeform apply_patch; none for forced / allow-listed choices; none for namespaced apply_patch; catalog-side shadow skip for client-owned names (tested); call-translation ownership guard — absent (gap); trailing-newline-only change rejection — absent (gap); multi_edit ordered-edit and EOF-no-newline semantics — unimplemented and undocumented (gaps).
  • Unmapped surfaces: cursorToolsForActivePrompt tool-count-demo narrowing can drop the structured tools from the visible catalog while rejectNativeFileMutations still fires with a message naming them (the refusal-availability minor); catalogLimitNote counts synthetic tools in kept.length (cosmetic, truncation path only); docs-site/ has no note for the new user-facing behavior.
  • Unproven equivalence assumptions: stateful tests use an identity cursorToolNameMap, so the wire-name folding path (mcp_opencodex-responses_edit_file to edit_file) is not covered through events; multi_edit has no stateful / native-exec event-path coverage; one representative replacement is used without proving equivalence for the trailing-newline, duplicate-old_string, and EOF-no-newline cases.
  • Representation mismatches: StructuredEditTranslation.patch is documented and exported as the complete apply_patch payload but addReplacement returns a bare @@ hunk under the same type; new_string: "bar" is represented as newline-terminated +bar regardless of the file's EOF state.
  • Coverage gaps: trailing-newline-only edit (silent no-op), client-owned edit_file call translation, multi_edit dependent / out-of-order edits, EOF-no-newline replacement, non-identity wire-name mapping through the stateful path, and a multi_edit event-path test.
  • Axis verdict: blocked — see Bugs / correctness for the concrete items.

Usefulness

High. Issue #1017 (and its older sibling #372) documents a real, reproducible failure: cursor/grok-4.5 (and other Cursor-trained models) cannot emit Codex's freeform *** Begin Patch grammar, so every edit attempt on the Cursor route produced malformed patch text that Codex rejected locally after the request already returned HTTP 200. This PR implements option 2 from the issue — provider-compatible structured edit tools converted server-side — which is the right architectural fix, and it keeps the Codex approval / sandbox boundary intact by always emitting apply_patch on the wire. The malformed-call rejection removes the silent 200-then-local-rejection mode. Verified locally at head e80c4061: bun run typecheck pass, bun run privacy:scan pass, tests/cursor-structured-edit.test.ts 18/18, 14-file cursor suite 238/238. The PR body's stated gates match my runs.

Bugs / correctness

Method: bug-review.md — Bugbot n/a (Codex host); static review plus targeted reproduction scripts run against the head worktree; complementary edge-case probes (silent failures, edge cases) run directly on translateStructuredEditCall.

Verified findings (all reproduced against e80c4061):

  1. Trailing-newline-only edits are a silent no-oppatchLines pops one trailing empty element on each side independently, so old_string: "export {};\n" to new_string: "export {};" emits @@ / -export {}; / +export {};: the newline change is discarded and the model reads success while nothing changes. Same for adding a lone trailing newline. The schema promises "exact text replacement including line breaks". Fix: compare the normalized patchLines results and return an actionable error when identical; add the malformed-matrix case. (CodeRabbit threads 3717200617, 3717200646 — valid.)
  2. Client-owned edit_file / multi_edit calls are still hijacked — commit 2b737025 fixed the catalog side (synthetic tools are skipped when the client catalog already has a bare same-named tool), but translation keys only on the name (isCursorStructuredEditToolName(open.name) and the stateless translateStructuredEditCall(responsesName, ...)), so a call to a client-owned bare edit_file is still rewritten into apply_patch (verified: args with non-schema fields still translate). Fix: carry the per-request synthetic-name set into commitToolCall and mapSyntheticMcpExecToToolEvents and translate only those names; add a call-translation collision test. (CodeRabbit 3717200638, Codex 3717205360 — partially fixed at head.)
  3. multi_edit does not preserve ordered / sequential edit semantics — each edit becomes a separate @@ hunk in one *** Update File section applied against the original file. Verified: edits: [{a→b},{b→c}] emits -a/+b then -b/+c; the second hunk cannot match because b does not exist in the original file. Cursor's native MultiEdit applies edits sequentially, so the advertised "ordered exact-text replacements" promise does not hold. The proxy has no workspace file access to pre-apply or sort. Fix or decide: reject edits whose old_string appears inside an earlier edit's new_string, and document that hunks must all match the current file content independently; or drop the "ordered" claim from the schema description. (Codex 3717205367 — valid limitation.)
  4. EOF-without-newline replacements become newline-terminatednew_string: "bar" is emitted as +bar, which Codex applies as bar\n, violating the exact-bytes replacement contract for a file whose final line lacks a trailing newline. Fix or decide: document the limitation in the schema / description, or reject when new_string / old_string end without a newline and the hunk is line-based. (Codex 3717205371 — valid limitation.)
  5. Context-free @@ hunks can edit the wrong duplicate occurrence — Codex's seek_sequence picks the first match without a uniqueness check, so a duplicated old_string silently modifies the wrong spot. The proxy cannot verify uniqueness without file access. Decision needed: document the first-occurrence semantics in the tool description (Cursor-native Edit behaves the same way) and / or advise models to include surrounding context inside old_string. (CodeRabbit 3717200621 — valid limitation.)
  6. Refusal message names edit_file / multi_edit when they are not advertisedcodexNativeMutationRefusal has no availability input; on forced / allow-listed tool_choice, byte-ceiling drops, or tool-count-demo narrowing the message still names tools absent from the catalog, inviting a hallucinated call. Fix: thread a structuredEditAvailable flag from the same per-request state into both call sites. (CodeRabbit 3717200611 — valid minor.)
  7. addReplacement reuses StructuredEditTranslation with mismatched semantics — the exported type documents a complete payload but { patch } here is a bare hunk; two narrowing idioms in one flow. Refactor to a dedicated HunkResult type. (CodeRabbit 3717200634 — valid code-quality.)
  8. Optional chaining after expect(...) in the new tests (lines 165–208) — runs today but invites false-pass copies; switch to direct toEqual. (CodeRabbit 3717200642 — trivial.)
  9. Test coverage gaps — stateful tests use identity cursorToolNameMap (no non-identity wire-name case) and no multi_edit stateful / native-exec coverage; the trailing-newline malformed case is missing. (CodeRabbit 3717200651, 3717200646 — valid.)

Not reproduced / declined:

  • No issue found on the non-Cursor path (the changed code is unreachable outside the Cursor route; the author's boot + non-Cursor POST validation is consistent with the code).
  • The "synthetic tools never truncated away" claim in the PR body overstates: they are pinned at priority 1, but the hard byte ceiling can still drop them in an extreme catalog (tryKeep returns false on bytes), which would also leave the guidance note naming tools that were omitted. Minor.

Fixed this session: none (foreign PR, review-only run).

Security

  • Scope reviewed: tool-call relay surface, prompt / guidance text; no auth / credential handling changed.
  • Findings: none. The adapter relays only patch text over the pre-existing apply_patch channel; refusal / guidance strings are static; bun run privacy:scan passes at head. No secret, token, or body logging introduced. The new tool schemas accept file paths and text — the same exposure apply_patch already had.
  • Fixed this session: none.

Spec / standards

Reviews

  • Humans: Wibias asked for a local dev-server test run plus validation that the fix works and introduces no regressions; ZachDreamZ provided a validation run (isolated proxy boot on port 10101, /v1/models 200, non-Cursor /v1/responses 200 DEV-OK, transport-level Cursor validation via the regression suite, all stated gates green). Assessment: the reported boot / test results match what I reproduced locally; the specific ask — a live cursor/grok-4.5 edit trial — is still open (the author explicitly notes no live Cursor credential was available).
  • Bots: CodeRabbit (review on e1e17328) posted 8 inline comments; Codex connector posted 3 P2 comments; all 11 threads unresolved. Verified against head: 3 fully valid, 1 partially fixed, 3 real limitations needing decision / documentation, 2 test / refactor gaps (details above). No bot finding was a false positive.
  • No human thread received an agent reply in this run (review-only; the verdict aggregates).

Base / CI

  • Behind / conflicts: head e80c4061 is 53 commits behind dev (merge-base ab5b20ca, 2026-08-05); no post-branch-point dev commit touches any of the 5 changed files; GitHub reports MERGEABLE. Owner action (foreign PR): update from latest dev.
  • Required checks: enforce-target pass and label pass only. Cross-platform CI and React Doctor runs exist but are action_required — they wait for a maintainer approval because this is a first-time-contributor fork PR; no matrix job has executed on this head, so the repo's typecheck / test / lint / privacy matrix is unproven on this head. Maintainer action: approve the two runs once the author fixes findings.
  • Local tip compile / tests: bun run typecheck pass; bun test tests/cursor-structured-edit.test.ts 18/18; 14-file cursor / bridge suite 238/238; bun run privacy:scan pass. Full local suite was still running at publication time and is not relied on.
  • Authoritative gate (ship-gate.mjs, mutation-mode review, workflow references/full-review-pr.md): blockedreviewPolicy:draft + reviewThreads:unresolved_review_threads. Base-health advisory: 2 base-tip failures not present on this head (track separately; do not expand this PR).

Gate

Draft (GitHub draft PR) + 11 unresolved review threads + required CI not run (action_required). Not merge-ready under any reading.

Bottom line

This is a good, well-scoped answer to #1017 / #372 with honest engineering: the server-side conversion approach is right, the malformed-call rejection fixes the silent failure mode, the new suite is meaningful, and the local gates reproduce. It should land after the author fixes the silent trailing-newline no-op, adds the translation ownership guard, and either implements or explicitly documents the multi_edit ordering and EOF-newline limits; then updates from latest dev, gets CI approved and green, and runs the live cursor/grok-4.5 edit trial that was requested. Maintainers: approve the CI runs and re-review after those changes.

@Wibias

Wibias commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

[GD] Validation conclusion

The requested local dev-server run was delivered: the branch boots in isolation (port 10101, production 10100 untouched and healthy), serves a real request (GET /v1/models 200, POST /v1/responses 200 DEV-OK), and the reported #1017 failure is fixed at the transport level — edit_file / multi_edit convert to valid apply_patch payloads and malformed calls are dropped with a clear error instead of being relayed for local rejection. The stated gates (typecheck, privacy:scan, 18 new + 238 cursor tests) reproduce locally.

Still open before maintainer review:

  • "Does not implement new ones" is not yet met — the earlier verdict lists five new edge-case behaviors this validation suite does not exercise.
  • Required CI has never run on this head (the fork-PR runs await maintainer approval).
  • A live cursor/grok-4.5 edit trial is still pending (no Cursor credential was available on the author's machine).

Address those (or explicitly decline each with rationale), update from latest dev, and run the live trial — then we approve CI and review.

@ZachDreamZ

Copy link
Copy Markdown
Author

Addressed review feedback — commit a35115c

Thanks @coderabbitai and @chatgpt-codex-connector for the thorough review. All actionable items are addressed in the latest push.

CodeRabbit comments

1. native-exec-fs.ts:42-44 — refusal hint gating
codexNativeMutationRefusal(operation, structuredEditAvailable) now only mentions edit_file/multi_edit when those tools are actually advertised. The flag is threaded from live-transport.ts via cursorRequestAdvertisesStructuredEdits()execContext.structuredEditAvailablehandleCursorNativeExec.

2. protobuf-events.ts:328-346 — trailing-newline no-op
replacementHunk now compares normalized oldLines/newLines and returns an error for identical content (covers both trailing-newline-only and literal identical pairs). Added two test cases.

3. protobuf-events.ts:335-346 — ambiguous context-free @@ hunks
This is a real limitation of translating exact-match replacements to line-based hunks without file-content access. Documented it in both tool descriptions (tool-definitions.ts): edits must match at exactly one location, and matching is line-based. The translation layer doesn't have file content available to do a uniqueness check at this layer — that's a schema-level contract on the model, enforced by apply_patch's own ambiguity rejection downstream.

4. protobuf-events.ts:386-395addReplacement return type
Now returns StructuredEditTranslation (single type) instead of overloading patch to mean both a bare hunk and a full payload. Call sites use consistent error !== undefined narrowing.

5. tool-definitions.ts:205-225 — collision protection ✅ (already fixed in 2b73702, verified)

6. Test assertions — remove ?. after expect()
All expect(...)?.toMatchObject(...) replaced with expect(...).toEqual(...) or expect(...).toEqual(expect.objectContaining(...)).

7. Trailing-newline test case
Added rejects a trailing-newline-only edit as a silent no-op and rejects identical old/new as a no-op.

8. Stateful non-identity wire-name tests
Added translates a non-identity wire-name mapping (Cursor display name -> Codex tool name) for multi_edit covering the mcp_opencodex-responses_multi_edit display-name path.

Codex connector comments

  • P2 request-builder.ts:73 — duplicate synthetic tools ✅ (already fixed in 2b73702)
  • P2 protobuf-events.ts:407 — multi_edit ordering — Documented in the multi_edit tool description: every old_string is matched against the ORIGINAL file content, so a later edit must not rely on text introduced by an earlier one. This is the correct contract since the translation layer emits independent hunks.
  • P2 protobuf-events.ts:345 — no-newline replacements — Documented in both tool descriptions: matching is line-based, so an edit cannot add or remove only the file's final newline; such edits are now rejected as no-ops.

Verification

  • 21 focused tests pass (bun test tests/cursor-structured-edit.test.ts)
  • bun run typecheck clean
  • bun run lint:gui clean
  • bun run privacy:scan passed

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current revision =='
git rev-parse --short HEAD
git show -s --format='%h%n%s%n%b' HEAD

printf '%s\n' '== Changed files at a35115c3, if available =='
git show --stat --oneline a35115c3 2>&1 || true

printf '%s\n' '== Source outlines =='
for f in \
  src/adapters/cursor/native-exec-fs.ts \
  src/adapters/cursor/live-transport.ts \
  src/adapters/cursor/protobuf-events.ts \
  src/adapters/cursor/tool-definitions.ts \
  tests/cursor-structured-edit.test.ts
do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all 2>&1 || true
  else
    echo "MISSING: $f"
  fi
done

printf '%s\n' '== Relevant definitions and tests =='
rg -n -C 5 --glob '*.ts' \
  'codexNativeMutationRefusal|structuredEditAvailable|cursorRequestAdvertisesStructuredEdits|replacementHunk|addReplacement|translateStructuredEditCall|CURSOR_EDIT_FILE_INPUT_SCHEMA|CURSOR_MULTI_EDIT_INPUT_SCHEMA|trailing-newline-only|non-identity wire-name|ORIGINAL file content|exactly one location|final newline' \
  src/adapters/cursor tests/cursor-structured-edit.test.ts

Length of output: 57270


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Event-state construction and translation ownership =='
sed -n '120,195p' src/adapters/cursor/protobuf-events.ts
sed -n '428,545p' src/adapters/cursor/protobuf-events.ts

printf '%s\n' '== Final visible-tool catalog and native-exec context =='
sed -n '510,575p' src/adapters/cursor/live-transport.ts
sed -n '175,245p' src/adapters/cursor/tool-definitions.ts
sed -n '386,425p' src/adapters/cursor/tool-definitions.ts

printf '%s\n' '== Client-owned structured-tool call tests and availability tests =='
rg -n -C 6 --glob '*.test.ts' --glob '*.ts' \
  'client-owned|shadowed|collision|structuredEditAvailable|cursorRequestAdvertisesStructuredEdits|cursorVisibleTools|mapSyntheticMcpExecToToolEvents\(' \
  tests src/adapters/cursor

Length of output: 122714


@ZachDreamZ: I verified commit a35115c3 by static inspection.

The following fixes are present:

  • src/adapters/cursor/protobuf-events.ts rejects normalized no-op replacements. This covers identical strings and final-newline-only changes.
  • src/adapters/cursor/protobuf-events.ts gives addReplacement the single StructuredEditTranslation return type.
  • src/adapters/cursor/tool-definitions.ts documents line-based matching, ambiguity rejection, original-file multi_edit semantics, and final-newline limits.
  • src/adapters/cursor/native-exec-fs.ts uses the structuredEditAvailable flag in the refusal text.
  • tests/cursor-structured-edit.test.ts contains the no-op and non-identity wire-name regression cases.

Two prior findings remain open.

  1. Client-owned edit_file and multi_edit calls are still translated to apply_patch.

    In src/adapters/cursor/protobuf-events.ts, Line 457 calls translateStructuredEditCall(responsesName, normalizedArgs) in the stateless path. Line 526 calls translateStructuredEditCall(open.name, finalArgs) in the stateful path.

    translateStructuredEditCall() decides only from isCursorStructuredEditToolName(toolName) at Line 375. CursorProtobufEventState stores advertised tool names, schemas, and wire-name mappings. It does not store the set of synthetic structured tool names.

    As a result, a client-owned bare edit_file tool is not added as a synthetic tool by cursorStructuredEditTools(), but a call to that client-owned tool is still converted when its name is edit_file. This changes the client tool contract and can discard client-specific arguments.

    Please carry a per-request synthetic structured-tool name set into CursorProtobufEventState and the stateless mapSyntheticMcpExecToToolEvents() path. Translate only when the resolved tool name is in that set. Add stateful and native-exec regression tests that assert a client-owned bare edit_file call remains an edit_file call with unchanged arguments.

  2. The refusal hint still uses the request catalog instead of the final advertised catalog.

    In src/adapters/cursor/live-transport.ts, Line 542 derives cursorVisibleTools from cursorToolsForActivePrompt(). Line 543 builds the actual clientToolDefs from that filtered catalog.

    However, Line 548 computes structuredEditAvailable from request.tools. When prompt-specific narrowing removes apply_patch and the synthetic tools from cursorVisibleTools, the native write/delete refusal can still instruct Cursor to use edit_file or multi_edit. Those tools are not in clientToolDefs.

    Please compute both rejectNativeFileMutations and structuredEditAvailable from cursorVisibleTools, or derive the flags directly from clientToolDefs. Add a regression test for the prompt-narrowing path.

I cannot mark the review feedback fully addressed until these two paths preserve tool ownership and final-catalog availability.

🐇


✏️ Learnings added
Learnt from: ZachDreamZ
URL: https://github.com/lidge-jun/opencodex/pull/1036

Timestamp: 2026-08-05T07:24:42.658Z
Learning: In the Cursor adapter, synthetic `edit_file` and `multi_edit` tools must be distinguished from client-owned tools with the same bare names. Catalog collision avoidance alone is insufficient because tool-call translation must convert only names that were synthetically advertised for the current request.

Learnt from: ZachDreamZ
URL: https://github.com/lidge-jun/opencodex/pull/1036

Timestamp: 2026-08-05T07:24:42.658Z
Learning: In `src/adapters/cursor/live-transport.ts`, availability messages for Cursor structured edit tools must use the final `cursorVisibleTools` or `clientToolDefs` catalog. `request.tools` can differ after `cursorToolsForActivePrompt()` narrows the visible tool set.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@lidge-jun lidge-jun left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the structured edit_file/multi_edit + server-side translation approach is the right direction for #1017, and we want to land it. Two gaps block it today:

  1. Synthetic-tool provenance. Conversion keys on the bare tool name. If a client already exposes its own edit_file, its calls would be translated too. Track the synthetic names injected for this request (a per-request set threaded from tool-catalog construction to the conversion site in src/adapters/cursor/protobuf-events.ts / live-transport.ts) and convert only those.
  2. Final-catalog derivation. Structured-edit availability is derived from the original request rather than the final prompt-filtered tool catalog; when filtering drops the tools the flag is stale.

A regression test for each (client-owned edit_file passes through untouched; filtered catalog disables translation) and we can re-review it.

@lidge-jun

Copy link
Copy Markdown
Owner

Re-reviewed against current dev. The compatibility gap you are closing is real — the Cursor route advertises freeform apply_patch (src/adapters/cursor/tool-definitions.ts:135-141) while dev has no structured-edit translation, which is exactly #1017. The conversion itself reads carefully: validated JSON, explicit drop errors instead of silent failure, and the same treatment on both the interactionUpdate and native-exec paths so the two cannot diverge.

The prior review asked for synthetic-tool provenance. This is that finding again, now with a reproduction, so I want to be concrete rather than repeat the phrase.

The gap. translateStructuredEditCall decides provenance from the tool NAME alone:

if (!isCursorStructuredEditToolName(toolName)) return undefined;

and both call sites pass a name that arrived from the wire — mapSyntheticMcpExecToToolEvents passes responsesName, and the completion path passes open.name. Neither consults whether this request actually advertised the synthetic tools. Your own cursorStructuredEditTools takes care not to shadow a real client tool with either name, so the collision is already understood as possible in the injection path; the translation path just does not carry that knowledge forward.

Reproduction. A user runs an MCP server exposing edit_file — not an unusual name; several editor-adjacent MCP servers use it. Their call arrives named edit_file, gets JSON-parsed as {file_path, old_string, new_string}, and one of two things happens:

  • the shape happens to match, and the call is silently re-emitted as apply_patch against a file path meant for a different tool; or
  • the shape does not match, and the user gets edit_file call was not converted to apply_patch: edit_file is missing a non-empty file_path; the call was dropped — an error naming a conversion they never asked for, about a tool that was working before they enabled Cursor.

The second is the more likely and the more confusing: the message describes our machinery, not their world.

What I am asking for. Tag the synthetic tools at injection time and convert only calls carrying that tag. The state object already threads through both paths — CursorProtobufEventState has a natural place for "synthetic edit tools were advertised this request", and the diff already adds a comment describing exactly that flag (The synthetic exact-match edit tools (edit_file / multi_edit) are advertised this request), so the intent is there and only the enforcement is missing. Gate translateStructuredEditCall on it, and pass through untouched when the flag is absent.

Please add the regression test that would have caught this: a client tool named edit_file present in the request while the synthetic tools are NOT advertised, asserting the call passes through unconverted. The current tests exercise conversion mechanics thoroughly, which is good, but every one of them assumes our own tool is the caller — so they raise confidence about a case that cannot go wrong while leaving the one that can uncovered.

Everything else here I would take as is. The drop-with-explanation behavior is the right call over silent best-effort patching, and applying the conversion at the protobuf event boundary rather than in the adapter shell keeps it out of the generic path.

One process note: the failing enforce-target check on this PR is not your code — it is a repository-side gate artifact where a stale head's workflow YAML calls into base-pinned scripts. Rebase onto current dev and it clears; do not spend time chasing it.

lidge-jun added a commit that referenced this pull request Aug 6, 2026
Second loop of the bug campaign, scoped to author corrected replacements for
four contributor PRs and close theirs as absorbed. Three adversarial audit
rounds refuted the premise, and the plan now records that instead of the
outcome it was written to produce.

The decisive finding: I judged "has the author responded to our review" by
`updatedAt`, which moves when WE comment and therefore can never show author
activity. Comparing last-commit time against review time gives the real
picture:

    #1092  commits 09:38:19Z  review 09:09:51Z  -> acted, in under 30 minutes
    #1068  commits 08:52:23Z  review 09:13:15Z  -> predates the review
    #1036  commits 08-05      review 09:12:51Z  -> no response yet
    #997   commits 02:51Z     review 09:16:02Z  -> no response yet

Under the wrong reading I was about to close #1092 as absorbed — taking
credit for work its author did in direct response to my own request — and to
credit #1068 with agreeing to a review it had not yet read. Neither is a
process nit; both would have been visible to the contributor.

So the plan changes shape. #1092 and #1068 become re-reviews. #1036 and #997
get a stated 72-hour response window with a mandatory head re-check before
anything is authored or closed; "has not replied within an hour" is not
abandonment, and #997's author was active at 02:51Z. This loop therefore
authors no absorbed layer at all, and says so.

#1068's re-review carries a finding neither side has: its new test asserts
all three DeepSeek ids are in `noVisionModels`, but routing merges the
registry list, which holds only the `-free` one. Reproduced with the test's
own routeModel config — Pro=false, Flash=false, Flash-free=true — so two of
three cases fail. Latent because no check currently runs that suite.

Also corrected: the layers were called dependency-ordered when they share no
files, so s3/s4 are independent heads off `origin/dev` and the
`--update-refs` cascade is reserved for genuine chains; and #978's exclusion
no longer claims it is "already correct" when it needs an author-side docs
change.

Terminal outcome is deferred, not DONE. Shrinking the criteria to match what
finished would have hidden exactly the thing worth recording.
lidge-jun added a commit that referenced this pull request Aug 6, 2026
Records what the stack-and-absorb campaign actually produced once live data
replaced its assumptions.

#1068 merged at 10:45:57Z while I was writing its review, and the merged code
is not what I reviewed: `noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS,
...DEEPSEEK_THINKING_MODELS]` — the union the review asked for. Running the
merged suite against `origin/dev` gives 9 pass / 0 fail, including a new test
pinning the six probed text-only models. My 10:53Z comment claiming a failing
test was true of the head I had fetched and false of what landed, so it got a
public correction eight minutes later.

That is the second process error in this unit, and both are now written down
rather than quietly fixed. First: judging author activity by `updatedAt`,
which moves when WE comment. Second: commenting against a stale fetched head.
Each produced a wrong public statement to a contributor. The rules are
re-fetch immediately before commenting, and compare last-commit time against
review time.

#1092's author restored the fail-closed guard within 30 minutes of the
review, with a comment keeping `unknown` distinct in debug. Credited
explicitly; asked only to split the unrelated `imageInput` scope.

#1036 and #997 are deferred, not absorbed. Their last commits (08-05 07:18Z,
08-06 02:51Z) predate our reviews and neither carries the requested change,
but roughly two hours have passed and #997's author was active that morning.
050 states a 72-hour response window with a mandatory head re-check before
anything is authored or closed. The window exists because this unit
demonstrated the alternative: two of four absorb targets were being actively
fixed by their authors while we drafted replacements.

#994 updated with the dev-only landing (merge 7d0c02d, ancestor of
`origin/dev`) and deliberately left open pending the reporter's provider
confirmation and a release.

Zero contributor PRs closed by this loop.
Agent59353 and others added 5 commits August 6, 2026 21:14
…1017

Gate the native-mutation refusal hint on whether the synthetic structured
edit tools are actually advertised (not every apply_patch request widens).

Reject trailing-newline-only and identical old/new replacements as a silent
no-op instead of emitting an empty hunk that apply_patch would drop.

Give addReplacement a single StructuredEditTranslation return type so the
patch field does not overload two different meanings.

Document the line-based matching limitations (single-location, edits
matched against ORIGINAL content, no final-newline-only edits) in the
tool descriptions.

Test hardening: remove optional-chaining after expect(), use toEqual for
full-shape assertions, add no-op rejection cases and a stateful non-identity
wire-name multi_edit translation case.

Verification: 21 focused tests pass, typecheck/lint/privacy clean.
…name (lidge-jun#1017)

Builds on Agent59353's structured-edit work in the commits below. The
conversion itself is unchanged and is the hard part: validated JSON, several
argument spellings, line-based hunks, no-op and final-newline rejection, and
a drop-with-explanation instead of a best-effort patch.

The gap was at the other end. `translateStructuredEditCall` decided a call
was ours from the tool NAME alone, and both call sites pass a name that came
off the wire. `cursorStructuredEditTools` already refuses to shadow a client
tool called `edit_file` or `multi_edit` — so the collision was understood at
injection — but that knowledge never reached the translation. A user running
an MCP server that exposes `edit_file` would have their call silently
re-emitted as `apply_patch`, or dropped with an error naming a conversion
they never requested.

This threads the answer through instead: live-transport records the bare
names we actually advertised on this request, derived from
`cursorStructuredEditTools` rather than from the name, and the event state
carries them. Both call sites convert only when the name is in that set.

The stateless fallback now passes through rather than converting. It has no
request state, so it cannot know whether we advertised anything, and the
safe direction is obvious: an unconverted structured call is a visible,
recoverable failure; a wrongly converted one edits a file. Live traffic
always carries state, so real conversions are unaffected.

Its test previously pinned the old contract ("stateless native-exec path
converts edit_file the same way"), so it now pins the new one and says why.

Ablation on the new collision test — restoring the name-only gate:

    (fail) a client tool named edit_file is not hijacked when we advertised
           nothing (lidge-jun#1036 review)
    21 pass, 1 fail

Exactly one test goes red, and the twenty-one conversion tests stay green,
which is what shows the gate narrows behavior without breaking the feature.

tests/cursor-structured-edit.test.ts 22 pass / 0 fail; cursor-tool-budget and
cursor-protobuf-events 33 pass / 0 fail; typecheck clean.
@lidge-jun
lidge-jun force-pushed the fix/1017-cursor-structured-edit branch from a35115c to b5e2929 Compare August 6, 2026 12:19
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deterministic PR hygiene checks passed.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

⏳ DRAFT

  • UI screenshot required.

What to do

  • Add a screenshot of the UI change to the PR description.
  • Tick all four boxes in the PR description once you're done (currently 0/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ⬜ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

0/4 boxes ticked.

This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.
@ZachDreamZ Tick the boxes once your local CI is green, your branch is on the latest dev commit, and every correct Codex and CodeRabbit finding is resolved.

@lidge-jun

Copy link
Copy Markdown
Owner

I pushed a commit onto this branch rather than asking you to do another round — your four commits are untouched on top of a rebase onto current dev, and mine sits above them. The PR stays yours.

What I changed, and only this: the conversion now checks provenance instead of the tool name.

translateStructuredEditCall decided a call was ours from the name alone, and both call sites pass a name that came off the wire. Your cursorStructuredEditTools already refuses to shadow a client tool called edit_file or multi_edit — so the collision was understood at injection time — but that knowledge never reached the translation. A user running an MCP server that exposes edit_file would have their call silently re-emitted as apply_patch, or dropped with an error naming a conversion they never requested.

So live-transport now records the bare names we actually advertised on this request (from cursorStructuredEditTools, not from the name), the event state carries them, and both call sites convert only for names in that set.

One behavior change worth your review: the stateless fallback in mapSyntheticMcpExecToToolEvents now passes through instead of converting. It has no request state, so it cannot know whether we advertised anything, and the safe direction seemed clear — an unconverted structured call is a visible, recoverable failure, while a wrongly converted one edits a file. Live traffic always carries state, so real conversions are unaffected. Your stateless native-exec path converts edit_file the same way test pinned the old contract, so I rewrote it to pin the new one and explain why. If you think the fallback should convert, say so — that is a judgment call and yours is as good as mine.

Everything else is yours and unchanged. The validated JSON parsing, the argument spellings, the line-based hunks, the no-op and final-newline rejection, and especially the choice to drop with an explanation rather than emit a best-effort patch — that last one is the right instinct and it is most of the value here.

Verification:

tests/cursor-structured-edit.test.ts        22 pass / 0 fail
cursor-tool-budget + cursor-protobuf-events 33 pass / 0 fail
bun run typecheck                            clean

Ablation on the new collision test, restoring the name-only gate:

(fail) a client tool named edit_file is not hijacked when we advertised nothing
21 pass, 1 fail

Exactly one test goes red and your twenty-one conversion tests stay green — which is what shows the gate narrows behavior without breaking the feature.

The branch is now on current dev. Ready for review from my side; #1017 should close when this lands.

lidge-jun added a commit that referenced this pull request Aug 6, 2026
Two loops circled a problem the campaign had invented. The user named the
answer in one line: their PRs report maintainerCanModify=true, so push our
completion commit onto their branch. Their commits stay, blame stays
accurate, their PR merges, and attribution is the commit graph rather than a
paragraph. No replacement PR, no close, no "absorbed with credit to" prose.

#1036 landed that way. Four Agent59353 commits, then ours on top, pushed
with a lease pinned to their head after re-verifying the remote. The change
is bounded: live-transport records the bare names we actually advertised
this request, the event state carries them, and both translate call sites
convert only for names in that set. Their conversion logic is byte-
unchanged. The stateless fallback passes through, since it has no state to
consult and an unconverted call is recoverable while a wrongly converted one
edits a file — disclosed in the PR comment as a judgment call open to
disagreement. Ablation: restoring the name-only gate gives 21 pass / 1 fail,
red on exactly the new collision test.

#997 was not ours to push. The lease rejected it as stale: the author landed
their own fix mid-flight, and theirs is stronger than what we staged — we
pinned getConfigDir(), they assert the resolved usage.jsonl receives the row
and the default location does not. Verified instead of overwritten, 10 pass
with a 9/1 ablation on their own assertion.

That is the third time in this campaign that acting on a stale head produced
wasted or wrong work: updatedAt as an activity signal, a "failing test"
comment posted eight minutes after #1068 merged with the fix, and a commit
built against a head the author had already improved. The lease caught the
third; the first two reached a contributor as a wrong public statement. The
rule is now written down — re-fetch immediately before preparing a commit,
not before pushing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants