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 },