diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2fa7649c75f9..947ccf342555 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -122,7 +122,7 @@ 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... // @@ -130,19 +130,26 @@ function normalizeMessages( // 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) }, ] }) } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 9b66eaa77c5d..d701ca26fbe6 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -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,