diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bd778dacc53e..e8eed80a311a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -111,6 +111,9 @@ function normalizeMessages( msg.content = sanitizeSurrogates(msg.content) } else { msg.content = msg.content.map((content) => { + if (content.type === "reasoning" && content.providerOptions) { + return content + } if (content.type === "text" || content.type === "reasoning") { content.text = sanitizeSurrogates(content.text) } @@ -129,12 +132,13 @@ function normalizeMessages( if (model.api.npm === "@ai-sdk/anthropic") { msgs = msgs .map((msg) => { + if (msg.role === "assistant") return msg if (typeof msg.content === "string") { if (msg.content === "") return undefined return msg } if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { + const filtered = (msg.content as any[]).filter((part: any) => { if (part.type === "text") { return part.text !== "" } @@ -157,12 +161,13 @@ function normalizeMessages( if (model.api.npm === "@ai-sdk/amazon-bedrock") { msgs = msgs .map((msg) => { + if (msg.role === "assistant") return msg if (typeof msg.content === "string") { if (msg.content === "") return undefined return msg } if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { + const filtered = (msg.content as any[]).filter((part: any) => { if (part.type === "text") { return part.text !== "" } @@ -429,6 +434,22 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes } export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { + // The Anthropic/Bedrock API requires thinking blocks in the latest assistant + // message to be replayed exactly. For all other assistant messages, strip + // reasoning blocks entirely to avoid signature validation failures. + if (model.api.npm === "@ai-sdk/amazon-bedrock" || model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") { + const lastAsstIdx = msgs.findLastIndex((m) => m.role === "assistant") + for (let i = 0; i < msgs.length; i++) { + if (i === lastAsstIdx) continue + const msg = msgs[i] + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue + const filtered = (msg.content as any[]).filter((p: any) => p.type !== "reasoning") + if (filtered.length !== (msg.content as any[]).length) { + msgs[i] = { ...msg, content: filtered } as any + } + } + } + msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) if ( diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e3539021b0a5..6c954c2c85af 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -837,7 +837,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // the neighboring signed reasoning blocks. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null + return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null }) for (const part of msg.parts) { if (msg.info.summary && part.type !== "text") continue diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index df21922b097a..b07181e3dea5 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1385,7 +1385,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toBe("World") }) - test("filters out empty text parts from array content", () => { + test("preserves empty text parts in assistant array content (to avoid breaking thinking block signatures)", () => { const msgs = [ { role: "assistant", @@ -1400,11 +1400,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + expect(result[0].content).toHaveLength(3) }) - test("filters out empty reasoning parts from array content", () => { + test("preserves empty reasoning parts in assistant array content (to avoid breaking thinking block signatures)", () => { const msgs = [ { role: "assistant", @@ -1419,11 +1418,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) 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) }) - test("removes entire message when all parts are empty", () => { + test("preserves assistant message even when all parts are empty (to avoid breaking thinking block signatures)", () => { const msgs = [ { role: "user", content: "Hello" }, { @@ -1438,12 +1436,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("World") + expect(result).toHaveLength(3) }) - test("keeps non-text/reasoning parts even if text parts are empty", () => { + test("preserves all parts in assistant messages including empty text (to avoid breaking thinking block signatures)", () => { const msgs = [ { role: "assistant", @@ -1457,16 +1453,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ - type: "tool-call", - toolCallId: "123", - toolName: "bash", - input: { command: "ls" }, - }) + expect(result[0].content).toHaveLength(2) }) - test("keeps messages with valid text alongside empty parts", () => { + test("preserves all content in assistant messages with reasoning (to avoid breaking thinking block signatures)", () => { const msgs = [ { role: "assistant", @@ -1481,12 +1471,11 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) 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" }) }) - test("filters empty content for bedrock provider", () => { + test("preserves assistant content for bedrock provider (to avoid breaking thinking block signatures)", () => { const bedrockModel = { ...anthropicModel, id: "amazon-bedrock/anthropic.claude-opus-4-6", @@ -1512,10 +1501,11 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, bedrockModel, {}) + // empty string assistant message is still filtered, but array content is preserved expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") - expect(result[1].content).toHaveLength(1) - expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) + // assistant messages with array content are preserved as-is + expect(result[1].content).toHaveLength(2) }) test("does not filter for non-anthropic providers", () => { @@ -3686,3 +3676,195 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +describe("ProviderTransform.message - reasoning block stripping for compaction safety", () => { + const bedrockModel = { + id: "amazon-bedrock/anthropic.claude-opus-4-7", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-opus-4-7", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Opus 4.7", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.015, output: 0.075, cache: { read: 0.0015, write: 0.01875 } }, + limit: { context: 200000, output: 16384 }, + status: "active", + options: {}, + headers: {}, + } as any + + const anthropicModel = { + ...bedrockModel, + id: "anthropic/claude-opus-4-7", + providerID: "anthropic", + api: { + id: "claude-opus-4-7-20250415", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + } + + test("strips reasoning blocks from non-latest assistant messages (bedrock)", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { bedrock: { signature: "sig1" } } }, + { type: "tool-call", toolCallId: "t1", toolName: "read", input: {} }, + ], + }, + { role: "user", content: [{ type: "text", text: "continue" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "more thinking", providerOptions: { bedrock: { signature: "sig2" } } }, + { type: "text", text: "final answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + + // First assistant (non-latest) should have reasoning stripped + const firstAssistant = result.find((m, i) => m.role === "assistant" && i === 1)! + expect(firstAssistant.content).toHaveLength(1) + expect(firstAssistant.content[0].type).toBe("tool-call") + + // Last assistant should keep reasoning intact + const lastAssistant = result.findLast((m: any) => m.role === "assistant")! + expect(lastAssistant.content).toHaveLength(2) + expect(lastAssistant.content[0].type).toBe("reasoning") + expect(lastAssistant.content[0].providerOptions.bedrock.signature).toBe("sig2") + }) + + test("strips reasoning blocks from non-latest assistant messages (anthropic)", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { anthropic: { signature: "sig1" } } }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: [{ type: "text", text: "more" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking again", providerOptions: { anthropic: { signature: "sig2" } } }, + { type: "text", text: "final" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + const firstAssistant = result[1] + expect(firstAssistant.content.some((p: any) => p.type === "reasoning")).toBe(false) + + const lastAssistant = result[3] + expect(lastAssistant.content[0].type).toBe("reasoning") + }) + + test("preserves reasoning on the only assistant message", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { bedrock: { signature: "sig1" } } }, + { type: "text", text: "answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + + expect(result[1].content).toHaveLength(2) + expect(result[1].content[0].type).toBe("reasoning") + expect(result[1].content[0].text).toBe("thinking") + }) + + test("does not strip reasoning for non-anthropic providers", () => { + const openaiModel = { + ...bedrockModel, + providerID: "openai", + api: { id: "gpt-4", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + } + + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { openai: { foo: "bar" } } }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: [{ type: "text", text: "more" }] }, + { + role: "assistant", + content: [{ type: "text", text: "final" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, {}) as any[] + + expect(result[1].content).toHaveLength(2) + expect(result[1].content[0].type).toBe("reasoning") + }) + + test("does not modify assistant messages without reasoning blocks", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "response" }, + { type: "tool-call", toolCallId: "t1", toolName: "read", input: {} }, + ], + }, + { role: "user", content: [{ type: "text", text: "more" }] }, + { + role: "assistant", + content: [{ type: "text", text: "final" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + + expect(result[1].content).toHaveLength(2) + expect(result[1].content[0].type).toBe("text") + expect(result[1].content[1].type).toBe("tool-call") + }) + + test("skips sanitizeSurrogates on signed reasoning parts", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking with special chars", providerOptions: { bedrock: { signature: "sig" } } }, + { type: "text", text: "answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) as any[] + + const reasoning = result[1].content.find((p: any) => p.type === "reasoning") + expect(reasoning.text).toBe("thinking with special chars") + expect(reasoning.providerOptions.bedrock.signature).toBe("sig") + }) +})