From 88095e6259cdadc37d0aa8efdbe6134a0043135d Mon Sep 17 00:00:00 2001 From: Robin Newhouse Date: Thu, 14 May 2026 13:05:13 -0700 Subject: [PATCH] fix(sdk): preserve thinking blocks during aggregate truncation --- .../session/services/message-builder.test.ts | 54 +++++++++++++++---- .../src/session/services/message-builder.ts | 19 +++---- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/sdk/packages/core/src/session/services/message-builder.test.ts b/sdk/packages/core/src/session/services/message-builder.test.ts index 161feb5e67..b85c0c5525 100644 --- a/sdk/packages/core/src/session/services/message-builder.test.ts +++ b/sdk/packages/core/src/session/services/message-builder.test.ts @@ -499,9 +499,10 @@ describe("MessageBuilder", () => { }); // CLINE-2191 (Layer A): widen MessageBuilder.collectTruncationCandidates - // beyond tool_result content so user text, assistant text, thinking - // blocks, and top-level file blocks also participate in the aggregate - // budget. Also tie the budget to the model's actual maxInputTokens. + // beyond tool_result content so user text, assistant text, and top-level + // file blocks also participate in the aggregate budget. Thinking blocks + // are counted but not middle-truncated because their signatures/details + // are tied to the original reasoning payload. it("truncates user text blocks under the aggregate budget (CLINE-2191)", () => { const builder = new MessageBuilder(50_000, undefined, 500_000); const messages: Message[] = [ @@ -521,13 +522,17 @@ describe("MessageBuilder", () => { expect(block.text).toContain("provider request budget"); }); - it("truncates assistant text and thinking blocks under the aggregate budget (CLINE-2191)", () => { + it("truncates assistant text but preserves signed thinking blocks under the aggregate budget (CLINE-2191)", () => { const builder = new MessageBuilder(50_000, undefined, 250_000); const messages: Message[] = [ { role: "assistant", content: [ - { type: "thinking", thinking: "t".repeat(1_000_000) }, + { + type: "thinking", + thinking: "t".repeat(1_000_000), + signature: "sig-1", + }, { type: "text", text: "a".repeat(2_000_000) }, ], }, @@ -535,18 +540,49 @@ describe("MessageBuilder", () => { const result = builder.buildForApi(messages); const serialized = JSON.stringify(result); - expect(serialized.length).toBeLessThan(1_000_000); expect(serialized).toContain("provider request budget"); - // Both blocks got reduced; neither is the full 2 MB / 1 MB original. const content = result[0].content as Array<{ type: string }>; const thinking = content.find((b) => b.type === "thinking") as unknown as - | { thinking: string } + | { thinking: string; signature?: string } | undefined; const text = content.find((b) => b.type === "text") as unknown as | { text: string } | undefined; if (!thinking || !text) throw new Error("expected both blocks present"); - expect(thinking.thinking.length).toBeLessThan(1_000_000); + expect(thinking.thinking).toBe("t".repeat(1_000_000)); + expect(thinking.signature).toBe("sig-1"); + expect(text.text.length).toBeLessThan(2_000_000); + }); + + it("preserves unsigned thinking blocks with details under the aggregate budget (CLINE-2191)", () => { + const details = [{ provider: "anthropic", hash: "opaque" }]; + const builder = new MessageBuilder(50_000, undefined, 250_000); + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "u".repeat(750_000), + details, + }, + { type: "text", text: "b".repeat(2_000_000) }, + ], + }, + ]; + + const result = builder.buildForApi(messages); + const content = result[0].content as Array<{ type: string }>; + const thinking = content.find((b) => b.type === "thinking") as unknown as + | { thinking: string; details?: unknown[] } + | undefined; + const text = content.find((b) => b.type === "text") as unknown as + | { text: string } + | undefined; + if (!thinking || !text) throw new Error("expected both blocks present"); + expect(thinking.thinking).toBe("u".repeat(750_000)); + expect(thinking.details).toEqual(details); + expect(text.text).toContain("provider request budget"); expect(text.text.length).toBeLessThan(2_000_000); }); diff --git a/sdk/packages/core/src/session/services/message-builder.ts b/sdk/packages/core/src/session/services/message-builder.ts index 60f6a33ab4..cee6273113 100644 --- a/sdk/packages/core/src/session/services/message-builder.ts +++ b/sdk/packages/core/src/session/services/message-builder.ts @@ -898,9 +898,12 @@ export class MessageBuilder { // share the existing `MIN_TOTAL_BUDGET_TOOL_RESULT_BYTES` // floor; truncating below that loses too much signal. // - // `redacted_thinking` is intentionally skipped — its - // content is a fixed placeholder. `tool_use` and its - // `input` body are skipped here too; a JSON-aware + // `thinking` and `redacted_thinking` are intentionally + // skipped: signatures/details are tied to the original + // reasoning payload, so middle-truncating the text can make + // providers reject the request. Layer B may remove those + // blocks whole if the request still exceeds budget. + // `tool_use` and its `input` body are skipped here too; a JSON-aware // structural truncator that can drill into values // without corrupting `tool_use_id`s or breaking JSON // shape is the responsibility of Layer B (CLINE-2192). @@ -914,16 +917,6 @@ export class MessageBuilder { }); continue; } - if (block.type === "thinking") { - candidates.push({ - byteLength: utf8ByteLength(block.thinking), - get: () => block.thinking, - set: (value) => { - block.thinking = value; - }, - }); - continue; - } if (block.type === "file") { candidates.push({ byteLength: utf8ByteLength(block.content),