From ed9eb70c88b7bd5ba7b0cf62ea687e0060beef29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Wed, 22 Apr 2026 14:15:27 +0100 Subject: [PATCH 1/2] fix(provider): substitute space for empty text between signed reasoning blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic adaptive thinking (Opus 4.6+) emits empty text parts between thinking blocks. Dropping them invalidates thinking block signatures ('thinking blocks cannot be modified'), but sending them as '' fails validation ('text content blocks must be non-empty'). Substitute a single space so the part survives all filters and Anthropic accepts it. Gated on reasoning having an Anthropic signature (metadata.anthropic. signature != null) so other providers' reasoning — which don't position-validate — continue to have empty text filtered normally. Closes #16748 --- packages/opencode/src/session/message-v2.ts | 12 ++- .../opencode/test/session/message-v2.test.ts | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a017ead1e631..3a919c864232 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -854,13 +854,21 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } + // Substitute a space for empty text between signed Anthropic reasoning + // blocks to keep thinking block positions (and signatures) valid without + // triggering Anthropic's empty-content rejection or the AI SDK's filter. + const hasSignedReasoning = msg.parts.some( + (p) => p.type === "reasoning" && (p.metadata as any)?.anthropic?.signature != null, + ) for (const part of msg.parts) { - if (part.type === "text") + if (part.type === "text") { + const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", - text: part.text, + text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) + } if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 89bae246a78c..07ba7946cd84 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1090,6 +1090,82 @@ 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)] + const assistantID = "m-assistant" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "reasoning", + text: "thinking-one", + metadata: { anthropic: { signature: "sig1" } }, + }, + { ...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: "the answer" }, + ] as MessageV2.Part[], + }, + ] + + 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(" ") + expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") + }) + + test("leaves empty text alone when reasoning has no Anthropic signature", async () => { + // Non-Anthropic providers' reasoning doesn't position-validate, so empty text + // should be filtered normally rather than substituted. + const assistantID = "m-assistant-unsigned" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) + }) + + test("leaves empty text alone in assistant messages without reasoning", async () => { + const assistantID = "m-assistant-no-reasoning" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "text", text: "" }, + { ...basePart(assistantID, "p2"), type: "text", text: "hello" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"]) + }) }) describe("session.message-v2.fromError", () => { From 876c79ff75e4037c620ee5e4a89bdb0d85cb713b Mon Sep 17 00:00:00 2001 From: Omer Koren <54630488+omer-koren@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:05:30 +0300 Subject: [PATCH 2/2] fix: extend empty-text gate to Bedrock and Vertex signature namespaces The hasSignedReasoning gate introduced in this PR only matches Anthropic's direct-API signature path (metadata.anthropic.signature). When Claude is hosted on AWS Bedrock or GCP Vertex AI, the reasoning part metadata stores the signature under metadata.bedrock.signature / metadata.vertex.signature respectively. The gate never fires for those providers, so empty text parts between signed reasoning blocks are not substituted with a space, and Anthropic/Bedrock/Vertex still reject the compacted message with: messages.N.content.M: 'thinking' or 'redacted_thinking' blocks in the latest assistant message cannot be modified Extend the check to match signatures under any of the three provider namespaces. Adds two tests covering the Bedrock and Vertex paths. --- packages/opencode/src/session/message-v2.ts | 10 +++- .../opencode/test/session/message-v2.test.ts | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3a919c864232..1eed28418c07 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -854,11 +854,17 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } - // Substitute a space for empty text between signed Anthropic reasoning + // Substitute a space for empty text between signed reasoning // blocks to keep thinking block positions (and signatures) valid without // triggering Anthropic's empty-content rejection or the AI SDK's filter. + // Signatures live under the provider-namespaced metadata key: anthropic + // (direct API), bedrock (AWS Bedrock), or vertex (GCP Vertex AI). const hasSignedReasoning = msg.parts.some( - (p) => p.type === "reasoning" && (p.metadata as any)?.anthropic?.signature != null, + (p) => + p.type === "reasoning" && + ((p.metadata as any)?.anthropic?.signature != null || + (p.metadata as any)?.bedrock?.signature != null || + (p.metadata as any)?.vertex?.signature != null), ) for (const part of msg.parts) { if (part.type === "text") { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 07ba7946cd84..b2590a7e32ed 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1126,6 +1126,58 @@ describe("session.message-v2.toModelMessage", () => { expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") }) + test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { + // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + const assistantID = "m-assistant-bedrock" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "thinking-bedrock", + metadata: { bedrock: { signature: "bedrock-sig" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + }) + + test("substitutes space for empty text when reasoning signature is under 'vertex' namespace", async () => { + // GCP Vertex AI hosts Anthropic Claude but stores signatures under metadata.vertex + const assistantID = "m-assistant-vertex" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "thinking-vertex", + metadata: { vertex: { signature: "vertex-sig" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + }) + test("leaves empty text alone when reasoning has no Anthropic signature", async () => { // Non-Anthropic providers' reasoning doesn't position-validate, so empty text // should be filtered normally rather than substituted.