mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-15 01:03:09 +00:00
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
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:
parent
b27c254430
commit
113de57b95
6 changed files with 202 additions and 3 deletions
1
changelog.d/fixes/15036-cursor-read-range.md
Normal file
1
changelog.d/fixes/15036-cursor-read-range.md
Normal 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)).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue