diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..342c37e4f 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; +import { isIncompleteToolCall, sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; describe("sanitizeToolUseResultPairing", () => { it("moves tool results directly after tool calls and inserts missing results", () => { @@ -109,4 +109,201 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); + + it("skips incomplete tool calls (partialJson, no arguments) and drops their synthetic results", () => { + // Simulates a terminated streaming response: the assistant message has a + // tool call with partialJson but no arguments, followed by a synthetic + // error toolResult inserted by session persistence. The repair must skip + // the incomplete tool call entirely so no synthetic result is emitted, and + // the orphaned synthetic result is dropped. + const input = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me write that file:" }, + { + type: "toolCall", + id: "toolu_terminated", + name: "write", + partialJson: '{"path": "/tmp/test.md", "content": "# Hello', + }, + ], + }, + { + role: "toolResult", + toolCallId: "toolu_terminated", + toolName: "write", + content: [ + { + type: "text", + text: "[moltbot] missing tool result in session history; inserted synthetic error result for transcript repair.", + }, + ], + isError: true, + }, + { role: "user", content: "what happened?" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + // The incomplete tool call should be treated as having zero tool calls, + // so no toolResult should appear in the output. + expect(out.some((m) => m.role === "toolResult")).toBe(false); + expect(out.map((m) => m.role)).toEqual(["assistant", "user"]); + }); + + it("keeps complete tool calls even when partialJson is present", () => { + // A tool call that completed successfully may still carry partialJson + // from the streaming buffer. It must be treated normally. + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_ok", + name: "read", + arguments: { path: "/tmp/test.md" }, + partialJson: '{"path": "/tmp/test.md"}', + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_ok", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + expect(out).toHaveLength(2); + expect(out[0]?.role).toBe("assistant"); + expect(out[1]?.role).toBe("toolResult"); + }); + + it("handles mixed complete and incomplete tool calls in one assistant message", () => { + // The assistant issued two parallel tool calls but was terminated during + // the second. The first call completed and has a real result; the second + // is incomplete. Only the first should be paired. + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_done", name: "read", arguments: { path: "/a" } }, + { + type: "toolCall", + id: "call_partial", + name: "write", + partialJson: '{"path": "/b", "content": "half...', + }, + ], + }, + { + role: "toolResult", + toolCallId: "call_done", + toolName: "read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + { + role: "toolResult", + toolCallId: "call_partial", + toolName: "write", + content: [{ type: "text", text: "[moltbot] synthetic" }], + isError: true, + }, + { role: "user", content: "continue" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + const results = out.filter((m) => m.role === "toolResult"); + // Only the completed tool call should have a result + expect(results).toHaveLength(1); + expect((results[0] as { toolCallId?: string }).toolCallId).toBe("call_done"); + expect(out.map((m) => m.role)).toEqual(["assistant", "toolResult", "user"]); + }); + + it("handles incomplete tool call with empty arguments object", () => { + // Some providers set arguments to {} when the stream was interrupted + // before any argument parsing completed. + const input = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_empty_args", + name: "exec", + arguments: {}, + partialJson: '{"command": "ls', + }, + ], + }, + { role: "user", content: "?" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + expect(out.some((m) => m.role === "toolResult")).toBe(false); + expect(out.map((m) => m.role)).toEqual(["assistant", "user"]); + }); +}); + +describe("isIncompleteToolCall", () => { + it("returns true for partialJson with no arguments", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "write", + partialJson: '{"path": "/tmp', + }), + ).toBe(true); + }); + + it("returns true for partialJson with empty arguments", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "exec", + arguments: {}, + partialJson: '{"command": "ls', + }), + ).toBe(true); + }); + + it("returns false for complete tool call with partialJson", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "read", + arguments: { path: "/tmp/test" }, + partialJson: '{"path": "/tmp/test"}', + }), + ).toBe(false); + }); + + it("returns false for tool call without partialJson", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "read", + arguments: { path: "/tmp/test" }, + }), + ).toBe(false); + }); + + it("returns false when partialJson is empty string", () => { + expect( + isIncompleteToolCall({ + type: "toolCall", + id: "x", + name: "read", + partialJson: "", + }), + ).toBe(false); + }); }); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index d680beb4d..4ec30813b 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -5,6 +5,35 @@ type ToolCallLike = { name?: string; }; +/** + * Check whether a tool call block is incomplete (terminated mid-stream). + * + * When a streaming response is interrupted (`stopReason: "error"`, + * `errorMessage: "terminated"`), the assistant message may contain tool call + * blocks with only `partialJson` and no valid `arguments`. These were never + * executed, so inserting a synthetic `toolResult` for them causes Anthropic's + * API to reject the request with "unexpected tool_use_id". + * + * We detect incompleteness by checking: + * 1. `partialJson` is present (streaming was in progress), AND + * 2. `arguments` is missing, empty, or not a non-empty object. + * + * If `arguments` is fully populated the call completed successfully and should + * be treated normally, even if `partialJson` happens to be set. + */ +function isIncompleteToolCall(block: Record): boolean { + if (typeof block.partialJson !== "string" || !block.partialJson) return false; + + const args = block.arguments; + // No arguments at all → incomplete + if (args === undefined || args === null) return true; + // Empty object {} → incomplete (arguments were not parsed) + if (typeof args === "object" && !Array.isArray(args) && Object.keys(args as object).length === 0) + return true; + + return false; +} + function extractToolCallsFromAssistant( msg: Extract, ): ToolCallLike[] { @@ -18,6 +47,10 @@ function extractToolCallsFromAssistant( if (typeof rec.id !== "string" || !rec.id) continue; if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") { + // Skip incomplete tool calls from terminated/errored streaming responses. + // These were never executed, so they must not receive a synthetic toolResult. + if (isIncompleteToolCall(rec as Record)) continue; + toolCalls.push({ id: rec.id, name: typeof rec.name === "string" ? rec.name : undefined, @@ -54,7 +87,7 @@ function makeMissingToolResult(params: { } as Extract; } -export { makeMissingToolResult }; +export { isIncompleteToolCall, makeMissingToolResult }; export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { return repairToolUseResultPairing(messages).messages;