Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, unknown>): string {
function serializeToolCallArguments(args: Record<string, unknown>): 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, unknown>): 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]`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the
* result envelope.
Expand Down Expand Up @@ -722,7 +759,17 @@ function toolCallsByCallId(messages: readonly OcxMessage[]): Map<string, Extract
continue;
}
// Same id, and not the same invocation: neither claim can be trusted for a given result.
if (existing.name !== part.name || toolCallArgumentsText(existing.arguments) !== toolCallArgumentsText(part.arguments)) {
// Identity is the FULL namespaced name — `one__read` and `two__read` are different tools, and
// comparing bare `name` labelled both results with the first namespace. Arguments count as
// different whenever either side cannot be serialized: two distinct unserializable argument
// sets are not evidence of the same call, so they must not compare equal.
const existingArgs = serializeToolCallArguments(existing.arguments);
const partArgs = serializeToolCallArguments(part.arguments);
const sameInvocation = namespacedToolName(existing.namespace, existing.name) === namespacedToolName(part.namespace, part.name)
&& existingArgs !== undefined
&& partArgs !== undefined
&& existingArgs === partArgs;
if (!sameInvocation) {
calls.delete(callId);
ambiguous.add(callId);
}
Expand Down
70 changes: 70 additions & 0 deletions tests/cursor-tool-result-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,74 @@ describe("cursor replayed tool results name their invocation", () => {
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<string, unknown> = { tag: "A" };
a.self = a;
const b: Record<string, unknown> = { 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:");
});
});
Loading