-
Notifications
You must be signed in to change notification settings - Fork 663
fix(cursor): structured edit tools convert to valid apply_patch calls (#1017) #1036
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f724063
500cd94
d06de0f
33d6176
b5e2929
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,10 +3,13 @@ import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb"; | |
| import { decodeCursorArgsMap } from "./arg-codec"; | ||
| import { normalizeArgKeys } from "./arg-normalize"; | ||
| import { | ||
| CODEX_APPLY_PATCH_TOOL, | ||
| CURSOR_MULTI_EDIT_TOOL, | ||
| cursorShellBridgeArgsValid, | ||
| cursorShellBridgeDropError, | ||
| defaultShellBridgeArgNormalizeSchema, | ||
| isCodexShellBridgeToolName, | ||
| isCursorStructuredEditToolName, | ||
| normalizeCursorWireName, | ||
| OCX_RESPONSES_TOOL_PROVIDER, | ||
| resolveShellBridgeAliasKey, | ||
|
|
@@ -161,14 +164,41 @@ export interface CursorProtobufEventState { | |
| toolSchemas?: Map<string, unknown>; | ||
| /** Cursor wire-name → original Responses/Codex tool name for this request. */ | ||
| cursorToolNameMap?: Map<string, string>; | ||
| /** | ||
| * Bare names WE advertised as synthetic structured-edit tools on this request. | ||
| * See structuredEditCallIsOurs: conversion is gated on provenance, not on the name. | ||
| */ | ||
| syntheticStructuredEditToolNames?: ReadonlySet<string>; | ||
| translatorBudget?: TranslatorBudget; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * Did WE advertise this bare tool name as a synthetic structured-edit tool on this request? | ||
| * | ||
| * Provenance, not a name test. `edit_file` / `multi_edit` are ordinary names a client or MCP | ||
| * server may legitimately expose, and `cursorStructuredEditTools` already refuses to shadow one | ||
| * that exists. Converting on the name alone would undo that refusal at the other end of the | ||
| * request: the client's own call would be silently re-emitted as `apply_patch`, or dropped with | ||
| * an error naming a conversion the user never asked for. | ||
| * | ||
| * Absent set = we advertised nothing, so nothing converts. Fail-closed in the safe direction: | ||
| * an unconverted structured call is a visible, recoverable failure; a wrongly converted one | ||
| * edits a file. | ||
| */ | ||
| function structuredEditCallIsOurs( | ||
| advertised: ReadonlySet<string> | undefined, | ||
| toolName: string, | ||
| ): boolean { | ||
| return advertised?.has(toolName) === true; | ||
| } | ||
|
|
||
| export function createCursorProtobufEventState(options: { | ||
| clientToolNames?: Iterable<string>; | ||
| parallelToolCalls?: boolean; | ||
| toolSchemas?: Map<string, unknown>; | ||
| cursorToolNameMap?: Map<string, string>; | ||
| syntheticStructuredEditToolNames?: Iterable<string>; | ||
| contextUsage?: CursorContextUsageControls; | ||
| /** | ||
| * Request-local input estimate derived from the payload actually sent. Used only | ||
|
|
@@ -185,6 +215,9 @@ export function createCursorProtobufEventState(options: { | |
| openToolCalls: new Map(), | ||
| completedToolCalls: new Set(), | ||
| ...(options.clientToolNames ? { clientToolNames: new Set(options.clientToolNames) } : {}), | ||
| ...(options.syntheticStructuredEditToolNames | ||
| ? { syntheticStructuredEditToolNames: new Set(options.syntheticStructuredEditToolNames) } | ||
| : {}), | ||
| ...(options.parallelToolCalls !== undefined ? { parallelToolCalls: options.parallelToolCalls } : {}), | ||
| startedClientToolCalls: 0, | ||
| ...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}), | ||
|
|
@@ -311,6 +344,117 @@ function resolveCompletedArgs(buffered: string, args: McpArgs | undefined, state | |
| return ""; | ||
| } | ||
|
|
||
| const PATCH_BEGIN = "*** Begin Patch"; | ||
| const PATCH_END = "*** End Patch"; | ||
|
|
||
| function firstStringArg(args: Record<string, unknown>, keys: readonly string[]): string | undefined { | ||
| for (const key of keys) { | ||
| const value = args[key]; | ||
| if (typeof value === "string") return value; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** 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 oldLines = patchLines(oldString); | ||
| const newLines = patchLines(newString); | ||
| // Line-based patch semantics cannot express an edit that only adds or removes the file's | ||
| // final newline, and an old/new pair that normalizes to the same lines is a silent no-op — | ||
| // reject it rather than emitting an empty hunk that apply_patch would drop. | ||
| if (oldLines.length === 0 && newLines.length === 0) { | ||
| return { error: "structured edit requires a non-empty old_string; an empty replacement is not a valid edit" }; | ||
| } | ||
| if (oldLines.length === newLines.length && oldLines.every((line, i) => line === newLines[i])) { | ||
| return { error: "structured edit old_string and new_string are identical after line normalization; the replacement is a no-op and was dropped" }; | ||
| } | ||
| const removed = oldLines.map(line => `-${line}`); | ||
| const added = newLines.map(line => `+${line}`); | ||
| return { hunk: ["@@", ...removed, ...added].join("\n") }; | ||
| } | ||
|
Comment on lines
+365
to
+387
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🌐 Web query:
💡 Result: The Codex CLI 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 || trueRepository: lidge-jun/opencodex Length of output: 50376 🌐 Web query:
💡 Result: In the OpenAI Codex 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 || 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:
💡 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
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 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Convert a completed Cursor structured edit call (`edit_file` / `multi_edit`) into a valid Codex | ||
| * apply_patch freeform payload (#1017). Cursor-trained models cannot emit Codex's freeform patch | ||
| * grammar, so the adapter advertises exact-match replacement tools and performs the grammar here. | ||
| * Returns `{ patch }` for a valid conversion, `{ error }` for a malformed call (which must never be | ||
| * relayed verbatim: Codex would reject it locally after the HTTP 200, the reported failure mode), | ||
| * and `undefined` for tools that are not structured edits. | ||
| */ | ||
| export type StructuredEditTranslation = | ||
| | { patch: string; error?: undefined } | ||
| | { error: string; patch?: undefined }; | ||
|
|
||
| export function translateStructuredEditCall( | ||
| toolName: string, | ||
| argsText: string, | ||
| ): StructuredEditTranslation | undefined { | ||
| if (!isCursorStructuredEditToolName(toolName)) return undefined; | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(argsText); | ||
| } catch { | ||
| return { | ||
| error: `${toolName} arguments were not valid JSON; the call was dropped. ${ | ||
| toolName === CURSOR_MULTI_EDIT_TOOL | ||
| ? "Use file_path and edits[] (each edit with old_string and new_string)." | ||
| : "Use file_path, old_string and new_string." | ||
| }`, | ||
| }; | ||
| } | ||
| if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| return { error: `${toolName} arguments must be a JSON object; the call was dropped.` }; | ||
| } | ||
| const args = parsed as Record<string, unknown>; | ||
| const path = firstStringArg(args, ["file_path", "filePath", "path", "filepath", "filename"]); | ||
| if (!path || path.trim().length === 0) { | ||
| return { error: `${toolName} is missing a non-empty file_path; the call was dropped.` }; | ||
| } | ||
| const hunks: string[] = []; | ||
| 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 as string }; | ||
| }; | ||
|
Comment on lines
+427
to
+436
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Give
The type therefore carries a payload that is valid at line 414 and invalid as an ♻️ 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);
🤖 Prompt for AI Agents |
||
| if (toolName === CURSOR_MULTI_EDIT_TOOL) { | ||
| const edits = args.edits; | ||
| if (!Array.isArray(edits) || edits.length === 0) { | ||
| return { error: "multi_edit requires a non-empty edits array; the call was dropped." }; | ||
| } | ||
| for (const edit of edits) { | ||
| if (!edit || typeof edit !== "object" || Array.isArray(edit)) { | ||
| return { error: "multi_edit edits entries must be objects with old_string and new_string; the call was dropped." }; | ||
| } | ||
| const editResult = addReplacement(edit as Record<string, unknown>); | ||
| if (editResult.error !== undefined) return editResult; | ||
| hunks.push(editResult.patch); | ||
|
Comment on lines
+446
to
+448
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } | ||
| } else { | ||
| const editResult = addReplacement(args); | ||
| if (editResult.error !== undefined) return editResult; | ||
| hunks.push(editResult.patch); | ||
| } | ||
| return { patch: [PATCH_BEGIN, `*** Update File: ${path}`, ...hunks, PATCH_END].join("\n") }; | ||
| } | ||
|
|
||
| export function mapSyntheticMcpExecToToolEvents( | ||
| args: McpArgs, | ||
| fallbackCallId = "cursor_mcp_exec", | ||
|
|
@@ -341,9 +485,20 @@ export function mapSyntheticMcpExecToToolEvents( | |
| } | ||
| } | ||
| // Stateless fallback (no shared event state): emit a complete, self-contained tool call. | ||
| // | ||
| // No conversion happens here by design (#1036 review). Structured-edit translation is gated on | ||
| // provenance — did WE advertise this bare name on THIS request — and that record lives on the | ||
| // request state, which this branch does not have. Converting anyway would reinstate the exact | ||
| // hazard the gate exists to close: a client or MCP tool legitimately named `edit_file` would be | ||
| // rewritten into an apply_patch it never asked for. The live path always carries state | ||
| // (live-transport seeds it), so this only affects direct/unit callers. | ||
| const emittedName = responsesName; | ||
| const emittedArgs = normalizedArgs; | ||
| return [ | ||
| { type: "tool_call_start", id: callId, name: responsesName }, | ||
| ...(normalizedArgs.length > 2 ? [{ type: "tool_call_delta" as const, arguments: normalizedArgs }] : []), | ||
| { type: "tool_call_start", id: callId, name: emittedName }, | ||
| ...(emittedArgs.length > 2 | ||
| ? [{ type: "tool_call_delta" as const, arguments: emittedArgs }] | ||
| : []), | ||
| { type: "tool_call_end", id: callId }, | ||
| ]; | ||
| } | ||
|
|
@@ -385,13 +540,28 @@ function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, to | |
| return [{ type: "error", message: cursorShellBridgeDropError(toolName) }]; | ||
| } | ||
|
|
||
| function dropStructuredEditCall(state: CursorProtobufEventState, callId: string, toolName: string, reason: string): CursorServerMessage[] { | ||
| state.openToolCalls.delete(callId); | ||
| state.translatorBudget?.closeCall(callId); | ||
| state.completedToolCalls.add(callId); | ||
| return [{ type: "error", message: `${toolName} call was not converted to apply_patch: ${reason}` }]; | ||
| } | ||
|
|
||
| function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] { | ||
| const open = state.openToolCalls.get(callId); | ||
| if (!open) return []; | ||
| const schema = toolSchemaForWireName(state, open.name); | ||
| if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) { | ||
| if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name); | ||
| } | ||
| // Structured edit calls are converted to apply_patch here so both the interactionUpdate and the | ||
| // native-exec mcpArgs paths emit the same valid freeform payload (#1017). | ||
| const translation = structuredEditCallIsOurs(state.syntheticStructuredEditToolNames, open.name) | ||
| ? translateStructuredEditCall(open.name, finalArgs) | ||
| : undefined; | ||
| if (translation?.error !== undefined) { | ||
| return dropStructuredEditCall(state, callId, open.name, translation.error); | ||
| } | ||
| if (finalArgs !== open.args) { | ||
| const previousBytes = Buffer.byteLength(open.args); | ||
| const reservation = state.translatorBudget?.reserveTransient( | ||
|
|
@@ -402,8 +572,10 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr | |
| reservation?.commitRetained(); | ||
| state.translatorBudget?.releaseRetained(previousBytes, { kind: "tool_args", callId }); | ||
| } | ||
| const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }]; | ||
| if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs }); | ||
| const emittedName = translation ? CODEX_APPLY_PATCH_TOOL : open.name; | ||
| const emittedArgs = translation ? JSON.stringify({ input: translation.patch }) : finalArgs; | ||
| const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: emittedName }]; | ||
| if (emittedArgs.length > 0) out.push({ type: "tool_call_delta", arguments: emittedArgs }); | ||
| out.push(...endToolCall(state, callId)); | ||
| return out; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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.
patchLinespops one trailing empty element fromold_stringand fromnew_stringindependently, so the difference between them is discarded rather than normalized.old_string: "export {};\n"withnew_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 actionableerror; 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