diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 9952762d85..c791aebf97 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -213,66 +213,29 @@ function normalizeMessages( }) } if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { - // A tool result must immediately follow its tool call, but signed thinking must - // not move. Split merged steps around their matching tool results instead. - const result: ModelMessage[] = [] - for (let i = 0; i < msgs.length; i++) { - const msg = msgs[i] - const next = msgs[i + 1] - if (msg.role !== "assistant" || !Array.isArray(msg.content) || next?.role !== "tool") { - result.push(msg) - continue - } + // Anthropic rejects assistant turns where 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. + msgs = msgs.flatMap((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg] - const clientToolIDs = new Set( - msg.content.flatMap((part) => - part.type === "tool-call" && part.providerExecuted !== true ? [part.toolCallId] : [], - ), - ) - if (clientToolIDs.size === 0) { - result.push(msg) - continue - } - - const chunks = msg.content.reduce>( - (chunks, part) => { - const current = chunks.at(-1)! - const hasToolCall = current.some( - (item) => item.type === "tool-call" && clientToolIDs.has(item.toolCallId), - ) - const isClientToolPart = - (part.type === "tool-call" || part.type === "tool-result") && clientToolIDs.has(part.toolCallId) - if (hasToolCall && !isClientToolPart) chunks.push([]) - chunks.at(-1)!.push(part) - return chunks - }, - [[]], - ) - if (chunks.length === 1) { - result.push(msg) - continue - } - - const used = new Set() - for (const chunk of chunks) { - result.push({ ...msg, content: chunk }) - const calls = new Set( - chunk.flatMap((part) => - part.type === "tool-call" && clientToolIDs.has(part.toolCallId) ? [part.toolCallId] : [], - ), - ) - if (calls.size === 0) continue - const output = next.content.filter((part) => part.type === "tool-result" && calls.has(part.toolCallId)) - if (output.length === 0) continue - output.forEach((part) => used.add(part.toolCallId)) - result.push({ ...next, content: output }) - } - - const remaining = next.content.filter((part) => part.type !== "tool-result" || !used.has(part.toolCallId)) - if (remaining.length > 0) result.push({ ...next, content: remaining }) - i++ - } - msgs = result + const parts = msg.content + const first = parts.findIndex((part) => part.type === "tool-call") + if (first === -1) return [msg] + if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] + return [ + { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, + { ...msg, content: parts.filter((part) => part.type === "tool-call") }, + ] + }) } if ( model.providerID === "mistral" || diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 862e06fc21..3c43b419b8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -285,7 +285,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (part.type !== "reasoning") return false return part.metadata?.anthropic?.signature != null }) + const splitAnthropicSteps = ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm) + let hasClientToolCall = false for (const part of msg.parts) { + if (splitAnthropicSteps && hasClientToolCall && (part.type === "text" || part.type === "reasoning")) { + assistantMessage.parts.push({ type: "step-start" }) + hasClientToolCall = false + } if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ @@ -294,11 +300,14 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } - if (part.type === "step-start") + if (part.type === "step-start") { assistantMessage.parts.push({ type: "step-start", }) + hasClientToolCall = false + } if (part.type === "tool") { + if (part.metadata?.providerExecuted !== true) hasClientToolCall = true toolNames.add(part.tool) if (part.state.status === "completed") { const outputText = part.state.time.compacted diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index aba5952884..7fb22ddf57 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1614,7 +1614,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toHaveLength(1) }) - test("places anthropic tool results before trailing text", () => { + test("splits anthropic assistant messages when text trails tool calls", () => { const msgs = [ { role: "user", @@ -1646,70 +1646,16 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result).toHaveLength(4) expect(result[1]).toMatchObject({ - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - ], - }) - expect(result[2]).toMatchObject({ role: "tool" }) - expect(result[3]).toMatchObject({ role: "assistant", content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], }) - }) - - test("keeps anthropic signed reasoning in order around tool results", () => { - const msgs = [ - { - role: "user", - content: [{ type: "text", text: "Check my home directory for PDFs" }], - }, - { - role: "assistant", - content: [ - { type: "reasoning", text: "First thought", providerOptions: { anthropic: { signature: "sig-1" } } }, - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - { type: "reasoning", text: "Second thought", providerOptions: { anthropic: { signature: "sig-2" } } }, - { type: "tool-call", toolCallId: "toolu_3", toolName: "bash", input: { command: "pwd" } }, - ], - }, - { - role: "tool", - content: [ - { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } }, - { - type: "tool-result", - toolCallId: "toolu_2", - toolName: "glob", - output: { type: "text", value: "No files found" }, - }, - { type: "tool-result", toolCallId: "toolu_3", toolName: "bash", output: { type: "text", value: "/root" } }, - ], - }, - ] as any[] - - const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] - - expect(result).toHaveLength(5) - expect(result[1]).toMatchObject({ + expect(result[2]).toMatchObject({ role: "assistant", content: [ - { type: "reasoning", text: "First thought", providerOptions: { anthropic: { signature: "sig-1" } } }, { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, ], }) - expect(result[2]).toMatchObject({ role: "tool" }) - expect(result[3]).toMatchObject({ - role: "assistant", - content: [ - { type: "reasoning", text: "Second thought", providerOptions: { anthropic: { signature: "sig-2" } } }, - { type: "tool-call", toolCallId: "toolu_3", toolName: "bash", input: { command: "pwd" } }, - ], - }) - expect(result[4]).toMatchObject({ role: "tool", content: [{ type: "tool-result", toolCallId: "toolu_3" }] }) }) test("leaves valid anthropic assistant tool ordering unchanged", () => { @@ -1734,53 +1680,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => ]) }) - test("keeps vertex anthropic signed reasoning in order around tool results", () => { - const model = { - ...anthropicModel, - providerID: "google-vertex-anthropic", - api: { - id: "claude-sonnet-4@20250514", - url: "https://us-central1-aiplatform.googleapis.com", - npm: "@ai-sdk/google-vertex/anthropic", - }, - } - - const msgs = [ - { - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - { type: "reasoning", text: "Second thought", providerOptions: { anthropic: { signature: "sig-2" } } }, - ], - }, - { - role: "tool", - content: [ - { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } }, - { type: "tool-result", toolCallId: "toolu_2", toolName: "glob", output: { type: "text", value: "ok" } }, - ], - }, - ] as any[] - - const result = ProviderTransform.message(msgs, model, {}) as any[] - - expect(result).toHaveLength(3) - expect(result[0]).toMatchObject({ - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - ], - }) - expect(result[1]).toMatchObject({ role: "tool" }) - expect(result[2]).toMatchObject({ - role: "assistant", - content: [{ type: "reasoning", text: "Second thought", providerOptions: { anthropic: { signature: "sig-2" } } }], - }) - }) - - test("places vertex anthropic tool results before trailing text", () => { + test("splits vertex anthropic assistant messages when text trails tool calls", () => { const model = { ...anthropicModel, providerID: "google-vertex-anthropic", @@ -1800,30 +1700,22 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { type: "text", text: "I checked your home directory and looked for PDF files." }, ], }, - { - role: "tool", - content: [ - { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } }, - { type: "tool-result", toolCallId: "toolu_2", toolName: "glob", output: { type: "text", value: "ok" } }, - ], - }, ] as any[] const result = ProviderTransform.message(msgs, model, {}) as any[] - expect(result).toHaveLength(3) + expect(result).toHaveLength(2) expect(result[0]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], + }) + expect(result[1]).toMatchObject({ role: "assistant", content: [ { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, ], }) - expect(result[1]).toMatchObject({ role: "tool" }) - expect(result[2]).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], - }) }) }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 75850e59fb..6556dfe8a6 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1150,6 +1150,113 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("splits anthropic replay when text follows a completed tool call", async () => { + const anthropicModel: Provider.Model = { + ...model, + id: ProviderV2.ModelID.make("claude-opus-4-8"), + providerID: ProviderV2.ID.make("anthropic"), + api: { id: "claude-opus-4-8", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + } + const assistantID = "m-assistant" + + const result = await MessageV2.toModelMessages( + [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: anthropicModel.providerID, + modelID: anthropicModel.id, + }), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "tool", + callID: "toolu_1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/root" }, + output: "ok", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + { ...basePart(assistantID, "p3"), type: "text", text: "done" }, + ] as SessionLegacy.Part[], + }, + ], + anthropicModel, + ) + + expect(result.map((message) => message.role)).toEqual(["assistant", "tool", "assistant"]) + expect(result[0].content).toMatchObject([{ type: "tool-call", toolCallId: "toolu_1" }]) + expect(result[2].content).toMatchObject([{ type: "text", text: "done" }]) + }) + + test("splits anthropic replay without moving signed reasoning", async () => { + const anthropicModel: Provider.Model = { + ...model, + id: ProviderV2.ModelID.make("claude-opus-4-8"), + providerID: ProviderV2.ID.make("anthropic"), + api: { id: "claude-opus-4-8", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + } + const assistantID = "m-assistant" + const tool = (id: string, callID: string) => ({ + ...basePart(assistantID, id), + type: "tool" as const, + callID, + tool: "bash", + state: { + status: "completed" as const, + input: { command: "pwd" }, + output: "ok", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }) + + const result = await MessageV2.toModelMessages( + [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: anthropicModel.providerID, + modelID: anthropicModel.id, + }), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "reasoning", + text: "one", + metadata: { anthropic: { signature: "sig-1" } }, + }, + tool("p3", "toolu_1"), + { + ...basePart(assistantID, "p4"), + type: "reasoning", + text: "two", + metadata: { anthropic: { signature: "sig-2" } }, + }, + tool("p5", "toolu_2"), + ] as SessionLegacy.Part[], + }, + ], + anthropicModel, + ) + + expect(result.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "tool"]) + expect(result[0].content).toMatchObject([ + { type: "reasoning", text: "one", providerOptions: { anthropic: { signature: "sig-1" } } }, + { type: "tool-call", toolCallId: "toolu_1" }, + ]) + expect(result[2].content).toMatchObject([ + { type: "reasoning", text: "two", providerOptions: { anthropic: { signature: "sig-2" } } }, + { type: "tool-call", toolCallId: "toolu_2" }, + ]) + }) + test("drops messages that only contain step-start parts", async () => { const assistantID = "m-assistant"