Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 36 additions & 21 deletions packages/ai/src/providers/transform-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,31 +152,40 @@ export function transformMessages<TApi extends Api>(
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<number>();
let pendingToolCalls: ToolCall[] = [];
let existingToolResultIds = new Set<string>();
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") {
Expand All @@ -202,14 +211,20 @@ export function transformMessages<TApi extends Api>(

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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: "<prime_agent_update_interrupted>", 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);
});
});
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down