From a23fab4693e7e7db3b2dc2ea7b7f65114eae54dc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 22 Apr 2026 19:41:55 +0200 Subject: [PATCH] fix(ai): synthesize trailing orphaned tool results closes #3555 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/transform-messages.ts | 3 + ...ssages-copilot-openai-to-anthropic.test.ts | 76 +++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cb677271..2be9dbdb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed `google-gemini-cli` built-in model discovery to include `gemini-3.1-flash-lite-preview`, so Cloud Code Assist model lists expose it without requiring manual `--model` fallback selection ([#3545](https://github.com/badlogic/pi-mono/issues/3545)) +- Fixed `transformMessages()` to synthesize missing trailing tool results for transcripts that end with unresolved assistant tool calls during direct low-level history replay ([#3555](https://github.com/badlogic/pi-mono/issues/3555)) ## [0.68.1] - 2026-04-22 diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 5c0abed4..8fde3716 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -213,5 +213,8 @@ export function transformMessages( } } + // If the conversation ends with unresolved tool calls, synthesize results now. + insertSyntheticToolResults(); + return result; } 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 2602b61a..111bd8d4 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 @@ -26,6 +26,26 @@ function makeCopilotClaudeModel(): Model<"anthropic-messages"> { }; } +function makeAssistantMessage(content: AssistantMessage["content"]): AssistantMessage { + return { + role: "assistant", + content, + api: "openai-responses", + provider: "github-copilot", + model: "gpt-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + describe("OpenAI to Anthropic session migration for Copilot Claude", () => { it("converts thinking blocks to plain text when source model differs", () => { const model = makeCopilotClaudeModel(); @@ -112,4 +132,60 @@ describe("OpenAI to Anthropic session migration for Copilot Claude", () => { expect(toolCall.thoughtSignature).toBeUndefined(); }); + + it("adds synthetic tool results for trailing orphaned tool calls", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: Date.now() }, + makeAssistantMessage([ + { + type: "toolCall", + id: "call_123|fc_123", + name: "read", + arguments: { path: "README.md" }, + }, + ]), + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + const lastMessage = result[result.length - 1]; + + expect(lastMessage).toMatchObject({ + role: "toolResult", + toolCallId: "call_123_fc_123", + toolName: "read", + isError: true, + content: [{ type: "text", text: "No result provided" }], + }); + }); + + it("adds synthetic results only for trailing tool calls that are still missing results", () => { + const model = makeCopilotClaudeModel(); + const messages: Message[] = [ + { role: "user", content: "run commands", timestamp: Date.now() }, + makeAssistantMessage([ + { type: "toolCall", id: "call_1|fc_1", name: "read", arguments: { path: "README.md" } }, + { type: "toolCall", id: "call_2|fc_2", name: "bash", arguments: { command: "pwd" } }, + ]), + { + role: "toolResult", + toolCallId: "call_1|fc_1", + toolName: "read", + content: [{ type: "text", text: "done" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const result = transformMessages(messages, model, anthropicNormalizeToolCallId); + const syntheticResults = result.filter((message) => message.role === "toolResult" && message.isError); + + expect(syntheticResults).toHaveLength(1); + expect(syntheticResults[0]).toMatchObject({ + role: "toolResult", + toolCallId: "call_2_fc_2", + toolName: "bash", + content: [{ type: "text", text: "No result provided" }], + }); + }); });