From d431f3fbe31ef9dec9b1fc8dbca4211ac71630c0 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sat, 29 Aug 2026 22:10:56 +0900 Subject: [PATCH 1/2] fix(cursor): bound the replayed invocation and compare full tool identity Review of the invocation-pairing change found that the invocation line could evict the very result it describes, and could name the wrong tool. - A large but legitimate argument (a 600 KiB write_file) serialized in full consumed the whole CURSOR_EXTERNAL_ROOT_BYTE_LIMIT history budget, so truncateToolResultBlob kept the invocation prefix and cut the actual output away - recreating the orphaned-result failure the line exists to prevent. Arguments are now bounded independently at 2 KiB with an explicit marker. - Ambiguity detection compared the bare name, so one decoded call id shared by one__read and two__read labelled both results with the first namespace. It now compares the full namespacedToolName. - Every JSON.stringify failure collapsed onto one marker string, so two distinct unserializable argument sets compared equal and the first call was kept. Serialization failure is now undefined and never compares equal. Each of the three added tests was proven to fail when its own fix line is reverted, and to pass under the other two mutations. --- src/adapters/cursor/protobuf-request.ts | 61 +++++++++++++++--- tests/cursor-tool-result-invocation.test.ts | 70 +++++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index de62882d7f..f7e2b3c3ad 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -73,6 +73,13 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization"; export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192; /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */ export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024; +/** + * Byte budget for the serialized arguments named inside ONE replayed tool-result envelope. The + * invocation identifies the call; the result is the payload. Without an independent cap, a single + * large-but-legitimate argument (a 600 KiB file write) consumed the whole root history budget and + * the result output was truncated away instead. + */ +export const CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT = 2 * 1024; /** * Action text for external-model tool-result continuations. Native models keep @@ -668,18 +675,48 @@ function toolResultContentItems( } /** - * Serialize tool-call arguments for the replayed transcript. `OcxToolCall.arguments` is always an - * object, but it originates in provider JSON, so a cyclic or BigInt-bearing value must degrade to a - * marker instead of throwing inside request encoding. + * Serialize tool-call arguments for the replayed transcript, or `undefined` when they cannot be + * serialized at all. `OcxToolCall.arguments` is always an object, but it originates in provider + * JSON, so a cyclic or BigInt-bearing value must degrade instead of throwing inside request + * encoding. The failure is reported as `undefined` rather than a marker string so callers can tell + * "these two argument sets are equal" apart from "neither could be read" — collapsing both onto one + * marker made every unserializable argument set compare equal to every other. */ -function toolCallArgumentsText(args: Record): string { +function serializeToolCallArguments(args: Record): string | undefined { try { - return JSON.stringify(args) ?? "[unserializable arguments]"; + const serialized = JSON.stringify(args); + return typeof serialized === "string" ? serialized : undefined; } catch { - return "[unserializable arguments]"; + return undefined; } } +/** Truncate to a byte budget without splitting a UTF-8 sequence. */ +function truncateUtf8(text: string, maxBytes: number): string { + const encoded = encoder.encode(text); + if (encoded.byteLength <= maxBytes) return text; + let end = Math.max(0, maxBytes); + while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end -= 1; + return decoder.decode(encoded.subarray(0, end)); +} + +/** + * Rendered argument text for one invocation line, bounded independently of the result it describes. + * + * The invocation is CONTEXT for a replayed result; the result itself is the payload. Serializing + * arguments in full inverted that: a legitimate 600 KiB `write_file` argument consumed the entire + * `CURSOR_EXTERNAL_ROOT_BYTE_LIMIT` history budget, so `truncateToolResultBlob` kept the invocation + * prefix and cut the actual output away — reproducing the very orphaned-result failure this line + * exists to prevent. A bounded prefix still identifies the call (tool name plus the head of its + * arguments) while leaving the output room to survive. + */ +function toolCallArgumentsText(args: Record): string { + const serialized = serializeToolCallArguments(args); + if (serialized === undefined) return "[unserializable arguments]"; + if (encoder.encode(serialized).byteLength <= CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) return serialized; + return `${truncateUtf8(serialized, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT)}…[arguments truncated]`; +} + /** * The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the * result envelope. @@ -722,7 +759,17 @@ function toolCallsByCallId(messages: readonly OcxMessage[]): Map { expect(root).toBeDefined(); expect(root).toContain("[unserializable arguments]"); }); + + // REVIEW BLOCKER PROBE 1: a large legitimate argument must not push the actual output out of the + // root byte budget. The invocation line is a convenience; the RESULT is the payload. + test("PROBE a huge argument must not evict the result output from root replay", () => { + const messages: OcxMessage[] = [ + { role: "user", content: "Write the file.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, name: "write_file", arguments: { contents: "Z".repeat(600 * 1024) } }], + timestamp: 2, + }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "write_file", content: "SENTINEL_OUTPUT", isError: false, timestamp: 3 }, + ]; + const root = resultRoot(encode(messages, "grok-4.6-high")); + expect(root).toBeDefined(); + expect(root).toContain("SENTINEL_OUTPUT"); + }); + + // REVIEW BLOCKER PROBE 2: namespace is part of tool identity. Two different tools sharing one + // decoded id must be ambiguous, not silently labelled with the first namespace. + test("PROBE namespaced collision must not name the wrong tool", () => { + const messages: OcxMessage[] = [ + { role: "user", content: "Read both.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, namespace: "one", name: "read", arguments: { p: "a" } }], + timestamp: 2, + }, + { role: "toolResult", toolCallId: CALL_ID, toolNamespace: "one", toolName: "read", content: "A", isError: false, timestamp: 3 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, namespace: "two", name: "read", arguments: { p: "a" } }], + timestamp: 4, + }, + { role: "toolResult", toolCallId: CALL_ID, toolNamespace: "two", toolName: "read", content: "B", isError: false, timestamp: 5 }, + ]; + const roots = rootTexts(encode(messages, "grok-4.6-high")); + const results = roots.filter(text => text.startsWith("[Tool Result]")); + expect(results.length).toBeGreaterThan(0); + for (const result of results) expect(result).not.toContain("invoked:"); + }); + + // REVIEW BLOCKER PROBE 3: distinct unserializable arguments both render as the same marker, so + // the ambiguity check treats two different calls as identical and keeps the first. Same tool name + // on both calls, so ONLY the argument comparison can distinguish them. + test("PROBE distinct unserializable arguments must be treated as ambiguous", () => { + const a: Record = { tag: "A" }; + a.self = a; + const b: Record = { tag: "B" }; + b.self = b; + const messages: OcxMessage[] = [ + { role: "user", content: "Run both.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, name: "exec_command", arguments: a }], + timestamp: 2, + }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "exec_command", content: "A", isError: false, timestamp: 3 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, name: "exec_command", arguments: b }], + timestamp: 4, + }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "exec_command", content: "B", isError: false, timestamp: 5 }, + ]; + const roots = rootTexts(encode(messages, "grok-4.6-high")); + const results = roots.filter(text => text.startsWith("[Tool Result]")); + expect(results.length).toBeGreaterThan(0); + for (const result of results) expect(result).not.toContain("invoked:"); + }); }); From 37048f56d7eec21b908cce997cc683ca5117bfc1 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sat, 29 Aug 2026 22:23:39 +0900 Subject: [PATCH 2/2] fix(cursor): count the truncation marker inside the argument budget The cap is a budget for the rendered invocation line, so appending the marker to a full-size prefix put every truncated invocation over the declared limit. The marker now comes out of the budget, and a test asserts the rendered argument text stays within it. --- src/adapters/cursor/protobuf-request.ts | 7 ++++++- tests/cursor-tool-result-invocation.test.ts | 22 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index f7e2b3c3ad..540d3cff31 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -714,7 +714,12 @@ function toolCallArgumentsText(args: Record): string { const serialized = serializeToolCallArguments(args); if (serialized === undefined) return "[unserializable arguments]"; if (encoder.encode(serialized).byteLength <= CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) return serialized; - return `${truncateUtf8(serialized, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT)}…[arguments truncated]`; + // The budget is the size of the RENDERED line, so the marker has to come out of it rather than be + // added on top: otherwise every truncated invocation exceeds the declared limit by the marker. + const marker = "…[arguments truncated]"; + const markerBytes = encoder.encode(marker).byteLength; + const keep = Math.max(0, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT - markerBytes); + return `${truncateUtf8(serialized, keep)}${marker}`; } /** diff --git a/tests/cursor-tool-result-invocation.test.ts b/tests/cursor-tool-result-invocation.test.ts index 5642455566..dfb588ae45 100644 --- a/tests/cursor-tool-result-invocation.test.ts +++ b/tests/cursor-tool-result-invocation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { create, fromBinary } from "@bufbuild/protobuf"; -import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT, encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; import { handleCursorNativeKv } from "../src/adapters/cursor/native-exec"; import { AgentClientMessageSchema, @@ -227,6 +227,26 @@ describe("cursor replayed tool results name their invocation", () => { expect(root).toContain("SENTINEL_OUTPUT"); }); + // The cap is a budget for the RENDERED line, so the truncation marker must come out of it rather + // than be appended on top of a full-size prefix. + test("the truncated invocation line stays within the declared argument budget", () => { + const messages: OcxMessage[] = [ + { role: "user", content: "Write the file.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: CALL_ID, name: "write_file", arguments: { contents: "Z".repeat(600 * 1024) } }], + timestamp: 2, + }, + { role: "toolResult", toolCallId: CALL_ID, toolName: "write_file", content: "SENTINEL_OUTPUT", isError: false, timestamp: 3 }, + ]; + const root = resultRoot(encode(messages, "grok-4.6-high")); + const line = root?.split("\n").find(text => text.startsWith("invoked: ")); + expect(line).toBeDefined(); + expect(line).toContain("…[arguments truncated]"); + const rendered = line!.slice("invoked: write_file with ".length); + expect(new TextEncoder().encode(rendered).byteLength).toBeLessThanOrEqual(CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT); + }); + // REVIEW BLOCKER PROBE 2: namespace is part of tool identity. Two different tools sharing one // decoded id must be ambiguous, not silently labelled with the first namespace. test("PROBE namespaced collision must not name the wrong tool", () => {