From 0cd7107552406303df0c62dfe8f04873a9173010 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:17:48 +0900 Subject: [PATCH 1/2] fix(cursor): bound invocation argument restoration --- src/adapters/cursor/protobuf-request.ts | 43 ++++++++++++++----- structure/providers/cursor.md | 2 + .../cursor-tool-result-invocation.test.ts | 15 +++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 83e076dcced..be2110c3b3a 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -949,11 +949,26 @@ function serializeToolCallArguments(args: Record): string | und /** 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)); + const encoded = new Uint8Array(Math.max(0, maxBytes)); + const { read, written } = encoder.encodeInto(text, encoded); + return read === text.length ? text : decoder.decode(encoded.subarray(0, written)); +} + +/** Return the UTF-8 length only when it fits the bound, without allocating an input-sized buffer. */ +function boundedUtf8ByteLength(text: string, maxBytes: number): number | undefined { + let bytes = 0; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length + && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) { + bytes += 4; + i += 1; + } else bytes += 3; + if (bytes > maxBytes) return undefined; + } + return bytes; } /** @@ -966,10 +981,8 @@ function truncateUtf8(text: string, maxBytes: number): string { * 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; +function serializedToolCallArgumentsText(serialized: string): string { + if (boundedUtf8ByteLength(serialized, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) !== undefined) 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]"; @@ -978,6 +991,11 @@ function toolCallArgumentsText(args: Record): string { return `${truncateUtf8(serialized, keep)}${marker}`; } +function toolCallArgumentsText(args: Record): string { + const serialized = serializeToolCallArguments(args); + return serialized === undefined ? "[unserializable arguments]" : serializedToolCallArgumentsText(serialized); +} + /** * The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the * result envelope. @@ -1052,8 +1070,13 @@ function restoreClippedInvocationArguments( if (!call) continue; const full = serializeToolCallArguments(call.arguments); if (full === undefined) continue; - const clipped = toolCallArgumentsText(call.arguments); + const clipped = serializedToolCallArgumentsText(full); if (clipped === full) continue; + // The replacement must add at least the raw UTF-8 argument-byte delta. Reject an impossible + // restoration with a bounded scan before building the widened string, JSON, and byte array. + const clippedBytes = encoder.encode(clipped).byteLength; + const fullBytes = boundedUtf8ByteLength(full, clippedBytes + spare); + if (fullBytes === undefined || fullBytes - clippedBytes > spare) 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 diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 7252130843a..ad650a1a58e 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -116,6 +116,8 @@ a small replay would otherwise clip a completed call's arguments with nearly the 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. +Before materializing a widened root, the pass uses a bounded UTF-8 scan to reject arguments whose +raw byte growth alone cannot fit the spare budget, and reuses its single argument serialization. The elision skip is load bearing, reached through initiator recovery rather than through truncation alone: a truncated root undershoots its own budget by far less than a restoration costs, but after the equal-share pass elides a trailing run, recovery drops an elided sibling to fit the user turn and diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index 7a1097c964e..6601210cfcf 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -603,6 +603,21 @@ describe("cursor spare envelope budget restores clipped invocation arguments", ( expect(line).toContain(JSON.stringify(args)); }); + test("an impossible restoration serializes its arguments only once in the refund pass", () => { + let serializations = 0; + const args = { + toJSON() { + serializations++; + return { contents: "A".repeat(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + 1) }; + }, + }; + const line = invokedLine(resultRoot(encode(writeFileHistory(args), "grok-4.6-high"))); + expect(line).toEndWith("…[arguments truncated]"); + // Indexing and initial rendering account for three calls; the refund pass adds exactly one and + // must reuse that serialization instead of calling the rendering helper for a fifth copy. + expect(serializations).toBe(4); + }); + // 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", () => { From 3d3a139d643103e8d6797794dd8dbaf656e1617f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:30:00 +0900 Subject: [PATCH 2/2] fix(cursor): drop dead spare comparison and pin the bounded byte probe --- src/adapters/cursor/protobuf-request.ts | 4 +++- .../cursor-tool-result-invocation.test.ts | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index be2110c3b3a..a1d416c4547 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1075,8 +1075,10 @@ function restoreClippedInvocationArguments( // The replacement must add at least the raw UTF-8 argument-byte delta. Reject an impossible // restoration with a bounded scan before building the widened string, JSON, and byte array. const clippedBytes = encoder.encode(clipped).byteLength; + // boundedUtf8ByteLength already gives up past clippedBytes + spare, so a returned number + // always fits; a second size comparison here can never fire. const fullBytes = boundedUtf8ByteLength(full, clippedBytes + spare); - if (fullBytes === undefined || fullBytes - clippedBytes > spare) continue; + if (fullBytes === undefined) 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 diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index 6601210cfcf..ba73cf69891 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -611,11 +611,26 @@ describe("cursor spare envelope budget restores clipped invocation arguments", ( return { contents: "A".repeat(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + 1) }; }, }; - const line = invokedLine(resultRoot(encode(writeFileHistory(args), "grok-4.6-high"))); + // The refund pass must not allocate a full-size byte buffer while deciding whether the + // restoration fits. encode() calls with a string longer than the envelope mean someone + // reintroduced the unbounded fullBytes measurement this change removed. + let oversizedEncodes = 0; + const originalEncode = TextEncoder.prototype.encode; + TextEncoder.prototype.encode = function (input?: string) { + if (typeof input === "string" && input.length > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT) oversizedEncodes++; + return originalEncode.call(this, input); + }; + let line: string | undefined; + try { + line = invokedLine(resultRoot(encode(writeFileHistory(args), "grok-4.6-high"))); + } finally { + TextEncoder.prototype.encode = originalEncode; + } expect(line).toEndWith("…[arguments truncated]"); // Indexing and initial rendering account for three calls; the refund pass adds exactly one and // must reuse that serialization instead of calling the rendering helper for a fifth copy. expect(serializations).toBe(4); + expect(oversizedEncodes).toBe(0); }); // The refund pass must be a no-op below the cap: a line that was never clipped has nothing to