From bf8e04bee026325e2e98398fc8754b103086407d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 03:21:39 +0900 Subject: [PATCH 1/4] fix(cursor): refund spare envelope bytes to clipped invocation arguments The 2 KiB per-call cap on the arguments named inside a replayed tool-result envelope is charged while the envelope is still being built, so it cost a call 2 KiB whether or not anything else wanted those bytes. In a small replay nearly the whole 192-root / 512 KiB envelope went unused and the cap still bit: a 4,693-byte successful write_file lost its tail inside a 6,011-byte replay, and because the result text does not repeat the argument, the model could no longer see what it had just written. Add a second pass after the root set is assembled and before it is stored. It spends only leftover aggregate bytes, newest tool result first, skips a root whose own output was already elided, and never drops, shrinks or reorders a retained root. The cap itself is unchanged and still decides admission on its 2 KiB prefix, so a 600 KiB argument stays clipped rather than evicting the output it describes. The gate is echoToolResultInRoot, not externalModel: native composer-2.5 echoes results into roots without being an external wire model, so the narrower gate would have left the one native model with clipped invocation lines capped for no reason. The widening uses the callback form of String.prototype.replace, because serialized arguments routinely contain $&, $' and $1, which the string form would expand into the surrounding match. Closes #4516 --- src/adapters/cursor/protobuf-request.ts | 87 +++++++++++++++ structure/providers/cursor.md | 15 +++ .../cursor-tool-result-invocation.test.ts | 105 +++++++++++++++++- 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 35e7abc42f..71eacbf212 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -694,6 +694,20 @@ function rootPromptMessages( historyMessageStart = firstKept?.messageIndex ?? (messages.length); } + // Refund envelope bytes the assembled set left unused to invocation arguments the per-call cap + // clipped. Gated on `echoToolResultInRoot`, not `externalModel`: native `composer-2.5` echoes its + // results into roots without being an external wire model, so the narrower gate would leave the one + // native model that has clipped invocation lines capped for no reason (#4516). + if (echoToolResultInRoot && replayedCalls) { + selected = restoreClippedInvocationArguments( + selected, + messages, + replayedCalls, + knownCallsOffset, + carriedRoots.byteLength, + ); + } + return { ids: selected.map(entry => storeCursorBlob(entry.data, requestScope)), byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0), @@ -967,6 +981,79 @@ function toolInvocationLine(call: Extract>, + knownCallsOffset: number, + carriedBytes: number, +): RootBlobCandidate[] { + let spare = CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + - carriedBytes + - selected.reduce((sum, entry) => sum + entry.byteLength, 0); + if (spare <= 0) return selected; + const restored = [...selected]; + for (let i = restored.length - 1; i >= 0 && spare > 0; i--) { + const entry = restored[i]; + if (!entry || entry.role !== "toolResult" || entry.outputElided === true) continue; + if (entry.text === undefined || entry.messageIndex === undefined) continue; + const message = messages[entry.messageIndex]; + if (message?.role !== "toolResult") continue; + // Same full-history bound the envelope builder used: `messageIndex` is local to this call's + // `rawMessages`, and `knownCallsOffset` re-bases it when only a suffix is replayed. + const call = callBefore( + replayedCalls, + decodeCursorCallId(message.toolCallId), + knownCallsOffset + entry.messageIndex, + ); + if (!call) continue; + const full = serializeToolCallArguments(call.arguments); + if (full === undefined) continue; + const clipped = toolCallArgumentsText(call.arguments); + if (clipped === full) continue; + const name = namespacedToolName(call.namespace, call.name); + const clippedLine = `invoked: ${name} with ${clipped}`; + // Absent when truncation already cut through the invocation line itself; there is nothing to + // widen in that root, and re-rendering the envelope would undo the output truncation too. + if (!entry.text.includes(clippedLine)) continue; + // Callback replacement: serialized arguments routinely contain `$&`, `$'` and `$1`, and the + // string form of `replace` expands those into the surrounding match instead of inserting them. + const widened = entry.text.replace(clippedLine, () => `invoked: ${name} with ${full}`); + const candidate = rootBlobCandidate( + toolResultRootPayload(widened), + "toolResult", + { messageIndex: entry.messageIndex, text: widened }, + ); + const cost = candidate.byteLength - entry.byteLength; + if (cost <= 0 || cost > spare) continue; + restored[i] = candidate; + spare -= cost; + } + return restored; +} + /** * History position of each indexed call, keyed by the map `toolCallsByCallId` returned. * diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 55f017685b..45093f6af6 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -81,6 +81,21 @@ does not expose authoritative cache_read_tokens. > Decision record: [ADR-0054](../decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md) +## Cursor root replay budgets + +`src/adapters/cursor/protobuf-request.ts` bounds the replayed root set at 192 blobs and 512 KiB, and +caps the serialized arguments named inside one replayed tool-result envelope at 2 KiB. That per-call +cap is what keeps a 600 KiB argument from consuming the aggregate budget and evicting the output it +describes, and it still decides admission. Because it is charged while the envelope is being built, +a small replay would otherwise clip a completed call's arguments with nearly the whole envelope +unused. After every pruning and truncation decision is final, a second pass re-widens clipped +invocation lines out of the leftover aggregate bytes only: newest tool result first, skipping a root +whose own output was already elided, and never dropping, shrinking or reordering a retained root. +Root-echo eligibility is `cursorNeedsExternalToolContinuation`, which includes native +`composer-2.5`, not only external wire models, so the restoration reaches every replay that carries +an invocation line. Coverage lives in +`tests/providers/cursor/cursor-tool-result-invocation.test.ts`. + ## Cursor executable tool schema ownership `src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index aa3b9d16d0..d9e473991b 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT, encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT, encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; import { handleCursorNativeKv, storeCursorBlob } from "../../../src/adapters/cursor/native-exec"; import { AgentClientMessageSchema, @@ -553,3 +553,106 @@ describe("cursor invocation lookup is bounded by history position", () => { expect(step).toContain("echo COVERED"); }); }); + +/** + * #4516: the 2 KiB invocation-argument cap is charged while the envelope is still being built, so + * it cost a call 2 KiB even when nearly the whole 512 KiB envelope went unused. A 4.6 KiB + * successful write_file lost its argument tail inside a 6 KiB replay, and because the result text + * does not repeat the argument, the model could no longer see what it had just written. + * + * The cap stays — it is what stops a 600 KiB argument from evicting the output it describes — but a + * second pass now refunds leftover aggregate bytes to clipped invocation lines, newest result + * first, without evicting or shrinking any root. These tests pin the refund: full restoration when + * the envelope is idle, a no-op below the cap, coverage of the native composer-2.5 path the gate + * exists for, verbatim handling of String.replace patterns inside arguments, and a hard stop at + * the envelope boundary. + */ +describe("cursor spare envelope budget restores clipped invocation arguments", () => { + function writeFileHistory(args: Record): OcxMessage[] { + return [ + { role: "user", content: "Write the file.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, name: "write_file", arguments: args }], + timestamp: 2, + }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "write_file", content: "SENTINEL_OUTPUT", isError: false, timestamp: 3 }, + ]; + } + + function invokedLine(root: string | undefined): string | undefined { + return root?.split("\n").find(text => text.startsWith("invoked: ")); + } + + test("an oversized argument is restored in full when the envelope is idle", () => { + const args = { contents: "A".repeat(4600) }; + const root = resultRoot(encode(writeFileHistory(args), "grok-4.6-high")); + expect(root).toBeDefined(); + expect(root).toContain("SENTINEL_OUTPUT"); + const line = invokedLine(root); + expect(line).toBeDefined(); + expect(line).not.toContain("…[arguments truncated]"); + expect(line).toContain(JSON.stringify(args)); + }); + + // The refund pass must be a no-op below the cap: a line that was never clipped has nothing to + // restore, and rewriting it would only risk drift from the admission-time rendering. + test("an under-cap argument is unchanged", () => { + const args = { contents: "A".repeat(64) }; + const root = resultRoot(encode(writeFileHistory(args), "grok-4.6-high")); + expect(invokedLine(root)).toBe("invoked: write_file with " + JSON.stringify(args)); + }); + + // composer-2.5 is a NATIVE wire model (isCursorExternalWireModel is false) that still routes + // through the external tool-continuation path, so it echoes results into roots and accumulates + // the same clipped lines. This is the case the echoToolResultInRoot gate exists for: a gate + // written as externalModel would leave the one native model with clipped lines capped. + test("native composer-2.5 root replay is restored too", () => { + const args = { contents: "A".repeat(4600) }; + const root = resultRoot(encode(writeFileHistory(args), "composer-2.5")); + expect(root).toBeDefined(); + const line = invokedLine(root); + expect(line).toBeDefined(); + expect(line).not.toContain("…[arguments truncated]"); + expect(line).toContain(JSON.stringify(args)); + }); + + // Serialized arguments routinely contain $&, $', $` and $1. The widening must use the callback + // form of String.prototype.replace: the string form expands those sequences into the surrounding + // match and writes corrupted arguments into the root. + test("replacement patterns inside arguments are not expanded", () => { + const args = { contents: "$&$'`$1" + "B".repeat(4600) }; + const root = resultRoot(encode(writeFileHistory(args), "grok-4.6-high")); + expect(invokedLine(root)).toContain(JSON.stringify(args)); + }); + + // The refund is bounded by the envelope's own leftover bytes, newest result first: when the spare + // cannot cover every clipped line, the pass must stop mid-set rather than overrun the limit, and + // the result the model most likely still needs — the one it just produced — is restored first. + test("restoration stops at the envelope and prefers the newest result", () => { + const messages: OcxMessage[] = []; + for (let n = 0; n < 60; n++) { + messages.push( + { role: "user", content: "round " + n, timestamp: n * 3 + 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_" + n, name: "write_file", arguments: { path: "/f" + n, contents: "C".repeat(16 * 1024) } }], + timestamp: n * 3 + 2, + }, + { role: "toolResult", toolCallId: "call_" + n, toolName: "write_file", content: "OUT_" + n, isError: false, timestamp: n * 3 + 3 }, + ); + } + const bytes = encode(messages, "grok-4.6-high"); + const blobIds = runRequest(bytes)?.conversationState?.rootPromptMessagesJson ?? []; + const total = blobIds.reduce((sum, blobId) => sum + blobData(blobId).byteLength, 0); + expect(total).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + + const results = rootTexts(bytes).filter(text => text.startsWith("[Tool Result]")); + const newest = results.find(text => text.includes("OUT_59")); + expect(newest).toBeDefined(); + expect(invokedLine(newest)).toBeDefined(); + expect(invokedLine(newest)).not.toContain("…[arguments truncated]"); + const stillClipped = results.filter(text => invokedLine(text)?.includes("…[arguments truncated]")); + expect(stillClipped.length).toBeGreaterThan(0); + }); +}); From 4ca2979d0d41ac0d73fd0696c7eb36d392059dfb Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 03:35:27 +0900 Subject: [PATCH 2/4] fix(cursor): accept readonly rawMessages in the restoration pass request.rawMessages is readonly OcxMessage[]; the new second pass declared a mutable OcxMessage[] parameter, which strict typecheck rejects (TS4104). The pass only reads the array, so widen the parameter instead of copying. --- src/adapters/cursor/protobuf-request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 71eacbf212..23d248c279 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1005,7 +1005,7 @@ function toolInvocationLine(call: Extract>, knownCallsOffset: number, carriedBytes: number, From fd04b5d545d46c336b0bd9dabcfa576b9d11a742 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 03:51:13 +0900 Subject: [PATCH 3/4] fix(cursor): keep a collapsed root's run note through a rebuild An adversarial counter-read of the restoration pass found the real defect one layer down. pushDeduped builds the collapsed root's wire payload from the marked text but stored the UNMARKED text in the candidate's `text` field, so every consumer that rebuilds a root from `text` silently deleted the "produced N times in a row" note: truncateToolResultBlob already did, and the new invocation restoration did too. That note is the repetition breaker's per-entry half, so losing it re-primes the self-reinforcing loop the breaker exists to end. Store the marked text, which makes `text` a true mirror of the stored payload for the first time, and fixes the truncation path by the same change. Also anchor the restoration's search on the preceding newline. toolResultToText always emits the invocation after the [tool_result], call_id: and name: lines, so the real line is never first; name: renders the result's tool name, which nothing sanitizes, so an unanchored search could be satisfied by a crafted tool name and rewrite that header instead of the invocation. The regression test fails with the pushDeduped change reverted and passes with it. --- src/adapters/cursor/protobuf-request.ts | 15 ++++++++-- .../cursor-tool-result-invocation.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 23d248c279..f36fde69ee 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -325,7 +325,12 @@ function rootPromptMessages( const replacement = rootBlobCandidate( { role: payload.role, content: [{ type: "text", text: marked }] }, role, - { ...opts, messageIndex: previous.entry.messageIndex ?? opts.messageIndex }, + // `text` must mirror the payload actually stored, not the unmarked text it was built from. + // It did not, and every consumer that rebuilds a root from `text` therefore dropped the run + // note: truncating a collapsed root silently deleted the "produced N times" line, and so did + // the invocation-argument restoration below. The note is the repetition breaker's per-entry + // half, so losing it re-primes the self-reinforcing loop the breaker exists to end. + { ...opts, text: marked, messageIndex: previous.entry.messageIndex ?? opts.messageIndex }, ); entries[entries.indexOf(previous.entry)] = replacement; replayRuns.set(role, { text: normalized, entry: replacement, length: runLength }); @@ -1034,13 +1039,17 @@ function restoreClippedInvocationArguments( const clipped = toolCallArgumentsText(call.arguments); if (clipped === full) continue; const name = namespacedToolName(call.namespace, call.name); - const clippedLine = `invoked: ${name} with ${clipped}`; + // Anchored on the preceding newline. `toolResultToText` always emits the invocation after the + // `[tool_result]`, `call_id:` and `name:` lines, so the real line is never first — and + // `name:` renders the RESULT's tool name, which nothing sanitizes, so an unanchored search could + // be satisfied by a crafted tool name and rewrite that header instead of the invocation. + const clippedLine = `\ninvoked: ${name} with ${clipped}`; // Absent when truncation already cut through the invocation line itself; there is nothing to // widen in that root, and re-rendering the envelope would undo the output truncation too. if (!entry.text.includes(clippedLine)) continue; // Callback replacement: serialized arguments routinely contain `$&`, `$'` and `$1`, and the // string form of `replace` expands those into the surrounding match instead of inserting them. - const widened = entry.text.replace(clippedLine, () => `invoked: ${name} with ${full}`); + const widened = entry.text.replace(clippedLine, () => `\ninvoked: ${name} with ${full}`); const candidate = rootBlobCandidate( toolResultRootPayload(widened), "toolResult", diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index d9e473991b..a9d6fb152a 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -655,4 +655,32 @@ describe("cursor spare envelope budget restores clipped invocation arguments", ( const stillClipped = results.filter(text => invokedLine(text)?.includes("…[arguments truncated]")); expect(stillClipped.length).toBeGreaterThan(0); }); + + // An adversarial counter-read of this change found the real defect here: pushDeduped built the + // collapsed root's wire payload from the marked text but stored the UNMARKED text in `entry.text`, + // so anything that rebuilt a root from `text` silently deleted the "produced N times in a row" + // note — the restoration pass below, and truncation before it. That note is the repetition + // breaker's per-entry half, so losing it re-primes the self-reinforcing loop the breaker exists to + // end. Restoring the arguments and keeping the note are both required. + test("a collapsed repeat run keeps its run note while its arguments are restored", () => { + const args = { contents: "A".repeat(4600) }; + const messages: OcxMessage[] = [ + { role: "user", content: "Write the file.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, name: "write_file", arguments: args }], + timestamp: 2, + }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "write_file", content: "SAME_OUTPUT", isError: false, timestamp: 3 }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "write_file", content: "SAME_OUTPUT", isError: false, timestamp: 4 }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "write_file", content: "SAME_OUTPUT", isError: false, timestamp: 5 }, + ]; + const results = rootTexts(encode(messages, "grok-4.6-high")).filter(text => text.startsWith("[Tool Result]")); + expect(results).toHaveLength(1); + const collapsed = results[0]!; + expect(collapsed).toContain("[note: this exact output was produced 3 times in a row]"); + const line = invokedLine(collapsed); + expect(line).not.toContain("…[arguments truncated]"); + expect(line).toContain(JSON.stringify(args)); + }); }); From 3d41c8c2d86e2d00276b89de22d4523478b9e89e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 04:00:34 +0900 Subject: [PATCH 4/4] test(cursor): record why the 600 KiB cap tests are not refund tests The refund leaves those two fixtures alone because restoring a 600 KiB argument costs more than the whole envelope, so cost > spare is always true there. That is a size-dependent skip, not a rule that the line stays clipped: an argument over the cap but well under the envelope is restored by design. Anyone shrinking those fixtures to speed them up would silently convert them from tests of the cap into tests of the refund, which is the one reading that would make them vacuous. --- .../cursor/cursor-tool-result-invocation.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index a9d6fb152a..bbfcdbdcf8 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -566,6 +566,14 @@ describe("cursor invocation lookup is bounded by history position", () => { * the envelope is idle, a no-op below the cap, coverage of the native composer-2.5 path the gate * exists for, verbatim handling of String.replace patterns inside arguments, and a hard stop at * the envelope boundary. + * + * One thing to know about the two 600 KiB tests above ("PROBE a huge argument must not evict the + * result output from root replay" and "the truncated invocation line stays within the declared + * argument budget"): the refund leaves them alone because restoring a 600 KiB argument costs more + * than the whole envelope, so `cost > spare` is always true there. That is a size-dependent skip, + * not a rule that the line stays clipped — an argument over the cap but well under the envelope IS + * restored, which is the entire point of this block. Anyone shrinking those fixtures to speed them + * up would silently convert them into tests of the refund instead of tests of the cap. */ describe("cursor spare envelope budget restores clipped invocation arguments", () => { function writeFileHistory(args: Record): OcxMessage[] {