mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user