fix(canvas-chat): dedup own USER_MESSAGE echo by threading one id (core#2697 regression) #2715

Merged
devops-engineer merged 1 commits from fix/chat-user-message-dedup-id into main 2026-06-13 06:25:52 +00:00
3 changed files with 57 additions and 3 deletions
@@ -200,3 +200,45 @@ describe("appendMessageDedupedById", () => {
expect(next).toHaveLength(2);
});
});
// createMessage id threading (core#2697 regression guard).
//
// The cross-device dedup above is only correct if the optimistic
// bubble's id EQUALS the messageId the sender puts in the A2A
// envelope (which the server echoes back as the USER_MESSAGE
// message_id). The original ship generated those as TWO independent
// crypto.randomUUID()s — so the echo never matched and the origin
// device rendered its own message twice. The fix threads one id:
// useChatSend mints `messageId` once, passes it to createMessage AND
// the payload. These tests pin that createMessage honors a supplied
// id so the wiring can't silently regress.
describe("createMessage id threading", () => {
it("uses a supplied id verbatim (sender threads its messageId)", () => {
const mid = "11111111-2222-3333-4444-555555555555";
const msg = createMessage("user", "hi", undefined, undefined, mid);
expect(msg.id).toBe(mid);
});
it("generates a uuid when no id is supplied (back-compat)", () => {
const a = createMessage("agent", "hi");
const b = createMessage("agent", "hi");
expect(a.id).toBeTruthy();
expect(a.id).not.toBe(b.id);
});
it("the threaded id makes the USER_MESSAGE echo a no-op (end-to-end of the fix)", () => {
// Simulate the real send: one id for both the optimistic bubble
// and the server echo (which carries the same messageId).
const mid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const optimistic = createMessage("user", "can you check this issue for me", undefined, undefined, mid);
const echo: ChatMessage = {
id: mid, // server broadcast pins message_id == sent messageId
role: "user",
content: "can you check this issue for me",
timestamp: new Date().toISOString(),
};
const next = appendMessageDedupedById([optimistic], echo);
expect(next).toHaveLength(1); // single bubble — the reported dup is gone
expect(next[0]).toBe(optimistic);
});
});
@@ -180,7 +180,12 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
setUploading(false);
}
const userMsg = createMessage("user", trimmed, uploaded);
// One id, threaded through both the optimistic bubble and the A2A
// payload's messageId, so the server's USER_MESSAGE broadcast echo
// dedups against this bubble on the origin device (core#2697 —
// otherwise the sender saw its own message twice).
const messageId = crypto.randomUUID();
const userMsg = createMessage("user", trimmed, uploaded, undefined, messageId);
optionsRef.current.onUserMessage?.(userMsg);
setSending(true);
@@ -219,7 +224,7 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
messageId,
parts,
},
metadata: { history },
+8 -1
View File
@@ -42,9 +42,16 @@ export function createMessage(
content: string,
attachments?: ChatAttachment[],
toolTrace?: ToolTraceEntry[],
id?: string,
): ChatMessage {
return Object.freeze({
id: crypto.randomUUID(),
// When the caller supplies an id (the sender threads the SAME id it
// puts in the A2A payload's messageId), the USER_MESSAGE broadcast
// echo — which carries that messageId — dedups against this
// optimistic bubble (core#2697). Without it the optimistic id and
// the payload messageId were two independent randomUUIDs, so the
// origin device rendered its own message twice.
id: id ?? crypto.randomUUID(),
role,
content,
// Conditional spread avoids `attachments: undefined` appearing in