From 757af1c6535b0a4a8f69b33834b9d82c123ccbeb Mon Sep 17 00:00:00 2001 From: Mike Beaumier Date: Wed, 8 Apr 2026 21:53:14 -0700 Subject: [PATCH] fix: repair orphaned tool_use/tool_result pairing in message pipeline Add repairToolPairing() to ProviderTransform.message() that detects and fixes orphaned tool-call blocks before sending to the API. When a tool-call has no matching tool-result (due to process crash, retry loop leaving orphaned parts, or plugin mutation), the function injects a synthetic tool-result with an interrupted message. This prevents the Anthropic API 400 validation error: 'tool_use ids were found without tool_result blocks immediately after' which permanently poisons sessions because the corrupt history is rebuilt identically on every subsequent turn. Refs: anthropics/claude-code#6836 --- packages/opencode/src/provider/transform.ts | 86 +++++++++++++++++++ .../opencode/test/provider/transform.test.ts | 16 +++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 111832099216..3df26e974046 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -6,6 +6,7 @@ import type { Provider } from "./provider" import type { ModelsDev } from "./models" import { iife } from "@/util/iife" import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" type Modality = NonNullable["input"][number] @@ -275,9 +276,94 @@ export namespace ProviderTransform { }) } + const repairLog = Log.create({ service: "provider.transform.repair" }) + + /** + * Repairs tool_use / tool_result pairing in the ModelMessage array. + * + * After convertToModelMessages and normalizeMessages, orphaned tool-call + * blocks can appear when: + * - A process crash left tool parts in pending/running state + * - normalizeMessages removed an empty tool-role message + * - A plugin mutated tool part state before conversion + * + * This function forward-walks the message array, tracking tool-call IDs + * from assistant messages and matching them against tool-result IDs in + * subsequent tool messages. Any unmatched tool-call gets a synthetic + * tool-result injected, preventing the API from rejecting the request. + */ + function repairToolPairing(msgs: ModelMessage[]): ModelMessage[] { + // Collect all tool-result IDs across the entire message array + const resultIds = new Set() + for (const msg of msgs) { + if (msg.role !== "tool" || !Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-result" && "toolCallId" in part) { + resultIds.add(part.toolCallId) + } + } + } + + // Find all tool-call IDs that have no matching tool-result + const orphanedCalls: Map> = new Map() + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i] + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-call" && "toolCallId" in part && !resultIds.has(part.toolCallId)) { + if (!orphanedCalls.has(i)) orphanedCalls.set(i, []) + orphanedCalls.get(i)!.push({ toolCallId: part.toolCallId, toolName: part.toolName }) + } + } + } + + if (orphanedCalls.size === 0) return msgs + + // Build repaired array: inject synthetic tool-result messages after + // each assistant message that has orphaned tool-calls + const repaired: ModelMessage[] = [] + const totalOrphaned = Array.from(orphanedCalls.values()).reduce((sum, arr) => sum + arr.length, 0) + repairLog.info("repairing tool pairing", { orphaned: totalOrphaned }) + + for (let i = 0; i < msgs.length; i++) { + repaired.push(msgs[i]) + + const orphans = orphanedCalls.get(i) + if (!orphans) continue + + // Check if the next message is a tool message we can append to + const next = msgs[i + 1] + if (next?.role === "tool" && Array.isArray(next.content)) { + // Append synthetic tool-results to the existing tool message + for (const orphan of orphans) { + next.content.push({ + type: "tool-result", + toolCallId: orphan.toolCallId, + toolName: orphan.toolName, + output: "[Tool execution was interrupted — result unavailable]", + } as any) + } + } else { + // Insert a new tool message with the synthetic results + repaired.push({ + role: "tool", + content: orphans.map((orphan) => ({ + type: "tool-result" as const, + toolCallId: orphan.toolCallId, + toolName: orphan.toolName, + output: "[Tool execution was interrupted — result unavailable]", + })), + } as ModelMessage) + } + } + + return repaired + } + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + msgs = repairToolPairing(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..660d0a96093b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -802,6 +802,12 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { }, ], }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "test", toolName: "bash", output: "hello" }, + ], + }, ] as any[] const result = ProviderTransform.message( @@ -843,7 +849,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { {}, ) - expect(result).toHaveLength(1) + expect(result).toHaveLength(2) expect(result[0].content).toEqual([ { type: "tool-call", @@ -1128,11 +1134,17 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, ], }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "123", toolName: "bash", output: "file.txt" }, + ], + }, ] as any[] const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(1) + expect(result).toHaveLength(2) expect(result[0].content).toHaveLength(1) expect(result[0].content[0]).toEqual({ type: "tool-call",