diff --git a/changelog.d/fixes/15036-cursor-read-range.md b/changelog.d/fixes/15036-cursor-read-range.md new file mode 100644 index 0000000000..51e7e0257d --- /dev/null +++ b/changelog.d/fixes/15036-cursor-read-range.md @@ -0,0 +1 @@ +- **fix(cursor):** ranged reads bridged from Cursor's native read tool keep their `offset`/`limit` instead of reaching the client as whole-file reads, which Claude Code answered with "file unchanged" and left the model re-reading the same file ([#15036](https://github.com/diegosouzapw/OmniRoute/pull/15036)). diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 08dae6f166..2e16ef3f95 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_29_15036_cursor_read_range": "PR #15036 (QuangBlue) own growth: open-sse/utils/cursorAgentProtobuf.ts 1588->1619 (+31 = ReadArgs offset/limit field constants, the two optional fields on the exec_read event type, and createReadExecEvent, which decodes them next to the shared path-event decoder it reuses and cannot move out without exporting the file's private varint helpers). The bridge-side range check lives in open-sse/executors/cursor/builtinToolBridge.ts (under cap). Covered by tests/unit/cursor-agent-exec-router.test.ts and tests/unit/cursor-builtin-tool-bridge.test.ts.", "_rebaseline_2026_09_29_15050_timing_safe_env_key": "PR #15050 (HouMinXi) own growth: src/sse/services/auth.ts 3622->3623 (+1 = the timingSafeCompare import; the env passthrough-key check in isValidApiKey swaps `===` for the constant-time helper on its existing line). Irreducible. Covered by tests/unit/secret-compare-constant-time.test.ts.", "_rebaseline_2026_09_28_14740_self_loop_bearer": "PR #14740 own growth: src/lib/db/apiKeys.ts +2 but no ceiling move: re-measured at 1685 after merging release/v3.8.51 (post #14975), under the 1719 tip ceiling (+2 = the import of the self-loop helpers and one spread of selfLoopKeyOverrides(key) at the end of the synthetic env-key record; validateApiKey and the env-key branch reuse their existing lines). The bearer check and the restricted record (id, name, scope internal:self-loop, allowedEndpoints chat/audio) live in src/lib/db/apiKeys/selfLoopKey.ts, under cap. Irreducible call-site wiring. Covered by tests/unit/self-loop-bearer-auth.test.ts.", "_rebaseline_2026_09_27_14919_streamed_ttft": "PR #14919 own growth (streamed TTFT), irreducible call-site lines after moving the first-output probe, the completion timing fields and the request-epoch TTFT math into open-sse/utils/streamTiming.ts and the new open-sse/utils/sseOutputSignal.ts (both under cap): open-sse/handlers/chatCore.ts +2 but no ceiling move: re-measured at 6383 after merging release/v3.8.51, under the 6441 tip ceiling (+2 = the stream-origin offset assignment before the SSE transform is created and the requestTtftMs call in the stream-complete callback; the offset declaration and import are absorbed) and open-sse/utils/stream.ts 3296->3297 (+1 = the firstOutputMs field of StreamCompletePayload; forward() gains one observeOutput call while the passthrough branch's four timing lines collapse into ...timing.completionTiming(), which the translate branch now also reports). Covered by tests/unit/chatcore-streaming-ttft.test.ts, tests/unit/sse-output-signal.test.ts and tests/unit/responses-ws-proxy-turn-timing.test.mjs.", @@ -528,7 +529,7 @@ "open-sse/services/combo.ts": 4080, "open-sse/services/combo/executeTargetAttempt.ts": 1273, "open-sse/translator/response/openai-responses.ts": 1536, - "open-sse/utils/cursorAgentProtobuf.ts": 1588, + "open-sse/utils/cursorAgentProtobuf.ts": 1619, "open-sse/utils/proxyFetch.ts": 1404, "open-sse/utils/stream.ts": 3297, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, diff --git a/open-sse/executors/cursor/builtinToolBridge.ts b/open-sse/executors/cursor/builtinToolBridge.ts index 576f265b1d..1aafea71cb 100644 --- a/open-sse/executors/cursor/builtinToolBridge.ts +++ b/open-sse/executors/cursor/builtinToolBridge.ts @@ -63,6 +63,8 @@ const PROPERTY_ANNOTATION_KEYS = [ const SCALAR_PROPERTY_KEYS = new Set(["type", ...PROPERTY_ANNOTATION_KEYS]); const ARRAY_PROPERTY_KEYS = new Set(["type", "items", ...PROPERTY_ANNOTATION_KEYS]); const TODO_SCALAR_PROPERTY_KEYS = new Set(["type", "enum", ...PROPERTY_ANNOTATION_KEYS]); +const NUMBER_BOUND_KEYS = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"] as const; +const INTEGER_PROPERTY_KEYS = new Set(["type", ...NUMBER_BOUND_KEYS, ...PROPERTY_ANNOTATION_KEYS]); /** Restrict bridge candidates to the caller's OpenAI tool_choice contract. */ export function selectCursorBridgeTools( @@ -273,6 +275,26 @@ function ptySpawnBridge( return null; } +/** + * True when `value` is an integer the property accepts: `integer`/`number` + * type, only the bound keywords checked here, and every bound satisfied. + */ +function propertyAcceptsInteger(property: unknown, value: number): boolean { + if (!Number.isSafeInteger(value) || !isRecord(property)) return false; + if (!hasOnlyKeys(property, INTEGER_PROPERTY_KEYS)) return false; + if (property.type !== "integer" && property.type !== "number") return false; + for (const key of NUMBER_BOUND_KEYS) { + const bound = property[key]; + if (bound === undefined) continue; + if (typeof bound !== "number") return false; + if (key === "minimum" && value < bound) return false; + if (key === "maximum" && value > bound) return false; + if (key === "exclusiveMinimum" && value <= bound) return false; + if (key === "exclusiveMaximum" && value >= bound) return false; + } + return true; +} + function readBridge( event: Extract, tools: McpToolDefinition[] @@ -284,6 +306,23 @@ function readBridge( const pathKey = selectProperty(schema, properties, ["filePath", "path", "file_path"], "string"); if (!pathKey) continue; const args: Record = { [pathKey]: event.path }; + // A partial read must keep its range. Sending the bare path reads the whole + // file instead, and a client that already holds it (Claude Code) answers + // "file unchanged", so the model never sees the lines it asked for and + // retries forever. A tool that cannot carry the range is not a match. + let rangeFits = true; + for (const [key, value] of [ + ["offset", event.offset], + ["limit", event.limit], + ] as const) { + if (value === undefined) continue; + if (!propertyAcceptsInteger(properties[key], value)) { + rangeFits = false; + break; + } + args[key] = value; + } + if (!rangeFits) continue; if (hasAllRequired(schema, args)) return { toolName: tool.name, arguments: args }; } return null; diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index f72ed56d87..cb2a7d7e76 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -192,6 +192,8 @@ const ESM_WRITE_SHELL_STDIN_ARGS = 23; // Args sub-message field numbers (path and shell variants) const ARG_PATH = 1; // ReadArgs.path / WriteArgs.path / DeleteArgs.path / LsArgs.path +const ARG_READ_OFFSET = 4; // ReadArgs.offset (optional int32) +const ARG_READ_LIMIT = 5; // ReadArgs.limit (optional uint32) const ARG_SHELL_COMMAND = 1; // ShellArgs.command const ARG_SHELL_WORKING_DIR = 2; // ShellArgs.working_directory const ARG_SHELL_TIMEOUT = 3; // ShellArgs.timeout @@ -885,7 +887,16 @@ export type ExecServerEvent = execId: string; serverIdentifiers: string[]; } - | { kind: "exec_read"; execMsgId: number; execId: string; path: string } + | { + kind: "exec_read"; + execMsgId: number; + execId: string; + path: string; + /** ReadArgs.offset: first line to read. Absent when the model reads the whole file. */ + offset?: number; + /** ReadArgs.limit: number of lines to read. */ + limit?: number; + } | { kind: "exec_write"; execMsgId: number; @@ -1009,6 +1020,27 @@ function createPathExecEvent(kind: PathExecKind, context: ExecEventContext): Exe }; } +/** + * ReadArgs also carries an optional line range. Dropping it turns every partial + * read into a whole-file read, which clients such as Claude Code answer with + * "file unchanged" — the model then never gets the lines it asked for. + */ +function createReadExecEvent(context: ExecEventContext): ExecServerEvent { + const event = createPathExecEvent("exec_read", context) as Extract< + ExecServerEvent, + { kind: "exec_read" } + >; + for (const field of decodeFields(context.variantBytes)) { + if (field.wireType !== WT_VARINT) continue; + if (field.fieldNumber === ARG_READ_OFFSET) { + event.offset = Number(BigInt.asIntN(32, field.varint)); + } else if (field.fieldNumber === ARG_READ_LIMIT) { + event.limit = Number(BigInt.asUintN(32, field.varint)); + } + } + return event; +} + function createShellExecEvent(kind: ShellExecKind, context: ExecEventContext): ExecServerEvent { return { kind, @@ -1053,7 +1085,7 @@ const EXEC_EVENT_DECODERS: Partial> = { execMsgId, execId, }), - [ESM_READ_ARGS]: (context) => createPathExecEvent("exec_read", context), + [ESM_READ_ARGS]: (context) => createReadExecEvent(context), [ESM_WRITE_ARGS]: (context) => { const encodingHint = decodeStringField(context.variantBytes, 6); const hasFileBytes = decodeFields(context.variantBytes).some( diff --git a/tests/unit/cursor-agent-exec-router.test.ts b/tests/unit/cursor-agent-exec-router.test.ts index 30d1655d25..51bfe4218a 100644 --- a/tests/unit/cursor-agent-exec-router.test.ts +++ b/tests/unit/cursor-agent-exec-router.test.ts @@ -102,6 +102,39 @@ test("decodeExecServerEvent recognizes read_args (field 7) with path", () => { }); }); +test("decodeExecServerEvent keeps read_args offset (4) and limit (5)", () => { + // Live capture 2026-09-29: the model asked for lines 1195-1210 of a file. + const variant = Buffer.concat([ + stringField(1, "/repo/service.py"), + stringField(2, "call-1"), + varintField(4, 1195), + varintField(5, 16), + ]); + const esm = buildExecServerMessage(1, "exec-range", 7, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_read", + execMsgId: 1, + execId: "exec-range", + path: "/repo/service.py", + offset: 1195, + limit: 16, + }); +}); + +test("decodeExecServerEvent decodes a negative read_args offset as int32", () => { + // int32 -5 is sign-extended to a 10-byte varint on the wire. + const minusFive = Buffer.from([0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]); + const variant = Buffer.concat([ + stringField(1, "/repo/log.txt"), + Buffer.concat([tag(4, 0), minusFive]), + ]); + const esm = buildExecServerMessage(1, "exec-tail", 7, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.equal(event?.kind, "exec_read"); + assert.equal((event as { offset?: number }).offset, -5); +}); + test("decodeExecServerEvent recognizes write_args (field 3) with path and file_text", () => { // WriteArgs { 1 path, 2 file_text } — the contents are what lets the bridge // forward the write to a declared client tool instead of rejecting it. diff --git a/tests/unit/cursor-builtin-tool-bridge.test.ts b/tests/unit/cursor-builtin-tool-bridge.test.ts index ccb34fdc6a..1a766bb0e9 100644 --- a/tests/unit/cursor-builtin-tool-bridge.test.ts +++ b/tests/unit/cursor-builtin-tool-bridge.test.ts @@ -294,6 +294,99 @@ test("bridges exec_read to a schema-compatible read tool", () => { }); }); +// Claude Code's Read schema, verbatim from a live request (2026-09-29). +const claudeCodeRead: OpenAITool = { + type: "function", + function: { + name: "Read", + description: "Reads a file", + parameters: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + file_path: { description: "The absolute path to the file to read", type: "string" }, + offset: { + description: "The line number to start reading from.", + type: "integer", + minimum: 0, + maximum: 9007199254740991, + }, + limit: { + description: "The number of lines to read.", + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + }, + pages: { description: "Page range for PDF files", type: "string" }, + }, + required: ["file_path"], + additionalProperties: false, + }, + }, +}; + +const rangedRead: ExecServerEvent = { + kind: "exec_read", + execMsgId: 1, + execId: "read-range", + path: "/repo/service.py", + offset: 1195, + limit: 16, +}; + +test("bridges exec_read offset and limit to Claude Code's Read", () => { + // Dropping the range turned every partial read into a full re-read, which + // Claude Code answers with "Wasted call — file unchanged": a read loop. + assert.deepEqual(bridgeCursorBuiltinTool(rangedRead, defs([claudeCodeRead])), { + toolName: "Read", + arguments: { file_path: "/repo/service.py", offset: 1195, limit: 16 }, + }); +}); + +test("bridges exec_read offset and limit to a number-typed read tool", () => { + assert.deepEqual(bridgeCursorBuiltinTool(rangedRead, defs([readTool])), { + toolName: "read", + arguments: { filePath: "/repo/service.py", offset: 1195, limit: 16 }, + }); +}); + +test("does not bridge a ranged exec_read to a read tool that cannot carry the range", () => { + const pathOnlyRead: OpenAITool = { + type: "function", + function: { + name: "read", + description: "Read a file", + parameters: { + type: "object", + properties: { filePath: { type: "string" } }, + required: ["filePath"], + additionalProperties: false, + }, + }, + }; + assert.equal(bridgeCursorBuiltinTool(rangedRead, defs([pathOnlyRead])), null); +}); + +test("does not bridge an exec_read range outside the schema bounds", () => { + const zeroLimit: ExecServerEvent = { ...rangedRead, limit: 0 }; + const negativeOffset: ExecServerEvent = { ...rangedRead, offset: -5 }; + assert.equal(bridgeCursorBuiltinTool(zeroLimit, defs([claudeCodeRead])), null); + assert.equal(bridgeCursorBuiltinTool(negativeOffset, defs([claudeCodeRead])), null); +}); + +test("bridges an unranged exec_read to Claude Code's Read with the path only", () => { + const event: ExecServerEvent = { + kind: "exec_read", + execMsgId: 1, + execId: "read-full", + path: "/repo/service.py", + }; + assert.deepEqual(bridgeCursorBuiltinTool(event, defs([claudeCodeRead])), { + toolName: "Read", + arguments: { file_path: "/repo/service.py" }, + }); +}); + test("bridges native TodoWrite and preserves priorities from structured history", () => { const history = extractLatestTodoHistory([ {