diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 83e076dcced..a1d416c4547 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,15 @@ 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; + // 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) 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..ba73cf69891 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -603,6 +603,36 @@ 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) }; + }, + }; + // 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 // restore, and rewriting it would only risk drift from the admission-time rendering. test("an under-cap argument is unchanged", () => {