diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 111832099216..0a0da0348789 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -275,9 +275,64 @@ export namespace ProviderTransform { }) } + /** + * Safety net: injects synthetic tool-results for any orphaned tool-calls. + * Catches corruption from normalizeMessages dropping messages, error-skip logic, + * lost step boundaries during retries, and filterCompacted cutting pairs. + * + * @see https://github.com/anomalyco/opencode/issues/21326 + */ + export function ensureToolIntegrity(msgs: ModelMessage[]): ModelMessage[] { + const calls = new Map() + const results = new Set() + + msgs.forEach((msg, i) => { + if (!Array.isArray(msg.content)) return + for (const part of msg.content) { + if (part.type === "tool-call") calls.set(part.toolCallId, { idx: i, tool: part.toolName }) + if (part.type === "tool-result") results.add(part.toolCallId) + } + }) + + const orphans = [...calls.entries()] + .filter(([id]) => !results.has(id)) + .map(([id, info]) => ({ id, idx: info.idx, tool: info.tool })) + + if (orphans.length === 0) return msgs + + const grouped = Map.groupBy(orphans, (o) => o.idx) + + return msgs.flatMap((msg, i) => { + const pending = grouped.get(i) + if (!pending) return [msg] + + const synthetic = pending.map((o) => ({ + type: "tool-result" as const, + toolCallId: o.id, + toolName: o.tool, + output: { type: "error-text" as const, value: "[Tool execution was interrupted]" }, + })) + + const next = msgs[i + 1] + if (next?.role === "tool" && Array.isArray(next.content)) { + next.content.push(...synthetic) + return [msg] + } + + return [msg, { role: "tool" as const, content: synthetic }] + }) + } + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + if ( + model.api.npm === "@ai-sdk/anthropic" || + model.api.npm === "@ai-sdk/amazon-bedrock" || + model.providerID === "google-vertex-anthropic" + ) { + msgs = ensureToolIntegrity(msgs) + } if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0aee396f44a3..44721bb3c1b2 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1132,7 +1132,9 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(1) + // normalizeMessages keeps the tool-call (filtering only empty text/reasoning), + // and ensureToolIntegrity adds a synthetic tool-result for the orphaned tool-call + expect(result).toHaveLength(2) expect(result[0].content).toHaveLength(1) expect(result[0].content[0]).toEqual({ type: "tool-call", @@ -1140,6 +1142,17 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => toolName: "bash", input: { command: "ls" }, }) + expect(result[1]).toMatchObject({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "123", + toolName: "bash", + output: { type: "error-text", value: "[Tool execution was interrupted]" }, + }, + ], + }) }) test("keeps messages with valid text alongside empty parts", () => { @@ -2837,3 +2850,261 @@ describe("ProviderTransform.variants", () => { }) }) }) + +describe("ProviderTransform.ensureToolIntegrity", () => { + test("returns messages unchanged when all tool-calls have matching tool-results", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "run ls" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { command: "ls" } }], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "text", value: "file.ts" } }], + }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + + expect(result).toHaveLength(3) + expect(result).toStrictEqual(msgs) + }) + + test("injects synthetic tool-result for orphaned tool-call when no tool message follows", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "run ls" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { command: "ls" } }], + }, + { role: "user", content: [{ type: "text", text: "next message" }] }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + + expect(result).toHaveLength(4) + expect(result[2]).toStrictEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "error-text", value: "[Tool execution was interrupted]" }, + }, + ], + }) + expect(result[3].role).toBe("user") + }) + + test("appends synthetic tool-result to existing tool message for partial orphans", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "run two tools" }] }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { command: "ls" } }, + { type: "tool-call", toolCallId: "call-2", toolName: "read", input: { path: "/tmp" } }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "text", value: "ok" } }, + // call-2 is missing its tool-result + ], + }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + + expect(result).toHaveLength(3) + const toolMsg = result[2] + expect(toolMsg.content).toHaveLength(2) + expect(toolMsg.content[1]).toStrictEqual({ + type: "tool-result", + toolCallId: "call-2", + toolName: "read", + output: { type: "error-text", value: "[Tool execution was interrupted]" }, + }) + }) + + test("handles multiple orphaned tool-calls across different assistant messages", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "step 1" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash", input: {} }], + }, + { role: "user", content: [{ type: "text", text: "step 2" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-2", toolName: "read", input: {} }], + }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + + expect(result).toHaveLength(6) // 4 original + 2 synthetic tool messages + expect(result[2]).toMatchObject({ + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash" }], + }) + expect(result[5]).toMatchObject({ + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-2", toolName: "read" }], + }) + }) + + test("does not affect messages without tool-calls", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + + expect(result).toStrictEqual(msgs) + }) + + test("handles the normalizeMessages scenario where assistant message is removed", () => { + // Simulates Vector 1: normalizeMessages removed an assistant message with tool-calls, + // but a tool message with results for those calls remains orphaned. + // In practice this means tool-results exist without tool-calls (opposite orphan). + // ensureToolIntegrity only fixes missing tool-results, not missing tool-calls. + // tool-results without tool-calls are valid (Anthropic does not reject them). + const msgs = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-orphan", toolName: "bash", output: { type: "text", value: "ok" } }], + }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + + // Should not crash or modify — orphaned tool-results are not our problem + expect(result).toStrictEqual(msgs) + }) + + test("handles empty messages array", () => { + const result = ProviderTransform.ensureToolIntegrity([]) + expect(result).toStrictEqual([]) + }) + + test("handles messages with string content (non-array)", () => { + const msgs = [ + { role: "system", content: "You are helpful" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "hi there" }, + ] as any[] + + const result = ProviderTransform.ensureToolIntegrity(msgs) + expect(result).toStrictEqual(msgs) + }) + + test("end-to-end: normalizeMessages drops message then ensureToolIntegrity repairs", () => { + // Simulates the full pipeline where normalizeMessages removes an empty assistant + // message that was between a user message and a tool message, leaving a tool-call + // in a prior assistant message without its tool-result. + const anthropicModel = { + id: "anthropic/claude-opus-4-6", + providerID: "anthropic", + api: { + id: "claude-opus-4-6", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude Opus", + 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: 32000 }, + status: "active", + options: {}, + headers: {}, + } as any + + // Step 1 assistant has tool-call + text + // Step 2 assistant has ONLY empty reasoning (will be dropped by normalizeMessages) + // Tool message has results for step 1's tool-call + const msgs = [ + { role: "user", content: [{ type: "text", text: "analyze this" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll run a command" }, + { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { command: "ls" } }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "text", value: "file.ts" } }, + ], + }, + { + role: "assistant", + content: [ + { type: "text", text: "Now let me read it" }, + { type: "tool-call", toolCallId: "call-2", toolName: "read", input: { filePath: "file.ts" } }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "call-2", toolName: "read", output: { type: "text", value: "content" } }, + ], + }, + { + role: "assistant", + content: [ + // Only empty reasoning — normalizeMessages will drop this entire message + { type: "reasoning", text: "" }, + ], + }, + // The next step's assistant with a tool-call + { + role: "assistant", + content: [ + { type: "text", text: "Final step" }, + { type: "tool-call", toolCallId: "call-3", toolName: "write", input: { path: "out.ts" } }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "call-3", toolName: "write", output: { type: "text", value: "written" } }, + ], + }, + ] as any[] + + // Run through full pipeline + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + // The empty assistant message should be gone (normalizeMessages removes it) + // But all tool-call/tool-result pairs should still be intact (ensureToolIntegrity) + const allToolCalls = new Set() + const allToolResults = new Set() + for (const msg of result) { + if (!Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-call") allToolCalls.add(part.toolCallId) + if (part.type === "tool-result") allToolResults.add(part.toolCallId) + } + } + + // Every tool-call must have a matching tool-result + for (const id of allToolCalls) { + expect(allToolResults.has(id)).toBe(true) + } + }) +})