diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 35e7abc42f..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 }); @@ -694,6 +699,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 +986,83 @@ 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); + // 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, () => `\ninvoked: ${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..bbfcdbdcf8 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,142 @@ 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. + * + * 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[] { + 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); + }); + + // 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)); + }); +});