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
23 changes: 15 additions & 8 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,27 +122,34 @@ function normalizeMessages(
})
}
if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
// Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
// Anthropic rejects assistant turns where client tool_use blocks are followed by non-tool
// content, e.g. [tool_use, tool_use, text], with:
// `tool_use` ids were found without `tool_result` blocks immediately after...
//
// Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive
// assistant messages are later merged by the provider/SDK, so preserving the
// original [tool_use...] then [text] order still produces the invalid payload.
//
// The root cause appears to be somewhere upstream where the stream is originally
// processed. We were unable to locate an exact narrower reproduction elsewhere,
// so we keep this transform in place for the time being.
// Provider-executed tools are different: the AI SDK intentionally represents
// their tool-call and tool-result together in assistant content.
msgs = msgs.flatMap((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]

const parts = msg.content
const first = parts.findIndex((part) => part.type === "tool-call")
const providerExecuted = new Set(
parts.flatMap((part) => (part.type === "tool-call" && part.providerExecuted === true ? [part.toolCallId] : [])),
)
const isClientToolPart = (part: (typeof parts)[number]) => {
if (part.type === "tool-call") return part.providerExecuted !== true
if (part.type === "tool-result") return !providerExecuted.has(part.toolCallId)
return false
}
const first = parts.findIndex((part) => part.type === "tool-call" && part.providerExecuted !== true)
if (first === -1) return [msg]
if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
if (!parts.slice(first).some((part) => !isClientToolPart(part))) return [msg]
return [
{ ...msg, content: parts.filter((part) => part.type !== "tool-call") },
{ ...msg, content: parts.filter((part) => part.type === "tool-call") },
{ ...msg, content: parts.filter((part) => !isClientToolPart(part)) },
{ ...msg, content: parts.filter(isClientToolPart) },
]
})
}
Expand Down
111 changes: 111 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,117 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
])
})

test("keeps tool-call and tool-result paired when splitting anthropic messages", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
{ type: "text", text: "I checked your home directory." },
],
},
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]

expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "I checked your home directory." }],
})
expect(result[1]).toMatchObject({
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
],
})
})

test("leaves provider-executed anthropic tool results with trailing text unchanged", () => {
const msgs = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "srvtoolu_1",
toolName: "code_execution",
input: { code: "print('ok')", type: "programmatic-tool-call" },
providerExecuted: true,
},
{
type: "tool-result",
toolCallId: "srvtoolu_1",
toolName: "code_execution",
output: {
type: "json",
value: { type: "code_execution_result", stdout: "ok", stderr: "", return_code: 0 },
},
},
{ type: "text", text: "The code ran successfully." },
],
},
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]

expect(result).toHaveLength(1)
expect(result[0].content).toMatchObject(msgs[0].content)
})

test("keeps provider-executed pairs together when splitting mixed anthropic tool parts", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
{
type: "tool-call",
toolCallId: "srvtoolu_1",
toolName: "code_execution",
input: { code: "print('ok')", type: "programmatic-tool-call" },
providerExecuted: true,
},
{
type: "tool-result",
toolCallId: "srvtoolu_1",
toolName: "code_execution",
output: {
type: "json",
value: { type: "code_execution_result", stdout: "ok", stderr: "", return_code: 0 },
},
},
{ type: "text", text: "The server-side tool ran successfully." },
],
},
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]

expect(result).toHaveLength(2)
expect(result[0].content).toMatchObject([
{
type: "tool-call",
toolCallId: "srvtoolu_1",
toolName: "code_execution",
input: { code: "print('ok')", type: "programmatic-tool-call" },
providerExecuted: true,
},
{
type: "tool-result",
toolCallId: "srvtoolu_1",
toolName: "code_execution",
output: { type: "json", value: { type: "code_execution_result", stdout: "ok", stderr: "", return_code: 0 } },
},
{ type: "text", text: "The server-side tool ran successfully." },
])
expect(result[1].content).toMatchObject([
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
])
})

test("splits vertex anthropic assistant messages when text trails tool calls", () => {
const model = {
...anthropicModel,
Expand Down
Loading