diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c791aebf97..1528f789ec 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -230,6 +230,17 @@ function normalizeMessages( 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 === "reasoning" && + (part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null), + ) + ) + return [msg] if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] return [ { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c7d8594553..2884c4cf86 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -285,13 +285,7 @@ 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({ @@ -300,14 +294,11 @@ 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 7fb22ddf57..e456d1075a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1680,6 +1680,25 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => ]) }) + test("leaves signed anthropic reasoning after tool calls unchanged", () => { + const msgs = [ + { + 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: "reasoning", text: "Second thought", providerOptions: { anthropic: { signature: "sig-2" } } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content).toMatchObject(msgs[0].content) + }) + test("splits vertex anthropic assistant messages when text trails tool calls", () => { const model = { ...anthropicModel, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 6556dfe8a6..75850e59fb 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1150,113 +1150,6 @@ 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"