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.
This commit is contained in:
aisling404 2026-01-30 11:03:00 +00:00
parent bc432d8435
commit 5d04fdd94b
2 changed files with 80 additions and 1 deletions

View File

@ -1,6 +1,9 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; import {
sanitizeToolUseResultPairing,
repairToolUseResultPairing,
} from "./session-transcript-repair.js";
describe("sanitizeToolUseResultPairing", () => { describe("sanitizeToolUseResultPairing", () => {
it("moves tool results directly after tool calls and inserts missing results", () => { 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.some((m) => m.role === "toolResult")).toBe(false);
expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); 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");
});
}); });

View File

@ -116,6 +116,19 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
} }
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>; const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
// 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); const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
out.push(msg); out.push(msg);