From 317de041c833a55aba71437c48d9d7eae2334a75 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:13:06 +0200 Subject: [PATCH] fix(opencode): drop truncated reasoning from replayed history --- packages/opencode/src/session/message-v2.ts | 10 +- .../opencode/test/session/message-v2.test.ts | 94 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..552209263343 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -259,6 +259,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } + let droppedUnsignedReasoning = false // Anthropic adaptive thinking can persist assistant turns like: // step-start, reasoning(signature), text(""), step-start, // reasoning(signature). The empty text part is a structural separator, @@ -368,6 +369,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) continue } + if (msg.info.finish === "length" && part.metadata?.anthropic?.signature == null) { + droppedUnsignedReasoning = true + continue + } assistantMessage.parts.push({ type: "reasoning", text: part.text, @@ -375,7 +380,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } - if (assistantMessage.parts.length > 0) { + const hasSubstantivePart = assistantMessage.parts.some( + (part) => part.type !== "step-start" && (part.type !== "text" || part.text.trim().length > 0), + ) + if (assistantMessage.parts.length > 0 && (!droppedUnsignedReasoning || hasSubstantivePart)) { result.push(assistantMessage) // Inject pending media as a user message for providers that don't support // media (images, PDFs) in tool results diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aeddb0..ba285f7a5870 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -111,7 +111,101 @@ function basePart(messageID: string, id: string) { } } +function assistantReasoningInput( + finish: string, + metadata?: Record, + text = "partial answer", +): SessionV1.WithParts[] { + const userID = `m-user-reasoning-${finish}` + const assistantID = `m-assistant-reasoning-${finish}` + + return [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u-reasoning"), + type: "text", + text: "continue the task", + }, + ] as SessionV1.Part[], + }, + { + info: { ...assistantInfo(assistantID, userID), finish }, + parts: [ + { + ...basePart(assistantID, "a-text-reasoning"), + type: "text", + text, + }, + { + ...basePart(assistantID, "a-reasoning"), + type: "reasoning", + text: "incomplete chain", + metadata, + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a-step-finish"), + type: "step-finish", + reason: finish, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + ] as SessionV1.Part[], + }, + ] +} + describe("session.message-v2.toModelMessage", () => { + test("drops a truncated assistant message left with only empty text", async () => { + const result = await MessageV2.toModelMessages(assistantReasoningInput("length", undefined, ""), model) + + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "continue the task" }], + }, + ]) + }) + + test("drops unsigned reasoning from an assistant message truncated by length", async () => { + const result = await MessageV2.toModelMessages(assistantReasoningInput("length"), model) + + expect(result[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "partial answer" }], + }) + }) + + test("preserves signed reasoning from an assistant message truncated by length", async () => { + const result = await MessageV2.toModelMessages( + assistantReasoningInput("length", { anthropic: { signature: "signed" } }), + model, + ) + + expect(result[1]?.content).toContainEqual({ + type: "reasoning", + text: "incomplete chain", + providerOptions: { anthropic: { signature: "signed" } }, + }) + }) + + test("preserves reasoning from an assistant message that stopped normally", async () => { + const result = await MessageV2.toModelMessages(assistantReasoningInput("stop"), model) + + expect(result[1]?.content).toContainEqual({ + type: "reasoning", + text: "incomplete chain", + providerOptions: undefined, + }) + }) + test("filters out messages with no parts", async () => { const input: SessionV1.WithParts[] = [ {