From 1832c280a621ef57ba789f6244893e0fec5c9253 Mon Sep 17 00:00:00 2001 From: RhoninSeiei <33801807+RhoninSeiei@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:45:32 +0800 Subject: [PATCH 1/2] fix(opencode): filter empty text content blocks for all providers Many providers (Anthropic, Bedrock, and proxies like openai-compatible forwarding to Bedrock) reject messages with empty text content blocks. The existing filter only applied to @ai-sdk/anthropic and @ai-sdk/amazon-bedrock, but users connecting through @ai-sdk/openai-compatible (e.g. custom Bedrock proxies, Databricks) hit the same ValidationException in multi-turn conversations. Changes: - normalizeMessages: apply empty text/reasoning filtering universally instead of only for Anthropic/Bedrock providers. Also use .trim() to catch whitespace-only content. - message-v2.ts: skip empty text and reasoning parts at the source when constructing UIMessages from stored parts. - Update test to verify universal filtering for openai-compatible. Fixes #15715 Fixes #5028 Refs #2655 --- packages/opencode/src/provider/transform.ts | 42 ++++++++++--------- packages/opencode/src/session/message-v2.ts | 6 +-- .../opencode/test/provider/transform.test.ts | 33 ++++++++++----- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05b9f031fe64..0938977f292e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -49,27 +49,31 @@ export namespace ProviderTransform { model: Provider.Model, options: Record, ): ModelMessage[] { - // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { - msgs = msgs - .map((msg) => { - if (typeof msg.content === "string") { - if (msg.content === "") return undefined - return msg + // Many providers (Anthropic, Bedrock, and proxies like openai-compatible + // forwarding to Bedrock) reject messages with empty text content blocks. + // Filter them out universally - empty text blocks are never useful. + msgs = msgs + .map((msg) => { + if (typeof msg.content === "string") { + 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" || part.type === "reasoning") { + if (typeof part.text !== "string") return false + return part.text.trim().length > 0 } - if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" - } - return true - }) - if (filtered.length === 0) return undefined - return { ...msg, content: filtered } + return true }) - .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") - } + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => { + if (!msg) return false + if (typeof msg.content !== "string") return true + return msg.content.trim().length > 0 + }) if (model.api.id.includes("claude")) { return msgs.map((msg) => { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8e4babd61924..9ea6671309a2 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -628,7 +628,7 @@ export namespace MessageV2 { } result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + if (part.type === "text" && !part.ignored && part.text.trim()) userMessage.parts.push({ type: "text", text: part.text, @@ -684,7 +684,7 @@ export namespace MessageV2 { parts: [], } for (const part of msg.parts) { - if (part.type === "text") + if (part.type === "text" && part.text.trim()) assistantMessage.parts.push({ type: "text", text: part.text, @@ -747,7 +747,7 @@ export namespace MessageV2 { ...(differentModel ? {} : { callProviderMetadata: part.metadata }), }) } - if (part.type === "reasoning") { + if (part.type === "reasoning" && part.text.trim()) { assistantMessage.parts.push({ type: "reasoning", text: part.text, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 917d357eafae..24709a0624ec 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1128,30 +1128,43 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) }) - test("does not filter for non-anthropic providers", () => { - const openaiModel = { + test("filters empty content for all providers including openai-compatible", () => { + const model = { ...anthropicModel, - providerID: "openai", + providerID: "ducc", api: { - id: "gpt-4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", + id: "ducc/claude-sonnet-4-6", + url: "https://example.com/v1/", + npm: "@ai-sdk/openai-compatible", }, } const msgs = [ { role: "assistant", content: "" }, + { role: "assistant", content: " " }, { role: "assistant", content: [{ type: "text", text: "" }], }, + { + role: "assistant", + content: [{ type: "text", text: " " }], + }, + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "hello" }, + ], + }, ] as any[] - const result = ProviderTransform.message(msgs, openaiModel, {}) + const result = ProviderTransform.message(msgs, model, {}) - expect(result).toHaveLength(2) - expect(result[0].content).toBe("") - expect(result[1].content).toHaveLength(1) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0].text).toBe("hello") }) }) From ef378fa1abecceee4e416c2d1ca521c897a9ddd5 Mon Sep 17 00:00:00 2001 From: Rhonin Wang <33801807+RhoninSeiei@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:12:50 +0800 Subject: [PATCH 2/2] fix(opencode): preserve adaptive thinking separators --- packages/opencode/src/provider/transform.ts | 14 +++++++ .../opencode/test/provider/transform.test.ts | 38 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0938977f292e..52a08edb8850 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -49,11 +49,25 @@ export namespace ProviderTransform { model: Provider.Model, options: Record, ): ModelMessage[] { + const preserveAdaptiveAnthropicReasoning = ["sonnet-4-6", "sonnet-4.6", "opus-4-6", "opus-4.6"].some( + (variant) => model.id.includes(variant) || model.api.id.includes(variant), + ) + // Many providers (Anthropic, Bedrock, and proxies like openai-compatible // forwarding to Bedrock) reject messages with empty text content blocks. // Filter them out universally - empty text blocks are never useful. msgs = msgs .map((msg) => { + // Anthropic adaptive thinking signs assistant reasoning blocks positionally. + // Preserve these messages verbatim, including whitespace-only text separators. + const preserveAssistantReasoning = + preserveAdaptiveAnthropicReasoning && + msg.role === "assistant" && + Array.isArray(msg.content) && + msg.content.some((part) => part.type === "reasoning") + + if (preserveAssistantReasoning) return msg + if (typeof msg.content === "string") { if (!msg.content.trim()) return undefined return msg diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 24709a0624ec..cc0eb412d6e1 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1096,6 +1096,42 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) }) + test("preserves whitespace text separators in assistant reasoning messages", () => { + const adaptiveAnthropicModel = { + ...anthropicModel, + id: "anthropic/claude-sonnet-4-6", + api: { + ...anthropicModel.api, + id: "claude-sonnet-4-6-20260301", + }, + capabilities: { + ...anthropicModel.capabilities, + reasoning: true, + }, + } + + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking step 1" }, + { type: "text", text: " " }, + { type: "reasoning", text: "Thinking step 2" }, + { type: "text", text: "Result" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, adaptiveAnthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(4) + expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking step 1" }) + expect(result[0].content[1]).toEqual({ type: "text", text: " " }) + expect(result[0].content[2]).toEqual({ type: "reasoning", text: "Thinking step 2" }) + expect(result[0].content[3]).toEqual({ type: "text", text: "Result" }) + }) + test("filters empty content for bedrock provider", () => { const bedrockModel = { ...anthropicModel, @@ -1164,7 +1200,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result).toHaveLength(1) expect(result[0].role).toBe("user") expect(result[0].content).toHaveLength(1) - expect(result[0].content[0].text).toBe("hello") + expect(result[0].content[0]).toMatchObject({ type: "text", text: "hello" }) }) })