diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..c36f1a6e99aa 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -259,27 +259,27 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } - // 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, - // but it does not carry the signature metadata itself. Dropping it shifts - // signed thinking positions after step-start splitting/provider regrouping; - // keeping it as "" is filtered by the AI SDK and rejected by Anthropic. - // It is unclear whether this shape originates in our stream processing, - // a proxy, or a lower-level library, but preserving a non-empty separator - // here is the only safe replay point we have. - // Use a single space so the separator survives replay without changing - // the neighboring signed reasoning blocks. + // Anthropic extended thinking signs reasoning blocks and rejects any + // modification to the assistant message that contains them — including + // mutations to adjacent text parts that shift content-block indices. + // An empty text part ("") can appear as a structural separator between + // reasoning groups (e.g. step-start, reasoning(sig), text(""), + // step-start, reasoning(sig)). Previously this was rewritten to a + // single space (" ") to survive AI-SDK filtering, but that mutation + // itself causes Anthropic to reject the signed reasoning blocks as + // "modified". Dropping the empty separator is safe because step-start + // parts already delimit reasoning groups, and the absence of the text + // part does not change the signed blocks themselves. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { if (part.type === "text") { - const text = part.text === "" && hasSignedReasoning ? " " : part.text + if (part.text === "" && hasSignedReasoning) continue assistantMessage.parts.push({ type: "text", - text, + text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } @@ -361,6 +361,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( } if (part.type === "reasoning") { if (differentModel) { + // When replaying to a different model, signed reasoning blocks + // cannot be preserved verbatim (the signature is Anthropic-specific). + // Downgrade readable thinking to plain text and silently drop + // redacted thinking (which has no readable content). This is a + // one-way transformation for cross-provider replay only; same-model + // replay preserves the signed block below. if (part.text.trim().length > 0) assistantMessage.parts.push({ type: "text", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aeddb0..23af0971931d 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1259,8 +1259,14 @@ describe("session.message-v2.toModelMessage", () => { ]) }) - test("substitutes space for empty text between signed reasoning blocks", async () => { - // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)] + test("drops empty text between signed reasoning blocks to preserve signature validity", async () => { + // Anthropic signs reasoning blocks and rejects any modification to the + // assistant message that contains them. An empty text part ("") can + // appear as a structural separator between reasoning groups. Previously + // it was rewritten to " " to survive AI-SDK filtering, but that mutation + // itself causes Anthropic to reject the signed blocks as "modified". + // Dropping the empty separator is safe because step-start parts already + // delimit reasoning groups. const assistantID = "m-assistant" const input: SessionV1.WithParts[] = [ { @@ -1288,12 +1294,64 @@ describe("session.message-v2.toModelMessage", () => { const result = await MessageV2.toModelMessages(input, model) - // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later expect(result).toHaveLength(2) - expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ") + // First reasoning group: no text part at all (empty separator dropped) + expect((result[0].content as any[]).find((p) => p.type === "text")).toBeUndefined() + const reasoning0 = (result[0].content as any[]).find((p) => p.type === "reasoning") + expect(reasoning0).toBeDefined() + expect(reasoning0.providerOptions?.anthropic?.signature).toBe("sig1") + // Second reasoning group: non-empty text preserved + const reasoning1 = (result[1].content as any[]).find((p) => p.type === "reasoning") + expect(reasoning1).toBeDefined() + expect(reasoning1.providerOptions?.anthropic?.signature).toBe("sig2") expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") }) + test("preserves redacted thinking blocks with empty text during replay", async () => { + // Redacted thinking (text: "" with anthropic.redactedData) must survive + // toModelMessages without mutation. The empty text separator between + // redacted reasoning and a normal text part must be dropped (not mutated + // to " ") so the signed redactedData block is not invalidated. + const assistantID = "m-assistant-redacted" + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "reasoning", + text: "", + metadata: { anthropic: { redactedData: "encrypted-blob" } }, + }, + { ...basePart(assistantID, "p3"), type: "text", text: "" }, + { ...basePart(assistantID, "p4"), type: "step-start" }, + { + ...basePart(assistantID, "p5"), + type: "reasoning", + text: "thinking-two", + metadata: { anthropic: { signature: "sig2" } }, + }, + { ...basePart(assistantID, "p6"), type: "text", text: "response" }, + ] as SessionV1.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(2) + // First group: redacted reasoning present, no text separator + const reasoning0 = (result[0].content as any[]).find((p) => p.type === "reasoning") + expect(reasoning0).toBeDefined() + expect(reasoning0.providerOptions?.anthropic?.redactedData).toBe("encrypted-blob") + expect((result[0].content as any[]).find((p) => p.type === "text")).toBeUndefined() + // Second group: signed reasoning + normal text + const reasoning1 = (result[1].content as any[]).find((p) => p.type === "reasoning") + expect(reasoning1).toBeDefined() + expect(reasoning1.providerOptions?.anthropic?.signature).toBe("sig2") + expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("response") + }) + test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => { // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the // direct Anthropic path we do not preserve empty text separators for Bedrock.