From 5d04fdd94b73873129d7fcbc19d1cbc6a2be2370 Mon Sep 17 00:00:00 2001 From: aisling404 Date: Fri, 30 Jan 2026 11:03:00 +0000 Subject: [PATCH] fix(agents): skip tool extraction for aborted/errored assistant messages When stopReason is 'error' or 'aborted', tool_use blocks may be incomplete (e.g., partialJson: true). Creating synthetic tool_results for these causes API 400 errors: 'unexpected tool_use_id found in tool_result blocks' This fix skips tool call extraction for failed messages, preventing session corruption after interruptions while preserving normal repair behavior. Adds tests for error, aborted, and normal (toolUse) stopReason cases. --- src/agents/session-transcript-repair.test.ts | 68 +++++++++++++++++++- src/agents/session-transcript-repair.ts | 13 ++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..28cef7256 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -1,6 +1,9 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; +import { + sanitizeToolUseResultPairing, + repairToolUseResultPairing, +} from "./session-transcript-repair.js"; describe("sanitizeToolUseResultPairing", () => { it("moves tool results directly after tool calls and inserts missing results", () => { @@ -109,4 +112,67 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); + + it("skips tool call extraction for assistant messages with stopReason 'error'", () => { + // When an assistant message has stopReason: "error", its tool_use blocks may be + // incomplete/malformed. We should NOT create synthetic tool_results for them, + // as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks" + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }], + stopReason: "error", + }, + { role: "user", content: "something went wrong" }, + ] as AgentMessage[]; + + const result = repairToolUseResultPairing(input); + + // Should NOT add synthetic tool results for errored messages + expect(result.added).toHaveLength(0); + // The assistant message should be passed through unchanged + expect(result.messages[0]?.role).toBe("assistant"); + expect(result.messages[1]?.role).toBe("user"); + expect(result.messages).toHaveLength(2); + }); + + it("skips tool call extraction for assistant messages with stopReason 'aborted'", () => { + // When a request is aborted mid-stream, the assistant message may have incomplete + // tool_use blocks (with partialJson). We should NOT create synthetic tool_results. + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_aborted", name: "Bash", arguments: {} }], + stopReason: "aborted", + }, + { role: "user", content: "retrying after abort" }, + ] as AgentMessage[]; + + const result = repairToolUseResultPairing(input); + + // Should NOT add synthetic tool results for aborted messages + expect(result.added).toHaveLength(0); + // Messages should be passed through without synthetic insertions + expect(result.messages).toHaveLength(2); + expect(result.messages[0]?.role).toBe("assistant"); + expect(result.messages[1]?.role).toBe("user"); + }); + + it("still repairs tool results for normal assistant messages with stopReason 'toolUse'", () => { + // Normal tool calls (stopReason: "toolUse" or "stop") should still be repaired + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_normal", name: "read", arguments: {} }], + stopReason: "toolUse", + }, + { role: "user", content: "user message" }, + ] as AgentMessage[]; + + const result = repairToolUseResultPairing(input); + + // Should add a synthetic tool result for the missing result + expect(result.added).toHaveLength(1); + expect(result.added[0]?.toolCallId).toBe("call_normal"); + }); }); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 2728bb89c..f34144a9b 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -116,6 +116,19 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } const assistant = msg as Extract; + + // Skip tool call extraction for aborted or errored assistant messages. + // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete + // (e.g., partialJson: true) and should not have synthetic tool_results created. + // Creating synthetic results for incomplete tool calls causes API 400 errors: + // "unexpected tool_use_id found in tool_result blocks" + // See: https://github.com/openclaw/openclaw/issues/4553 + const stopReason = (assistant as { stopReason?: string }).stopReason; + if (stopReason === "error" || stopReason === "aborted") { + out.push(msg); + continue; + } + const toolCalls = extractToolCallsFromAssistant(assistant); if (toolCalls.length === 0) { out.push(msg);