diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 79cfa3ea508a..8e692121b297 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -130,18 +130,32 @@ function normalizeMessages( }) // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content + // and remove empty/whitespace-only text and empty reasoning parts from array content. + // Anthropic returns 400 "text content blocks must contain non-whitespace text" for any + // text block that is empty OR whitespace-only, so whitespace must be treated like empty. if (model.api.npm === "@ai-sdk/anthropic") { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { - if (msg.content === "") return undefined + if (msg.content.trim() === "") return undefined return msg } if (!Array.isArray(msg.content)) return msg + // MessageV2.toModelMessages substitutes a single space for empty assistant text + // that sits between signed reasoning blocks; the AI SDK merges that separator into + // an adjacent block so it never reaches the API as a standalone whitespace block. + // Preserve a whitespace-only text part only when this message still carries + // signed/redacted Anthropic reasoning that survives filtering below; otherwise drop + // it, since a standalone whitespace text block is rejected by Anthropic. + const hasSignedReasoning = msg.content.some( + (part) => + part.type === "reasoning" && + (part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null), + ) const filtered = msg.content.filter((part) => { if (part.type === "text") { - return part.text !== "" + return hasSignedReasoning ? part.text !== "" : part.text.trim() !== "" } if (part.type === "reasoning") { return ( @@ -158,18 +172,22 @@ function normalizeMessages( .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } - // Bedrock specific transforms + // Bedrock specific transforms. Claude on Bedrock has the same Anthropic content rules, so + // empty/whitespace-only text blocks are rejected too. Unlike the direct Anthropic path, + // MessageV2.toModelMessages does not emit the single-space reasoning separator for Bedrock + // (its substitution is gated on the 'anthropic' signature namespace), so whitespace-only + // text is always stray here and can be dropped unconditionally. if (model.api.npm === "@ai-sdk/amazon-bedrock") { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { - if (msg.content === "") return undefined + if (msg.content.trim() === "") return undefined return msg } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { if (part.type === "text") { - return part.text !== "" + return part.text.trim() !== "" } if (part.type === "reasoning") { return ( diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 674d4ef00475..d2a8fe0ea766 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1744,6 +1744,117 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, ]) }) + + test("drops an assistant message whose only text part is whitespace", () => { + // Regression: github-copilot Claude (via /v1/messages) returned HTTP 400 + // "text content blocks must contain non-whitespace text" when history contained an + // assistant turn whose single content block was a lone space (e.g. an empty response + // from a cheap fallback model that was later replayed to Claude). + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: " " }] }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("drops whitespace-only text parts but keeps real text", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: " " }, + { type: "text", text: "Hello" }, + { type: "text", text: "\n\t" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "Hello" }]) + }) + + test("preserves a single-space separator between signed reasoning blocks", () => { + // MessageV2 emits a " " separator alongside signed reasoning; it must survive so the + // AI SDK can merge it, otherwise signed-reasoning replay positioning changes. + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { anthropic: { signature: "sig1" } } }, + { type: "text", text: " " }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(1) + const texts = result[0].content.filter((p: any) => p.type === "text") + expect(texts).toHaveLength(1) + expect(texts[0].text).toBe(" ") + expect(result[0].content.some((p: any) => p.type === "reasoning")).toBe(true) + }) + + test("drops whitespace text when reasoning is empty/unsigned (no separator leak)", () => { + // The space must not survive merely because a reasoning part is present: an empty, + // unsigned reasoning part is filtered out, which would otherwise leave a lone " ". + const msgs = [ + { role: "user", content: "Hi" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "" }, + { type: "text", text: " " }, + ], + }, + { role: "user", content: "Bye" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hi") + expect(result[1].content).toBe("Bye") + }) + + test("drops whitespace-only text for bedrock claude", () => { + const bedrockModel = { + ...anthropicModel, + id: "amazon-bedrock/anthropic.claude-opus-4-6", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-opus-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + } + + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: " " }] }, + { + role: "assistant", + content: [ + { type: "text", text: " " }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toEqual([{ type: "text", text: "Answer" }]) + }) }) describe("ProviderTransform.message - strip openai metadata when store=false", () => {