Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export namespace ProviderTransform {
options: Record<string, unknown>,
): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
// and remove empty text/reasoning parts from array content.
// Assistant messages with reasoning blocks are excluded from filtering because
// thinking block signatures encode positional context — removing an empty text
// part between reasoning blocks invalidates the signatures.
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
Expand All @@ -61,6 +64,7 @@ export namespace ProviderTransform {
return msg
}
if (!Array.isArray(msg.content)) return msg
if (msg.role === "assistant" && msg.content.some((part) => part.type === "reasoning")) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text !== ""
Expand Down
82 changes: 75 additions & 7 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,7 +1080,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
})

test("filters out empty reasoning parts from array content", () => {
test("preserves assistant message verbatim when reasoning blocks present", () => {
const msgs = [
{
role: "assistant",
Expand All @@ -1094,12 +1094,16 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>

const result = ProviderTransform.message(msgs, anthropicModel, {})

// Assistant messages with reasoning blocks are preserved verbatim
// because thinking block signatures encode positional context
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
expect(result[0].content).toHaveLength(3)
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "" })
expect(result[0].content[1]).toEqual({ type: "text", text: "Answer" })
expect(result[0].content[2]).toEqual({ type: "reasoning", text: "" })
})

test("removes entire message when all parts are empty", () => {
test("preserves assistant message with all-empty parts when reasoning present", () => {
const msgs = [
{ role: "user", content: "Hello" },
{
Expand All @@ -1114,6 +1118,25 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>

const result = ProviderTransform.message(msgs, anthropicModel, {})

// Preserved because reasoning blocks are present
expect(result).toHaveLength(3)
expect(result[0].content).toBe("Hello")
expect(result[1].content).toHaveLength(2)
expect(result[2].content).toBe("World")
})

test("removes entire non-assistant message when all parts are empty", () => {
const msgs = [
{ role: "user", content: "Hello" },
{
role: "user",
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")
Expand Down Expand Up @@ -1142,7 +1165,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
})
})

test("keeps messages with valid text alongside empty parts", () => {
test("preserves all parts in assistant message with reasoning alongside empty text", () => {
const msgs = [
{
role: "assistant",
Expand All @@ -1156,10 +1179,55 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>

const result = ProviderTransform.message(msgs, anthropicModel, {})

// All 3 parts preserved — reasoning present means no filtering
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(2)
expect(result[0].content).toHaveLength(3)
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
expect(result[0].content[1]).toEqual({ type: "text", text: "" })
expect(result[0].content[2]).toEqual({ type: "text", text: "Result" })
})

test("preserves empty text between reasoning blocks in assistant messages", () => {
// Anthropic adaptive thinking (Opus 4.6) emits:
// reasoning(sig1) → text("") → reasoning(sig2) → text("answer") → tool_use
// The empty text is positionally significant — thinking block signatures
// encode block arrangement. Removing it invalidates signatures.
const msgs = [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "analyzing...",
providerOptions: { anthropic: { signature: "sig1" } },
},
{ type: "text", text: "" },
{
type: "reasoning",
text: "checking auth...",
providerOptions: { anthropic: { signature: "sig2" } },
},
{ type: "text", text: "Let me check." },
{
type: "tool-call",
toolCallId: "toolu_01ABC",
toolName: "grep",
input: { pattern: "endpoint" },
},
],
},
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {})

expect(result).toHaveLength(1)
const parts = result[0].content as any[]
expect(parts).toHaveLength(5)
expect(parts[0].type).toBe("reasoning")
expect(parts[1]).toEqual({ type: "text", text: "" })
expect(parts[2].type).toBe("reasoning")
expect(parts[3].text).toBe("Let me check.")
expect(parts[4].type).toBe("tool-call")
})

test("filters empty content for bedrock provider", () => {
Expand Down
Loading