diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index db26f73558..56a81ff2f6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed interrupted tool turns replaying late tool results after an interposed user message ([#1102](https://github.com/PrimeIntellect-ai/prime-agent/pull/1102) by [@junhoyeo](https://github.com/junhoyeo)). + ## [0.7.1] - 2026-08-07 ## [0.7.0] - 2026-08-05 diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 8fde3716d1..d29135cb58 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -152,31 +152,40 @@ export function transformMessages( return msg; }); - // Second pass: insert synthetic empty tool results for orphaned tool calls - // This preserves thinking signatures and satisfies API requirements + // Second pass: pair every emitted tool result with a currently pending tool call. + // A persisted user message can race an abort result; recover that result from the + // remainder of the same turn before synthesizing anything at the user boundary. const result: Message[] = []; + const consumedToolResultIndexes = new Set(); let pendingToolCalls: ToolCall[] = []; let existingToolResultIds = new Set(); + const hasUnresolvedToolCall = (toolCallId: string) => + pendingToolCalls.some((toolCall) => toolCall.id === toolCallId) && !existingToolResultIds.has(toolCallId); + const appendRealToolResult = (message: ToolResultMessage): boolean => { + if (!hasUnresolvedToolCall(message.toolCallId)) return false; + existingToolResultIds.add(message.toolCallId); + result.push(message); + return true; + }; const insertSyntheticToolResults = () => { - if (pendingToolCalls.length > 0) { - for (const tc of pendingToolCalls) { - if (!existingToolResultIds.has(tc.id)) { - result.push({ - role: "toolResult", - toolCallId: tc.id, - toolName: tc.name, - content: [{ type: "text", text: "No result provided" }], - isError: true, - timestamp: Date.now(), - } as ToolResultMessage); - } + for (const toolCall of pendingToolCalls) { + if (!existingToolResultIds.has(toolCall.id)) { + result.push({ + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: "No result provided" }], + isError: true, + timestamp: Date.now(), + }); } - pendingToolCalls = []; - existingToolResultIds = new Set(); } + pendingToolCalls = []; + existingToolResultIds = new Set(); }; for (let i = 0; i < transformed.length; i++) { + if (consumedToolResultIndexes.has(i)) continue; const msg = transformed[i]; if (msg.role === "assistant") { @@ -202,14 +211,20 @@ export function transformMessages( result.push(msg); } else if (msg.role === "toolResult") { - existingToolResultIds.add(msg.toolCallId); - result.push(msg); + // Unmatched and duplicate results are invalid at every provider boundary. + appendRealToolResult(msg); } else if (msg.role === "user") { - // User message interrupts tool flow - insert synthetic results for orphaned calls + if (pendingToolCalls.length > 0) { + for (let lookahead = i + 1; lookahead < transformed.length; lookahead++) { + const later = transformed[lookahead]; + if (later.role === "assistant") break; + if (later.role === "toolResult" && appendRealToolResult(later)) { + consumedToolResultIndexes.add(lookahead); + } + } + } insertSyntheticToolResults(); result.push(msg); - } else { - result.push(msg); } } diff --git a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts index 7508e74b80..d6cf387e72 100644 --- a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts +++ b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { transformMessages } from "../src/providers/transform-messages.js"; -import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.js"; +import type { AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../src/types.js"; // Normalize function matching what anthropic.ts uses function anthropicNormalizeToolCallId( @@ -46,6 +46,17 @@ function makeAssistantMessage(content: AssistantMessage["content"]): AssistantMe }; } +function makeToolResult(toolCallId: string, text: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId, + toolName: "read", + content: [{ type: "text", text }], + isError: text === "Request was aborted", + timestamp: 2, + }; +} + describe("OpenAI to Anthropic session migration for Copilot Claude", () => { it("converts thinking blocks to plain text when source model differs", () => { const model = makeCopilotClaudeModel(); @@ -188,4 +199,117 @@ describe("OpenAI to Anthropic session migration for Copilot Claude", () => { content: [{ type: "text", text: "No result provided" }], }); }); + + it("hoists a real abort result across an interposed update-restart user message", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + makeAssistantMessage([ + { type: "toolCall", id: "call_1|fc_1", name: "read", arguments: { path: "README.md" } }, + ]), + { role: "user", content: "", timestamp: 1 }, + makeToolResult("call_1|fc_1", "Request was aborted"), + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + + expect(result.map((message) => message.role)).toEqual(["assistant", "toolResult", "user"]); + expect(result[1]).toMatchObject({ + role: "toolResult", + toolCallId: "call_1_fc_1", + content: [{ type: "text", text: "Request was aborted" }], + }); + expect(result).not.toContainEqual( + expect.objectContaining({ role: "toolResult", content: [{ type: "text", text: "No result provided" }] }), + ); + }); + + it("uses real results from both sides of a user boundary in a parallel tool batch", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + makeAssistantMessage([ + { type: "toolCall", id: "call_1|fc_1", name: "read", arguments: { path: "one" } }, + { type: "toolCall", id: "call_2|fc_2", name: "read", arguments: { path: "two" } }, + { type: "toolCall", id: "call_3|fc_3", name: "read", arguments: { path: "three" } }, + ]), + makeToolResult("call_1|fc_1", "one"), + { role: "user", content: "restart", timestamp: 1 }, + makeToolResult("call_2|fc_2", "two"), + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + const results = result.filter((message): message is ToolResultMessage => message.role === "toolResult"); + + expect(result.map((message) => message.role)).toEqual([ + "assistant", + "toolResult", + "toolResult", + "toolResult", + "user", + ]); + expect(results.map((message) => [message.toolCallId, message.content[0]])).toEqual([ + ["call_1_fc_1", { type: "text", text: "one" }], + ["call_2_fc_2", { type: "text", text: "two" }], + ["call_3_fc_3", { type: "text", text: "No result provided" }], + ]); + }); + + it("still synthesizes a missing result at a user boundary", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + makeAssistantMessage([{ type: "toolCall", id: "call_1|fc_1", name: "read", arguments: {} }]), + { role: "user", content: "continue", timestamp: 1 }, + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + + expect(result.map((message) => message.role)).toEqual(["assistant", "toolResult", "user"]); + expect(result[1]).toMatchObject({ + role: "toolResult", + toolCallId: "call_1_fc_1", + content: [{ type: "text", text: "No result provided" }], + }); + }); + + it("drops truly orphaned and duplicate late tool results", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + makeToolResult("orphan", "orphan"), + makeAssistantMessage([{ type: "toolCall", id: "call_1", name: "read", arguments: {} }]), + makeToolResult("call_1", "real"), + { role: "user", content: "continue", timestamp: 1 }, + makeToolResult("call_1", "duplicate"), + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + + expect(result.map((message) => message.role)).toEqual(["assistant", "toolResult", "user"]); + expect(result[1]).toMatchObject({ content: [{ type: "text", text: "real" }] }); + }); + + it("leaves a well-formed tool turn in source order", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + makeAssistantMessage([{ type: "toolCall", id: "call_1", name: "read", arguments: {} }]), + makeToolResult("call_1", "real"), + { role: "user", content: "continue", timestamp: 1 }, + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + + expect(result).toEqual(messages); + }); + + it("is idempotent after repairing a broken tool turn", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + makeAssistantMessage([ + { type: "toolCall", id: "call_1|fc_1", name: "read", arguments: { path: "README.md" } }, + ]), + { role: "user", content: "restart", timestamp: 1 }, + makeToolResult("call_1|fc_1", "Request was aborted"), + ]; + const repaired = transformMessages(messages, model, anthropicNormalizeToolCallId); + + expect(transformMessages(repaired, model, anthropicNormalizeToolCallId)).toEqual(repaired); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579a..221bf3d6ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Fixed update-restored sessions becoming unresumable when a restart marker preceded an aborted tool result ([#1102](https://github.com/PrimeIntellect-ai/prime-agent/pull/1102) by [@junhoyeo](https://github.com/junhoyeo)). - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844))