From fa2616c390677028387a3faedd3a1013661d3deb Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sun, 15 Feb 2026 21:31:30 +0800 Subject: [PATCH] fix(session): drop duplicate assistant messages in transcript repair When a session is aborted mid-tool-execution, the assistant message can be persisted twice (once by message_end, once by the abort handler). The repair logic failed to handle this: it generated a synthetic tool result for the first copy but deduplicated the result for the second, leaving an orphaned tool call that caused "tool_call_id is not found" errors on all subsequent API calls. Detect and remove duplicate assistant messages whose tool call IDs have all already been paired with results from an earlier copy. Co-Authored-By: Claude Opus 4.6 --- .../session/session-transcript-repair.test.ts | 72 +++++++++++++++++++ .../session/session-transcript-repair.ts | 11 +++ 2 files changed, 83 insertions(+) diff --git a/packages/core/src/agent/session/session-transcript-repair.test.ts b/packages/core/src/agent/session/session-transcript-repair.test.ts index 39282e246..a4d9e79c9 100644 --- a/packages/core/src/agent/session/session-transcript-repair.test.ts +++ b/packages/core/src/agent/session/session-transcript-repair.test.ts @@ -113,6 +113,78 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); + it("drops duplicate assistant messages from abort double-save", () => { + // Reproduces the bug: session abort saves the same assistant message twice, + // leaving the second copy with a tool call that has no matching tool result. + const input = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me write a file" }, + { type: "toolCall", id: "tool_ABC", name: "write", arguments: { path: "/tmp/test.txt", content: "hello" } }, + ], + }, + // Duplicate from abort handler saving the same message again + { + role: "assistant", + content: [ + { type: "text", text: "Let me write a file" }, + { type: "toolCall", id: "tool_ABC", name: "write", arguments: { path: "/tmp/test.txt", content: "hello" } }, + ], + }, + // User sends a new message after the abort + { role: "user", content: "hello" }, + ] as AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + + // Should have: assistant, synthetic toolResult, user + // The duplicate assistant should be removed + const assistants = out.filter((m) => m.role === "assistant"); + expect(assistants).toHaveLength(1); + + const toolResults = out.filter((m) => m.role === "toolResult") as Array<{ toolCallId?: string }>; + expect(toolResults).toHaveLength(1); + expect(toolResults[0]?.toolCallId).toBe("tool_ABC"); + + const users = out.filter((m) => m.role === "user"); + expect(users).toHaveLength(1); + + // Verify ordering: assistant, toolResult, user + expect(out.map((m) => m.role)).toEqual(["assistant", "toolResult", "user"]); + }); + + it("drops duplicate assistant followed by error assistant", () => { + // Full reproduction: duplicate assistant + user + error assistant + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "tool_ABC", name: "write", arguments: { path: "/tmp/test.txt", content: "hello" } }, + ], + }, + { + role: "assistant", + content: [ + { type: "toolCall", id: "tool_ABC", name: "write", arguments: { path: "/tmp/test.txt", content: "hello" } }, + ], + }, + { role: "user", content: "continue" }, + { role: "assistant", content: [] }, + { role: "user", content: "how are you" }, + { role: "assistant", content: [] }, + ] as AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + + // The duplicate assistant should be removed; error assistants are kept (no tool calls) + const assistants = out.filter((m) => m.role === "assistant"); + expect(assistants).toHaveLength(3); // original + 2 error assistants + + const toolResults = out.filter((m) => m.role === "toolResult"); + expect(toolResults).toHaveLength(1); + }); + it("drops tool results with empty tool call id", () => { const input = [ { diff --git a/packages/core/src/agent/session/session-transcript-repair.ts b/packages/core/src/agent/session/session-transcript-repair.ts index d8417e605..9a082cd11 100644 --- a/packages/core/src/agent/session/session-transcript-repair.ts +++ b/packages/core/src/agent/session/session-transcript-repair.ts @@ -254,6 +254,17 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } } + // Drop duplicate assistant messages whose tool calls all already have results. + // This happens when a session abort persists the same assistant message twice. + if (toolCalls.every((call) => seenToolResultIds.has(call.id))) { + for (const rem of remainder) { + out.push(rem); + } + changed = true; + i = j - 1; + continue; + } + out.push(msg); if (spanResultsById.size > 0 && remainder.length > 0) {