diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index bfd217f19..1beb2eec7 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1299,7 +1299,12 @@ async function handleExecServerMessage( args, execHandlers?.read?.bind(execHandlers), onToolResult, - toolResult => buildReadResultFromToolResult(args.path, toolResult), + toolResult => + buildReadResultFromToolResult( + args.path, + toolResult, + args.offset !== undefined || args.limit !== undefined, + ), reason => buildReadRejectedResult(args.path, reason), error => buildReadErrorResult(args.path, error), { toolCallId: args.toolCallId, toolName: "read" }, @@ -2321,7 +2326,7 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo return typeof value === "boolean" ? value : false; } -function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage) { +function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage, rangeApplied = false) { const text = toolResultToText(toolResult); if (toolResult.isError) { return buildReadErrorResult(path, text || "Read failed"); @@ -2336,6 +2341,10 @@ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessa fileSize: BigInt(Buffer.byteLength(text, "utf-8")), truncated: toolResultWasTruncated(toolResult), output: { case: "content", value: text }, + // Whether the frame's window was honored. Left false for an + // unranged read: the server reads it as "this is the whole file", + // which is exactly true when no range was asked for. + rangeApplied, }), }, }); diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 41adb9f5a..22ba470e6 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -1533,3 +1533,41 @@ describe("Cursor modern exec frames: server-resolved tool calls leave a paired b expect(output.content.filter(block => block.type === "toolCall")).toHaveLength(0); }); }); + +describe("Cursor legacy read frame: range reporting", () => { + it("reports rangeApplied only when the frame asked for a window", async () => { + // `range_applied: false` tells the server "this is the whole file", which + // is true for an unranged read and a lie for a paginated one — a model + // walking a large file would stop after the first window believing it had + // seen everything. + const handlers: CursorExecHandlers = { + async read() { + return toolResult("line1\nline2"); + }, + }; + + const ranged = await dispatchExec( + buildExecMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path: "/repo/big.ts", toolCallId: "c1", offset: 5, limit: 20 }), + }), + { execHandlers: handlers }, + ); + const rangedAnswer = soleResult(ranged.frames); + if (rangedAnswer.case !== "readResult") throw new Error(`got ${rangedAnswer.case}`); + if (rangedAnswer.value.result.case !== "success") throw new Error(`got ${rangedAnswer.value.result.case}`); + expect(rangedAnswer.value.result.value.rangeApplied).toBe(true); + + const whole = await dispatchExec( + buildExecMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path: "/repo/big.ts", toolCallId: "c2" }), + }), + { execHandlers: handlers }, + ); + const wholeAnswer = soleResult(whole.frames); + if (wholeAnswer.case !== "readResult") throw new Error(`got ${wholeAnswer.case}`); + if (wholeAnswer.value.result.case !== "success") throw new Error(`got ${wholeAnswer.value.result.case}`); + expect(wholeAnswer.value.result.value.rangeApplied).toBe(false); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c6a42ed5..0cce9f43e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -152,6 +152,8 @@ - Fixed `pi_grep` returning fewer matches than it asked for when they spread across many files. The local `grep` windows results to the first 20 files and tells the caller to paginate with `skip`, but `PiGrepExecArgs` has no `skip` field — so a frame asking for 100 matches over 25 one-match files got 20, `match_limit_reached` unset, and advice it could not act on: output silently short and labelled complete. A search carrying a total match cap now reads enough files to satisfy it (cap+1, so a result landing exactly on the cap is distinguishable from a clipped one) and reports the cap when it actually bites. - Fixed every native `pi_edit` failing after a session switched onto Cursor. The replace-mode `edit` instance the frame needs was built only for sessions *created* on Cursor, and the tool roster is not rebuilt on a model switch — so a session that started elsewhere kept its configured-mode `edit` in the registry, which the bridge resolves before its fallback, and the frame's `old_text`/`new_text` pairs failed validation against a `hashline` schema. The instance is now built from the `edit` grant regardless of the session's initial provider (lazily, so a session that never reaches Cursor never constructs one) and `pi_edit` asks for it explicitly through a dedicated accessor. A session that was never granted `edit` is still refused. - Fixed the Cursor bridge's tool resolver being able to execute an unadvertised `edit`. That resolver doubles as the agent loop's fallback for any call outside the advertised set, so serving `edit` from it meant a hallucinated call — or one naming a tool the session deselected after startup — could run a replace-mode edit the model was never offered. It is device-only again; `pi_edit` uses its own accessor. +- Fixed the legacy Cursor `read` frame ignoring the `offset`/`limit` modern builds paginate with. Only the Pi variant composed a range, so every page of a legacy read returned the whole file (or its own truncation) and a model walking a large file never advanced past the first window. Both frames now translate a range through the same helper, and the answer reports `range_applied` — left false for an unranged read, which is the server's "this is the whole file". +- Fixed the legacy Cursor `grep` frame ignoring its pagination `offset`. The local `grep` paginates by file through `skip` and advertises exactly that in its own "use skip=N" advice, so an unforwarded offset re-ran the identical search and answered page one for every page. ## [17.1.5] - 2026-07-27 diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index 7159999a5..2e1d96413 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -401,10 +401,21 @@ function buildTodoSyncResult( export class CursorExecHandlers implements ICursorExecHandlers { constructor(private options: CursorExecBridgeOptions) {} + /** + * Modern Cursor builds paginate the legacy `read` frame with + * `offset`/`limit`, exactly as `pi_read` does. Dropping them returns the + * whole file (or its own truncation) for every page, so a model walking a + * large file never advances. Composed with the same helper, so both frames + * translate a range identically. + */ async read(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); - const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path }); - return toolResultMessage; + const composed = piReadPath(args.path, args.offset, args.limit); + // A present `limit: 0` asks for zero lines; no selector expresses that. + if (composed === null) { + return createToolResultMessage(toolCallId, "read", { content: [{ type: "text", text: "" }] }, false); + } + return await executeTool(this.options, "read", toolCallId, { path: composed }); } async ls(args: Parameters>[0]) { @@ -414,13 +425,21 @@ export class CursorExecHandlers implements ICursorExecHandlers { return toolResultMessage; } + /** + * Modern Cursor builds paginate this frame with `offset`. The local `grep` + * paginates by file through `skip`, which is the same unit its own + * "use skip=N for the next page" advice counts in — so an unforwarded + * offset re-runs the identical search and returns page one forever. + */ async grep(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || "."; + const skip = args.offset !== undefined && args.offset > 0 ? Math.floor(args.offset) : undefined; const toolResultMessage = await executeTool(this.options, "grep", toolCallId, { pattern: args.pattern, path: searchPath, case: args.caseInsensitive === true ? false : undefined, + skip, }); return toolResultMessage; } diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index 4b347dc51..c2d9be495 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -1461,6 +1461,32 @@ describe("CursorExecHandlers Pi frame translation", () => { expect(result.content).toEqual([{ type: "text", text: "" }]); }); + it("composes the legacy read frame's offset/limit the same way pi_read does", async () => { + // Modern Cursor builds paginate the legacy `read` frame too. Dropping the + // range returns the whole file for every page, so a model walking a large + // file never advances past the first window. + const { handlers, calls } = recordingHandlers("read"); + + await handlers.read({ toolCallId: "c1", path: "a.ts", offset: 5, limit: 20 } as never); + await handlers.read({ toolCallId: "c2", path: "a.ts" } as never); + + expect(calls).toEqual([{ path: "a.ts:raw:5+20" }, { path: "a.ts" }]); + }); + + it("forwards the legacy grep frame's offset as the local tool's file skip", async () => { + // `grep` paginates by file and reports "use skip=N for the next page" in + // that same unit. An unforwarded offset re-runs the identical search, so + // every page after the first repeats page one. + const { handlers, calls } = recordingHandlers("grep"); + + await handlers.grep({ toolCallId: "c1", pattern: "x", path: "src", offset: 20 } as never); + await handlers.grep({ toolCallId: "c2", pattern: "x", path: "src" } as never); + // A present 0 is "start at the beginning", which is the unset search. + await handlers.grep({ toolCallId: "c3", pattern: "x", path: "src", offset: 0 } as never); + + expect(calls.map(call => (call as { skip?: number }).skip)).toEqual([20, undefined, undefined]); + }); + it("returns exactly the lines a pi_read range asked for", async () => { // Producer/consumer contract against the real `ReadTool`: a plain `:N+K` // selector deliberately pads with one leading and three trailing context