diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..34210bd57 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -89,6 +89,48 @@ describe("sanitizeToolUseResultPairing", () => { expect(results[0]?.toolCallId).toBe("call_1"); }); + it("drops errored assistant with tool calls and their matching tool results", () => { + const input = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_err", name: "cron", arguments: {} }], + stopReason: "error", + errorMessage: "JSON parse error", + }, + { + role: "toolResult", + toolCallId: "call_err", + toolName: "cron", + content: [{ type: "text", text: "synthetic repair" }], + isError: true, + }, + { role: "user", content: "fix this" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + // The errored assistant and its tool_result should both be dropped + expect(out.map((m) => m.role)).toEqual(["user", "user"]); + expect(out).toHaveLength(2); + }); + + it("keeps errored assistant without tool calls", () => { + const input = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [{ type: "text", text: "partial response" }], + stopReason: "error", + errorMessage: "timeout", + }, + { role: "user", content: "try again" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + expect(out).toHaveLength(3); + expect(out[1]?.role).toBe("assistant"); + }); + it("drops orphan tool results that do not match any tool call", () => { const input = [ { role: "user", content: "hello" }, diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 2728bb89c..780e54237 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -116,6 +116,47 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } const assistant = msg as Extract; + + // Drop errored/aborted assistant messages that contain tool calls, along with + // their matching tool_results. The provider-level transform (pi-ai) also drops + // these assistants, but if the tool_results survive they become orphans that + // cause "unexpected tool_use_id" API rejections. Defence-in-depth: strip them + // here so downstream transforms never see the mismatch. + if ( + (assistant as { stopReason?: unknown }).stopReason === "error" || + (assistant as { stopReason?: unknown }).stopReason === "aborted" + ) { + const erroredToolCalls = extractToolCallsFromAssistant(assistant); + if (erroredToolCalls.length > 0) { + const erroredIds = new Set(erroredToolCalls.map((t) => t.id)); + // Skip ahead past any matching tool_results for this errored assistant + let j = i + 1; + for (; j < messages.length; j += 1) { + const next = messages[j] as AgentMessage; + if (!next || typeof next !== "object") continue; + const nextRole = (next as { role?: unknown }).role; + if (nextRole === "assistant") break; + if (nextRole === "toolResult") { + const id = extractToolResultId( + next as Extract, + ); + if (id && erroredIds.has(id)) { + // Drop the orphan tool_result that matched the errored assistant + changed = true; + continue; + } + } + out.push(next); + } + i = j - 1; + changed = true; + continue; + } + // Errored assistant without tool calls - keep it + out.push(msg); + continue; + } + const toolCalls = extractToolCallsFromAssistant(assistant); if (toolCalls.length === 0) { out.push(msg);