Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import { desktopDepsFromConfig } from "./native-exec-desktop";
import {
buildCursorToolDefinitions,
cursorRequestAdvertisesApplyPatch,
cursorRequestAdvertisesStructuredEdits,
cursorStructuredEditTools,
cursorRequestHasShellAlias,
cursorToolArgNormalizeSchema,
cursorToolWireName,
Expand Down Expand Up @@ -544,7 +546,19 @@ class LiveCursorTransport implements CursorTransport {
...this.execContext,
clientToolDefs,
rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
structuredEditAvailable: cursorRequestAdvertisesStructuredEdits(request.tools, request.toolChoice),
};
// Provenance for the synthetic structured-edit tools (#1036 review): record the bare names WE
// actually advertised on THIS request, taken from the definitions that were really sent rather
// than from the name alone. A client or MCP tool legitimately called `edit_file` is in
// clientToolDefs too, so the discriminator is `cursorStructuredEditTools` having produced it —
// which is precisely what `structuredEditAvailable` already reflects.
const syntheticStructuredEditToolNames = new Set(
(this.execContext.structuredEditAvailable
? cursorStructuredEditTools(request.tools, request.toolChoice)
: []
).map(tool => tool.name),
);
const toolSchemas = new Map<string, unknown>();
const cursorToolNameMap = new Map<string, string>();
for (const tool of cursorVisibleTools ?? []) {
Expand All @@ -571,6 +585,7 @@ class LiveCursorTransport implements CursorTransport {
parallelToolCalls: request.parallelToolCalls,
toolSchemas,
cursorToolNameMap,
syntheticStructuredEditToolNames,
translatorBudget: this.translatorBudget,
contextUsage,
...(prepared.estimatedInputTokens !== undefined
Expand Down
15 changes: 9 additions & 6 deletions src/adapters/cursor/native-exec-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ const MAX_GREP_FILES = 500;
const MAX_GREP_RESULTS = 200;
const MAX_FILE_BYTES = 1_000_000;

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.`;
function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean): string {
const structuredHint = structuredEditAvailable
? " 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."
: " Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout.";
return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available.${structuredHint} No file was changed.`;
}

const NATIVE_LOCAL_EXEC_DISABLED =
Expand Down Expand Up @@ -84,13 +87,13 @@ export function readExec(execMsg: ExecServerMessage): Uint8Array {
}
}

export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage): Uint8Array {
export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array {
if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec");
const path = resolve(execMsg.message.value.path);
return execBytes(execMsg, "writeResult", create(WriteResultSchema, {
result: {
case: "rejected",
value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write") }),
value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable) }),
},
}));
}
Expand Down Expand Up @@ -133,13 +136,13 @@ export function writeExec(execMsg: ExecServerMessage): Uint8Array {
}
}

export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage): Uint8Array {
export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array {
if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec");
const path = resolve(execMsg.message.value.path);
return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, {
result: {
case: "rejected",
value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete") }),
value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable) }),
},
}));
}
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/cursor/native-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps {
unsafeAllowNativeLocalExec?: boolean;
/** apply_patch is visible for this request; Cursor-native write/delete must not bypass Codex. */
rejectNativeFileMutations?: boolean;
/** The synthetic exact-match edit tools (edit_file / multi_edit) are advertised this request. */
structuredEditAvailable?: boolean;
}

export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecContext, "unsafeAllowNativeLocalExec"> = {}): boolean {
Expand Down Expand Up @@ -514,8 +516,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg)];
}
if (execCase === "readArgs") return [readExec(execMsg)];
if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg) : writeExec(execMsg)];
if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg) : deleteExec(execMsg)];
if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)];
if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : deleteExec(execMsg)];
if (execCase === "lsArgs") return [lsExec(execMsg)];
if (execCase === "grepArgs") return [grepExec(execMsg)];
if (execCase === "shellArgs") return [shellExec(execMsg)];
Expand Down
180 changes: 176 additions & 4 deletions src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 } : {}),
Expand Down Expand Up @@ -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 +358 to +387

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 +365 to +387

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.


/**
* 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

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.

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

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 👍 / 👎.

}
} 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",
Expand Down Expand Up @@ -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 },
];
}
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
Expand Down
Loading
Loading