diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index de62882d7f..540d3cff31 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,53 @@ 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; + // 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}`; +} + /** * The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the * result envelope. @@ -722,7 +764,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"); + }); + + // 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", () => { + 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:"); + }); });