fix(chat): reuse optimistic message id as A2A messageId to prevent origin duplicate bubble (core#2713) #2716

Closed
agent-dev-a wants to merge 1 commits from fix/2713-origin-duplicate-message-id into main
2 changed files with 93 additions and 1 deletions
@@ -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<unknown>
>();
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);
});
});
@@ -219,7 +219,7 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
messageId: userMsg.id,
parts,
},
metadata: { history },