Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/quiet-tools-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"agents": patch
"@cloudflare/ai-chat": patch
"@cloudflare/think": patch
---

Reconcile reused tool-call IDs one-to-one so later assistant messages and tool outputs stay attached to the correct turn.

Some providers reuse a `toolCallId` across turns. Assistant reconciliation now claims server rows one-to-one across the whole transcript instead of resolving each message against a conversation-wide `toolCallId` lookup, so a later assistant can no longer adopt an earlier row's ID and overwrite it on upsert. Terminal tool outputs merge from the server row a message actually resolved to; a message that resolved to no row may still merge from an unambiguous `toolCallId` whose tool input is identical, which keeps stale duplicates from persisting in a pre-terminal state.

`resolveToolMergeId` is deprecated. It carries the original conversation-wide behaviour and is no longer used by `@cloudflare/ai-chat` or `@cloudflare/think`; use `reconcileMessages` instead.
141 changes: 138 additions & 3 deletions packages/agents/src/chat/__tests__/message-reconciler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,149 @@ describe("reconcileMessages — ID reconciliation", () => {
expect(result[3].id).toBe("srv-a2");
});

it("skips content matching for tool-bearing assistant messages", () => {
it("adopts server ID for a toolCallId match", () => {
const server = [
toolAssistantMsg("srv-a1", "tc1", "output-available", { output: 1 })
];
const client = [
toolAssistantMsg("cli-a1", "tc1", "output-available", { output: 1 })
];
const result = reconcileMessages(client, server);
expect(result[0].id).toBe("cli-a1");
expect(result[0].id).toBe("srv-a1");
});

it("claims reused toolCallIds one-to-one across turns", () => {
const server = [
userMsg("u1", "first"),
toolAssistantMsg("srv-a1", "tc1", "output-available", {
output: "old result"
})
];
const client = [
userMsg("u1", "first"),
toolAssistantMsg("srv-a1", "tc1", "output-available", {
output: "old result"
}),
userMsg("u2", "second"),
toolAssistantMsg("cli-a2", "tc1", "input-available", {
input: { turn: 2 }
})
];

const result = reconcileMessages(client, server);

expect(result[1].id).toBe("srv-a1");
expect(result[3].id).toBe("cli-a2");
expect((result[3].parts[0] as Record<string, unknown>).state).toBe(
"input-available"
);
expect(
(result[3].parts[0] as Record<string, unknown>).output
).toBeUndefined();
});

it("still merges a terminal result into an unclaimed same-input duplicate", () => {
// The server row is claimed by an exact-ID match, so the stale duplicate
// can claim nothing. It carries the SAME input, so it is the same call and
// must still pick up the server's terminal state — otherwise it persists
// as a dangling `input-available` orphan (#1381) and the server's
// output-error is lost from that row (#1623).
const server = [
toolAssistantMsg("srv-a1", "tc1", "output-error", {
input: { q: "same" },
output: undefined
})
];
const client = [
toolAssistantMsg("srv-a1", "tc1", "output-error", {
input: { q: "same" }
}),
toolAssistantMsg("cli-a9", "tc1", "input-available", {
input: { q: "same" }
})
];

const result = reconcileMessages(client, server);

expect(result).toHaveLength(2);
expect((result[1].parts[0] as Record<string, unknown>).state).toBe(
"output-error"
);
});

it("merges a result stored on a different server row than the one matched", () => {
// The incoming message resolves to srv-a1, which carries tc1 but not tc2.
// tc2's result was persisted on a separate row, so a per-message-only
// lookup would write tc2 back as still pending.
const server = [
toolAssistantMsg("srv-a1", "tc1", "output-available", {
input: { q: "one" },
output: "one done"
}),
toolAssistantMsg("srv-a2", "tc2", "output-available", {
input: { q: "two" },
output: "two done"
})
];
const client = [
{
id: "srv-a1",
role: "assistant",
parts: [
{
type: "tool-calc",
toolCallId: "tc1",
state: "input-available",
input: { q: "one" }
},
{
type: "tool-calc",
toolCallId: "tc2",
state: "input-available",
input: { q: "two" }
}
]
} as unknown as ChatMessage
];

const result = reconcileMessages(client, server);
const parts = result[0].parts as unknown as Record<string, unknown>[];

expect(parts[0].state).toBe("output-available");
expect(parts[0].output).toBe("one done");
expect(parts[1].state).toBe("output-available");
expect(parts[1].output).toBe("two done");
});

it("does not merge a terminal result into a reused-ID call with different input", () => {
// Same toolCallId but a DIFFERENT input means the provider reused the ID
// for a genuinely new call, which must not inherit the old result.
const server = [
toolAssistantMsg("srv-a1", "tc1", "output-available", {
input: { q: "first" },
output: "old result"
})
];
const client = [
toolAssistantMsg("srv-a1", "tc1", "output-available", {
input: { q: "first" },
output: "old result"
}),
toolAssistantMsg("cli-a9", "tc1", "input-available", {
input: { q: "second" }
})
];

const result = reconcileMessages(client, server);

expect(result).toHaveLength(2);
expect(result[1].id).toBe("cli-a9");
expect((result[1].parts[0] as Record<string, unknown>).state).toBe(
"input-available"
);
expect(
(result[1].parts[0] as Record<string, unknown>).output
).toBeUndefined();
});

it("passes through when server state is empty", () => {
Expand Down Expand Up @@ -327,6 +461,7 @@ describe("reconcileMessages — composed stages", () => {
expect((result[0].parts[0] as Record<string, unknown>).state).toBe(
"output-available"
);
expect(result[0].id).toBe("srv-a1");
expect(result[1].id).toBe("srv-a2");
});

Expand Down Expand Up @@ -355,7 +490,7 @@ describe("reconcileMessages — composed stages", () => {
}
];
const result = reconcileMessages([msg], server);
expect(result[0].id).toBe("cli-a1");
expect(result[0].id).toBe("srv-a1");
});
});

Expand Down
Loading
Loading