diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 7b656606646..105b587cf9d 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -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); + }); }); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 689bb816c1e..dba618a3103 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -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) : [];