From 3b57983d6570868977698831f6f8a497be9aa9e2 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sat, 13 Jun 2026 06:28:31 +0000 Subject: [PATCH] fix(chat): reuse optimistic message id as A2A messageId to prevent origin duplicate bubble (core#2713) useChatSend created the optimistic user message with one id, then sent a fresh crypto.randomUUID() as the A2A params.message.messageId. The server echoes USER_MESSAGE with the A2A id, and appendMessageDedupedById could not collapse the two bubbles on the origin device. - Use userMsg.id for the outbound message.messageId. - Add regression test asserting the optimistic and A2A ids match. Fixes #2713. Co-Authored-By: Claude --- .../useChatSend.originMessageId.test.tsx | 92 +++++++++++++++++++ .../components/tabs/chat/hooks/useChatSend.ts | 2 +- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.originMessageId.test.tsx diff --git a/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.originMessageId.test.tsx b/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.originMessageId.test.tsx new file mode 100644 index 000000000..f70d0ae30 --- /dev/null +++ b/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.originMessageId.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +// +// core#2713 — origin-device duplicate user bubble. +// +// Mechanism: useChatSend optimistically appends a user message with id X, +// then POSTs /a2a with message.messageId = crypto.randomUUID() (id Y). +// The server broadcasts USER_MESSAGE with id Y, and ChatTab dedupes by id +// via appendMessageDedupedById. Because X ≠ Y, the origin device renders +// both the optimistic bubble and the server echo. +// +// Fix: reuse the optimistic message id as the outbound A2A messageId so +// the echo is collapsed on the origin device while other devices still +// append normally. + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +const apiPostMock = vi.fn< + (url: string, body?: unknown, opts?: unknown) => Promise +>(); +vi.mock("@/lib/api", () => ({ + api: { + post: (url: string, body?: unknown, opts?: unknown) => + apiPostMock(url, body, opts), + get: vi.fn(), + }, +})); +vi.mock("../../uploads", () => ({ + uploadChatFiles: vi.fn(), + FileTooLargeError: class FileTooLargeError extends Error {}, +})); + +import { useChatSend } from "../useChatSend"; + +beforeEach(() => { + apiPostMock.mockReset(); +}); + +describe("useChatSend — origin-device message id deduplication — core#2713", () => { + it("reuses the optimistic user message id as the outbound A2A messageId", async () => { + apiPostMock.mockResolvedValueOnce({ status: "queued", delivery_mode: "poll" }); + + const onUserMessage = vi.fn(); + const { result } = renderHook(() => + useChatSend("ws-origin-dedup", { + getHistoryMessages: () => [], + onUserMessage, + }), + ); + + await act(async () => { + await result.current.sendMessage("What is the weather?"); + await Promise.resolve(); + }); + + expect(onUserMessage).toHaveBeenCalledTimes(1); + const optimisticMsg = onUserMessage.mock.calls[0][0] as { id: string }; + expect(optimisticMsg.id).toBeTruthy(); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + const body = apiPostMock.mock.calls[0][1] as { + params: { message: { messageId: string } }; + }; + expect(body.params.message.messageId).toBe(optimisticMsg.id); + }); + + it("does not mint a fresh random UUID for the A2A payload", async () => { + apiPostMock.mockResolvedValueOnce({ status: "queued", delivery_mode: "poll" }); + + const onUserMessage = vi.fn(); + const { result } = renderHook(() => + useChatSend("ws-no-random-uuid", { + getHistoryMessages: () => [], + onUserMessage, + }), + ); + + await act(async () => { + await result.current.sendMessage("no duplicate please"); + await Promise.resolve(); + }); + + const optimisticMsg = onUserMessage.mock.calls[0][0] as { id: string }; + const body = apiPostMock.mock.calls[0][1] as { + params: { message: { messageId: string } }; + }; + // The critical regression guard: if these diverge, appendMessageDedupedById + // cannot collapse the optimistic bubble and the WS USER_MESSAGE echo. + expect(body.params.message.messageId).not.toBe(crypto.randomUUID()); // always different + expect(body.params.message.messageId).toBe(optimisticMsg.id); + }); +}); diff --git a/canvas/src/components/tabs/chat/hooks/useChatSend.ts b/canvas/src/components/tabs/chat/hooks/useChatSend.ts index cf124b602..6237ad52d 100644 --- a/canvas/src/components/tabs/chat/hooks/useChatSend.ts +++ b/canvas/src/components/tabs/chat/hooks/useChatSend.ts @@ -219,7 +219,7 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) { params: { message: { role: "user", - messageId: crypto.randomUUID(), + messageId: userMsg.id, parts, }, metadata: { history }, -- 2.52.0