From 247eb906ed3a28707137a23995582d249158cab5 Mon Sep 17 00:00:00 2001 From: core-devops Date: Fri, 12 Jun 2026 23:20:38 -0700 Subject: [PATCH] fix(canvas-chat): dedup own USER_MESSAGE echo by threading one id (core#2697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the cross-device-sync ship: the user saw their OWN message twice. The optimistic bubble's id (createMessage's own crypto.randomUUID) and the A2A payload's messageId (a separate crypto.randomUUID) were independent, so the server's USER_MESSAGE broadcast echo — keyed on the payload messageId — never matched the optimistic bubble, and appendMessageDedupedById appended a second copy. Thread a single id: useChatSend mints messageId once and passes it to BOTH createMessage (new optional id param) and the payload. Now the echo dedups against the optimistic bubble on the origin device (one bubble), while other devices still receive and append it. Adds regression tests: createMessage honors a supplied id, and the threaded id makes the echo a no-op end-to-end. Co-Authored-By: Claude Fable 5 --- .../tabs/chat/__tests__/types.test.ts | 42 +++++++++++++++++++ .../components/tabs/chat/hooks/useChatSend.ts | 9 +++- canvas/src/components/tabs/chat/types.ts | 9 +++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/canvas/src/components/tabs/chat/__tests__/types.test.ts b/canvas/src/components/tabs/chat/__tests__/types.test.ts index 607e65cb..20abfc0d 100644 --- a/canvas/src/components/tabs/chat/__tests__/types.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/types.test.ts @@ -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); + }); +}); diff --git a/canvas/src/components/tabs/chat/hooks/useChatSend.ts b/canvas/src/components/tabs/chat/hooks/useChatSend.ts index cf124b60..d0a613f8 100644 --- a/canvas/src/components/tabs/chat/hooks/useChatSend.ts +++ b/canvas/src/components/tabs/chat/hooks/useChatSend.ts @@ -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 }, diff --git a/canvas/src/components/tabs/chat/types.ts b/canvas/src/components/tabs/chat/types.ts index 1bbba504..03c102d8 100644 --- a/canvas/src/components/tabs/chat/types.ts +++ b/canvas/src/components/tabs/chat/types.ts @@ -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 -- 2.52.0