diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05b9f031fe64..52a08edb8850 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -49,27 +49,45 @@ 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 + 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 + } + 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 f1335f6f21a3..f0401efc9d90 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..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, @@ -1128,30 +1164,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]).toMatchObject({ type: "text", text: "hello" }) }) })