From 05764f0ee55296de171a24d418e7f91a22c231ae Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 13:13:45 -0400 Subject: [PATCH] fix(opencode): strip thinking blocks from compaction input, guard orphaned boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #267 — compaction fails with API error on Bedrock Claude models using extended thinking because reasoning blocks in the latest assistant message cannot be modified. Two independent fixes: 1. Add forCompaction option to toModelMessagesEffect that unconditionally downgrades reasoning blocks to plain text (regardless of model match), enforcing the spec invariant that reasoning does not survive compaction. Wire this option in the compaction caller. 2. Guard filterCompacted's second-phase reorder to require !msg.info.error on the summary-assistant, preventing an errored compaction from truncating pre-tail history (the 'context cleared' symptom). --- packages/opencode/src/session/compaction.ts | 1 + packages/opencode/src/session/message-v2.ts | 7 +- .../opencode/test/session/message-v2.test.ts | 130 ++++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fa439e4eff..fd069b63ce 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -351,6 +351,7 @@ const layer = Layer.effect( const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true, toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + forCompaction: true, }) const ctx = yield* InstanceState.context const msg: SessionV1.Assistant = { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index fbc8fb7804..58ebb602a3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -131,7 +131,7 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number; forCompaction?: boolean }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -368,7 +368,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { - if (differentModel) { + if (differentModel || options?.forCompaction) { if (part.text.trim().length > 0) assistantMessage.parts.push({ type: "text", @@ -425,7 +425,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number; forCompaction?: boolean }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options)) } @@ -565,6 +565,7 @@ export function filterCompacted(msgs: Iterable) { index > compactionIndex && msg.info.role === "assistant" && msg.info.summary && + !msg.info.error && msg.info.parentID === compaction.info.id, ) : -1 diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 82ef262f84..cb0e47230e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1361,6 +1361,40 @@ describe("session.message-v2.toModelMessage", () => { const texts = (result[0].content as any[]).filter((p) => p.type === "text") expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"]) }) + + test("strips reasoning blocks when forCompaction is true, even for same model", async () => { + // When building messages for the compaction agent, reasoning blocks must be + // unconditionally stripped (downgraded to text) regardless of model match. + // This prevents the Anthropic API error: "thinking blocks in the latest + // assistant message cannot be modified." + const assistantID = "m-assistant-compaction" + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "deep thinking about the problem", + metadata: { anthropic: { signature: "sig-compaction" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "the answer" }, + ] as SessionV1.Part[], + }, + ] + + // Without forCompaction, same-model reasoning is preserved + const preserved = await MessageV2.toModelMessages(input, model) + const preservedParts = preserved[0].content as any[] + expect(preservedParts.some((p) => p.type === "reasoning")).toBe(true) + + // With forCompaction, reasoning is stripped to text + const stripped = await MessageV2.toModelMessages(input, model, { forCompaction: true }) + const strippedParts = stripped[0].content as any[] + expect(strippedParts.some((p) => p.type === "reasoning")).toBe(false) + expect(strippedParts.some((p) => p.type === "text" && p.text === "deep thinking about the problem")).toBe(true) + expect(strippedParts.some((p) => p.type === "text" && p.text === "the answer")).toBe(true) + }) }) describe("session.message-v2.fromError", () => { @@ -1661,6 +1695,102 @@ describe("session.message-v2.latest", () => { }) }) +describe("session.message-v2.filterCompacted — orphaned compaction guard", () => { + const ANCIENT_USER = MessageID.make("msg_009") + const ANCIENT_ASSISTANT = MessageID.make("msg_010") + const TAIL_USER = MessageID.make("msg_011") + const TAIL_ASSISTANT = MessageID.make("msg_012") + const COMPACTION_USER = MessageID.make("msg_013") + const ERRORED_SUMMARY = MessageID.make("msg_014") + + const ancientUser: SessionV1.WithParts = { + info: userInfo(ANCIENT_USER), + parts: [{ ...basePart(ANCIENT_USER, "p1"), type: "text", text: "ancient question" }] as SessionV1.Part[], + } + + const ancientAssistant: SessionV1.WithParts = { + info: { + ...assistantInfo(ANCIENT_ASSISTANT, ANCIENT_USER), + finish: "stop", + tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 }, total: 200 }, + } as SessionV1.Assistant, + parts: [{ ...basePart(ANCIENT_ASSISTANT, "p1"), type: "text", text: "ancient response" }] as SessionV1.Part[], + } + + const tailUser: SessionV1.WithParts = { + info: userInfo(TAIL_USER), + parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "recent question" }] as SessionV1.Part[], + } + + const tailAssistant: SessionV1.WithParts = { + info: { + ...assistantInfo(TAIL_ASSISTANT, TAIL_USER), + finish: "stop", + tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 }, total: 200 }, + } as SessionV1.Assistant, + parts: [{ ...basePart(TAIL_ASSISTANT, "p1"), type: "text", text: "recent response" }] as SessionV1.Part[], + } + + // tail_start_id points at TAIL_USER, meaning ancientUser + ancientAssistant + // would normally be "summarized away" — but only if the summary succeeds. + const compactionUser: SessionV1.WithParts = { + info: userInfo(COMPACTION_USER), + parts: [ + { + ...basePart(COMPACTION_USER, "p1"), + type: "compaction", + auto: true, + tail_start_id: TAIL_USER, + }, + ] as SessionV1.Part[], + } + + test("errored summary does not truncate pre-tail history", () => { + // When the compaction LLM call fails (e.g. thinking block API error), the + // summary-assistant gets summary:true but also error set. filterCompacted + // must NOT use this as a valid boundary — the ancient messages must survive. + const erroredSummary: SessionV1.WithParts = { + info: { + ...assistantInfo(ERRORED_SUMMARY, COMPACTION_USER), + summary: true, + finish: "error", + error: { name: "APIError", message: "thinking blocks cannot be modified" } as any, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 }, total: 0 }, + } as SessionV1.Assistant, + parts: [], + } + + // Newest-first (DB order) + const input: SessionV1.WithParts[] = [ + erroredSummary, + compactionUser, + tailAssistant, + tailUser, + ancientAssistant, + ancientUser, + ] + + const result = MessageV2.filterCompacted(input) + + // ALL messages must be preserved — errored compaction is not a valid boundary + expect(result).toHaveLength(6) + // Critically: ancient messages (before tail_start_id) must still be present + expect(result.some((m) => m.info.id === ANCIENT_USER)).toBe(true) + expect(result.some((m) => m.info.id === ANCIENT_ASSISTANT)).toBe(true) + }) + + test("missing summary does not truncate pre-tail history", () => { + // Compaction marker exists with tail_start_id but no summary-assistant + const input: SessionV1.WithParts[] = [compactionUser, tailAssistant, tailUser, ancientAssistant, ancientUser] + + const result = MessageV2.filterCompacted(input) + + // All messages preserved + expect(result).toHaveLength(5) + expect(result.some((m) => m.info.id === ANCIENT_USER)).toBe(true) + }) +}) + describe("session.message-v2.toModelMessage — skill parts", () => { test("expands skill part content to text for the model", async () => { const input: SessionV1.WithParts[] = [