fix: prevent synthetic toolResult for aborted/errored assistant messages

When an assistant message with toolCalls has stopReason 'aborted' or 'error',
the guard should not add those tool call IDs to the pending map. Creating
synthetic tool results for incomplete/aborted tool calls causes API 400 errors:
'unexpected tool_use_id found in tool_result blocks'

This aligns the WRITE path (session-tool-result-guard.ts) with the READ path
(session-transcript-repair.ts) which already skips aborted messages.

Fixes: orphaned tool_result causing session corruption

Tests added:
- does NOT create synthetic toolResult for aborted assistant messages
- does NOT create synthetic toolResult for errored assistant messages
This commit is contained in:
Leakim
2026-02-24 13:15:38 +00:00
committed by Peter Steinberger
parent 31b1b20b3c
commit 8db7ca8c02
2 changed files with 67 additions and 1 deletions

View File

@@ -357,4 +357,63 @@ describe("installSessionToolResultGuard", () => {
sourceTool: "sessions_send",
});
});
// Regression test for orphaned tool_result bug
// See: https://github.com/clawdbot/clawdbot/issues/XXXX
// When an assistant message with toolCalls is aborted, no synthetic toolResult
// should be created. Creating synthetic results for aborted/incomplete tool calls
// causes API 400 errors: "unexpected tool_use_id found in tool_result blocks"
it("does NOT create synthetic toolResult for aborted assistant messages with toolCalls", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
// Aborted assistant message with incomplete toolCall
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "read", arguments: {} }],
stopReason: "aborted",
}),
);
// Next message triggers flush of pending tool calls
sm.appendMessage(
asAppendMessage({
role: "user",
content: "are you stuck?",
timestamp: Date.now(),
}),
);
// Should only have assistant + user, NO synthetic toolResult
const messages = getPersistedMessages(sm);
const roles = messages.map((m) => m.role);
expect(roles).toEqual(["assistant", "user"]);
expect(roles).not.toContain("toolResult");
});
it("does NOT create synthetic toolResult for errored assistant messages with toolCalls", () => {
const sm = SessionManager.inMemory();
const guard = installSessionToolResultGuard(sm);
// Error assistant message with incomplete toolCall
sm.appendMessage(
asAppendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }],
stopReason: "error",
}),
);
// Explicit flush should NOT create synthetic result for errored messages
guard.flushPendingToolResults();
const messages = getPersistedMessages(sm);
const toolResults = messages.filter((m) => m.role === "toolResult");
// No synthetic toolResults should exist for the errored call
const syntheticForError = toolResults.filter(
(m) => (m as { toolCallId?: string }).toolCallId === "call_error",
);
expect(syntheticForError).toHaveLength(0);
});
});

View File

@@ -166,8 +166,15 @@ export function installSessionToolResultGuard(
return originalAppend(persisted as never);
}
// Skip tool call extraction for aborted/errored assistant messages.
// When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
// 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"
// This matches the behavior in repairToolUseResultPairing (session-transcript-repair.ts)
const stopReason = (nextMessage as { stopReason?: string }).stopReason;
const toolCalls =
nextRole === "assistant"
nextRole === "assistant" && stopReason !== "aborted" && stopReason !== "error"
? extractToolCallsFromAssistant(nextMessage as Extract<AgentMessage, { role: "assistant" }>)
: [];