fix(cursor): structured edit tools convert to valid apply_patch calls (#1017) - #1036
fix(cursor): structured edit tools convert to valid apply_patch calls (#1017)#1036ZachDreamZ wants to merge 5 commits into
Conversation
📝 WalkthroughWalkthroughCursor now advertises ChangesCursor structured edit support
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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
src/adapters/cursor/native-exec-fs.tssrc/adapters/cursor/protobuf-events.tssrc/adapters/cursor/request-builder.tssrc/adapters/cursor/tool-definitions.tstests/cursor-structured-edit.test.ts
| 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.`; | ||
| } |
There was a problem hiding this comment.
🎯 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.tsline 210 returns[]for any objecttoolChoice.tests/cursor-structured-edit.test.tslines 117-120 assert exactly that: with{ name: "apply_patch" }the budget keepsapply_patchalone.src/adapters/cursor/request-builder.tsline 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.
| /** 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") }; | ||
| } |
There was a problem hiding this comment.
🎯 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: inreplacementHunk, compare the two collapsed line lists before building the hunk and return anerrorwhen 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 assertingtranslateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, JSON.stringify({ file_path: "src/f.ts", old_string: "export {};\n", new_string: "export {};" }))?.erroris 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.
| /** 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") }; | ||
| } |
There was a problem hiding this comment.
🎯 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:
- 1: https://github.com/openai/codex/blob/35aaa5d9/codex-rs/tools/src/apply_patch_tool.rs
- 2: https://github.com/openai/codex/blob/main/codex-rs/core/prompt_with_apply_patch_instructions.md
- 3: https://developers.openai.com/api/docs/guides/tools-apply-patch
- 4: https://github.com/openai/codex/blob/eaf81d3f/codex-rs/core/src/tools/handlers/apply_patch.rs
- 5: https://github.com/openai/codex/blob/0a0caa9d/codex-rs/core/src/tools/handlers/apply_patch.rs
- 6: https://github.com/openai/codex/blob/fbe65995/codex-rs/apply-patch/src/parser.rs
- 7: https://pypi.org/project/apply-patch-py/0.4.1/
- 8: https://github.com/openai/codex/blob/35aaa5d9/codex-rs/apply-patch/src/lib.rs
- 9: https://github.com/judysonnen/patchwise
- 10: https://github.com/Hmbown/DeepSeek-TUI/blob/e10e53d3/crates/tui/src/tools/apply_patch.rs
- 11: https://deepwiki.com/alvinunreal/oh-my-opencode-slim/4.2-apply-patch-hook
🏁 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 || trueRepository: 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:
- 1: https://github.com/openai/codex/blob/35aaa5d9/codex-rs/tools/src/apply_patch_tool.rs
- 2: https://github.com/openai/codex/blob/main/codex-rs/apply-patch/apply_patch_tool_instructions.md
- 3: https://github.com/openai/codex/blob/35aaa5d9/codex-rs/apply-patch/src/parser.rs
- 4: https://github.com/openai/codex/blob/f1affbac/codex-rs/apply-patch/src/parser.rs
- 5: https://docs.rs/hanzo-apply-patch/latest/src/hanzo_apply_patch/parser.rs.html
- 6: https://github.com/openai/codex/blob/af8a9d2d2ba84a1dafad6fa51fe3d63e96aceb75/codex-rs/tools/src/tool_apply_patch.lark
- 7: openai/codex@e26f734
🏁 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 || trueRepository: 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
doneRepository: 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:
- 1: [apply-patch] Handle multiple context lines openai/codex#2596
- 2: https://github.com/openai/codex/blob/9a8730f3/codex-rs/apply-patch/src/parser.rs
- 3: https://github.com/openai/codex/blob/main/codex-rs/prompts/templates/apply_patch_tool_instructions.md
- 4: https://github.com/openai/codex/blob/main/codex-rs/apply-patch/apply_patch_tool_instructions.md
- 5: https://github.com/openai/codex/blob/f1affbac/codex-rs/apply-patch/src/parser.rs
- 6: apply_patch in system prompt is inconsistent with the actual impl openai/codex#2578
- 7: feat: use the arg0 trick with apply_patch openai/codex#2646
🏁 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
doneRepository: 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]}")
PYRepository: 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'
doneRepository: 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.tsRepository: 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.
| 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 }; | ||
| }; |
There was a problem hiding this comment.
📐 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.
| 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 }, | ||
| }, | ||
| ]; | ||
| } |
There was a problem hiding this comment.
🗄️ 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*\(' srcRepository: 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/cursorRepository: 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)))
PYRepository: 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.tsRepository: 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)
PYRepository: 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/cursorRepository: 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.
| old_string: "line1\nline2", | ||
| new_string: "line1\nchanged\nline2", | ||
| }); | ||
| expect(translateStructuredEditCall(CURSOR_EDIT_FILE_TOOL, args))?.toMatchObject({ |
There was a problem hiding this comment.
📐 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:
- It reads as if the author meant to guard a possibly-
undefinedvalue.translateStructuredEditCallgenuinely returnsStructuredEditTranslation | undefined, so a reader reasonably assumes the guard is load-bearing. It is not. - If the pattern is later copied onto the value instead of the matcher —
expect(result?.patch).toBe(...)withresultundefined — the assertion silently comparesundefinedand 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.
| 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(); | ||
| }); |
There was a problem hiding this comment.
🎯 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.
| 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]]), | ||
| }); |
There was a problem hiding this comment.
🎯 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. Useexpect(...).toEqual(...). multi_edithas no stateful or native-exec coverage. Onlyedit_fileis driven through the event paths, andmulti_editis the branch with the loop and the per-entry validation atprotobuf-events.tslines 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
There was a problem hiding this comment.
💡 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".
| const synthetic = cursorStructuredEditTools(catalog, toolChoice); | ||
| const eligible = [...baseEligible, ...synthetic]; |
There was a problem hiding this comment.
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 👍 / 👎.
| const editResult = addReplacement(edit as Record<string, unknown>); | ||
| if (editResult.error !== undefined) return editResult; | ||
| hunks.push(editResult.patch); |
There was a problem hiding this comment.
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 👍 / 👎.
| const removed = patchLines(oldString).map(line => `-${line}`); | ||
| const added = patchLines(newString).map(line => `+${line}`); | ||
| return { hunk: ["@@", ...removed, ...added].join("\n") }; |
There was a problem hiding this comment.
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 👍 / 👎.
|
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. |
|
Validation run, as requested: Local dev server (built from this branch)
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:
Gates
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. |
[GD] Verdict: gatedTLDR
Full verdictSemantic propagation
UsefulnessHigh. Issue #1017 (and its older sibling #372) documents a real, reproducible failure: Bugs / correctnessMethod: 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 Verified findings (all reproduced against
Not reproduced / declined:
Fixed this session: none (foreign PR, review-only run). Security
Spec / standards
Reviews
Base / CI
GateDraft (GitHub draft PR) + 11 unresolved review threads + required CI not run ( Bottom lineThis 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 |
|
[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 ( Still open before maintainer review:
Address those (or explicitly decline each with rationale), update from latest |
Addressed review feedback — commit a35115cThanks @coderabbitai and @chatgpt-codex-connector for the thorough review. All actionable items are addressed in the latest push. CodeRabbit comments1. 2. 3. 4. 5. 6. Test assertions — remove 7. Trailing-newline test case ✅ 8. Stateful non-identity wire-name tests ✅ Codex connector comments
Verification
|
|
You have reached your Codex usage limits. You can see your limits in the Codex usage dashboard. |
|
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.tsLength 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/cursorLength of output: 122714
The following fixes are present:
Two prior findings remain open.
I cannot mark the review feedback fully addressed until these two paths preserve tool ownership and final-catalog availability. 🐇 ✏️ Learnings added
You are interacting with an AI system. |
lidge-jun
left a comment
There was a problem hiding this comment.
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:
- 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 insrc/adapters/cursor/protobuf-events.ts/live-transport.ts) and convert only those. - 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.
|
Re-reviewed against current 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. if (!isCursorStructuredEditToolName(toolName)) return undefined;and both call sites pass a name that arrived from the wire — Reproduction. A user runs an MCP server exposing
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 — Please add the regression test that would have caught this: a client tool named 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 |
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.
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.
…ic structured tools
…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.
a35115c to
b5e2929
Compare
|
✅ Deterministic PR hygiene checks passed. |
⏳ DRAFT
What to do
Review readiness checklist
0/4 boxes ticked. This pull request was already a draft. Its draft status will be preserved after every issue above is resolved. |
|
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 What I changed, and only this: the conversion now checks provenance instead of the tool name.
So One behavior change worth your review: the stateless fallback in 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: Ablation on the new collision test, restoring the name-only gate: 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 |
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.
Summary
The Cursor adapter advertises Codex's
apply_patchfreeform 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 ascursor/grok-4.5cannot 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):
apply_patchtool, the adapter now also advertisesedit_fileandmulti_edit— exact-match replacement tools shaped like Cursor's native Edit/MultiEdit tools.edit_file/multi_editcalls are converted server-side into a valid Codex apply_patch payload (*** Begin Patchenvelope,*** Update File:section,@@hunks with-/+line prefixes) and relayed to the bridge as anapply_patchcustom tool call, so Codex approval/sandbox policy still applies.file_path/old_string/new_string, emptyold_string, invalidedits[]) are dropped with a clear error instead of relaying invalid patch text — the previously silent 200-then-local-rejection failure mode.apply_patch(never truncated away), the guidance note tells models to prefer them, and the Cursor-native write/delete refusal message points to them.tool_choiceselections are never widened: a forcedapply_patchchoice does not gain sibling tools.Verification
Commands run (Windows 11, bun 1.3.14):
bun run typecheck— passbun run lint:gui— passbun 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 failbun 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 failNew 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
Summary by CodeRabbit
edit_fileandmulti_edittools.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.