diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fa439e4ef..fd069b63c 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 fbc8fb780..58ebb602a 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 82ef262f8..cb0e47230 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[] = [