fix(cursor): keep the line range when bridging the native read tool (#15036)
Some checks failed
Publish to Docker Hub / Resolve Docker release metadata (push) Waiting to run
Publish to Docker Hub / Build Docker (linux/amd64) (push) Blocked by required conditions
Publish to Docker Hub / Build Docker (linux/arm64) (push) Blocked by required conditions
Publish to Docker Hub / Publish multi-arch manifests (push) Blocked by required conditions
Lock released branch / lock-branch (push) Waiting to run
Lock released branch / guard-no-push-after-release (push) Waiting to run
Release-Green (continuous) / Validate active release branch (push) Waiting to run
Release-Green (continuous) / Validate main branch (push) Waiting to run
Release-Green (continuous) / Bank ratchet shrinks (push) Waiting to run
Release-Green (continuous) / Baseline headroom (push) Waiting to run
Release acceptance / Release acceptance (push) Waiting to run
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
Radar Export / publish-export (push) Has been cancelled

The Cursor exec_read decoder keeps ReadArgs offset/limit and the read bridge forwards them only to a read tool whose schema accepts them, so partial reads no longer loop on 'file unchanged'. Maintainer rework on top: merged the current release tip and froze the +31-line decoder growth of open-sse/utils/cursorAgentProtobuf.ts in file-size-baseline.json. New tests fail 6 on the release tip and pass with the change; cursor exec-router/bridge/protobuf suites 102/102; typecheck and file-size gates clean. Thank you @QuangBlue!
This commit is contained in:
QuangBlue 2026-09-29 14:16:19 +07:00 • committed by GitHub
parent b27c254430
commit 113de57b95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 3 deletions

View file

@ -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)).

View file

@ -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,

View file

@ -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<ExecServerEvent, { kind: "exec_read" }>,
tools: McpToolDefinition[]
@ -284,6 +306,23 @@ function readBridge(
const pathKey = selectProperty(schema, properties, ["filePath", "path", "file_path"], "string");
if (!pathKey) continue;
const args: Record<string, unknown> = { [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;

View file

@ -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<Record<number, ExecEventDecoder>> = {
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(

View file

@ -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.

View file

@ -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([
{