From d0fa37e71d2d12d7c4f538bd5365481729f8d977 Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Sat, 23 May 2026 17:02:23 +0800 Subject: [PATCH] fix(frontend): avoid duplicate optimistic user message (#3002) --- frontend/src/core/threads/hooks.ts | 22 ++++- .../unit/core/threads/message-merge.test.ts | 95 ++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index fba3edd0c..1c927c5ff 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -135,6 +135,20 @@ function getMessagesAfterBaseline( }); } +export function getVisibleOptimisticMessages( + optimisticMessages: Message[], + previousHumanMessageCount: number, + currentHumanMessageCount: number, +): Message[] { + if ( + optimisticMessages.some((message) => message.type === "human") && + currentHumanMessageCount > previousHumanMessageCount + ) { + return []; + } + return optimisticMessages; +} + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; @@ -627,10 +641,16 @@ export function useThreadStream({ messagesRef.current = thread.messages; } + const visibleOptimisticMessages = getVisibleOptimisticMessages( + optimisticMessages, + prevHumanMsgCountRef.current, + humanMessageCount, + ); + const mergedMessages = mergeMessages( history, thread.messages, - optimisticMessages, + visibleOptimisticMessages, ); const pendingUsageMessages = thread.isLoading ? getMessagesAfterBaseline( diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts index 9b29aebc9..2afca1eef 100644 --- a/frontend/tests/unit/core/threads/message-merge.test.ts +++ b/frontend/tests/unit/core/threads/message-merge.test.ts @@ -1,7 +1,10 @@ import type { Message } from "@langchain/langgraph-sdk"; import { expect, test } from "vitest"; -import { mergeMessages } from "@/core/threads/hooks"; +import { + getVisibleOptimisticMessages, + mergeMessages, +} from "@/core/threads/hooks"; test("mergeMessages removes duplicate messages already present in history", () => { const human = { @@ -62,3 +65,93 @@ test("mergeMessages deduplicates tool messages by tool_call_id", () => { expect(mergeMessages([oldTool], [liveTool], [])).toEqual([liveTool]); }); + +test("getVisibleOptimisticMessages hides optimistic user input after server human arrives", () => { + const optimisticHuman = { + id: "opt-human-1", + type: "human", + content: "hello", + } as Message; + + expect(getVisibleOptimisticMessages([optimisticHuman], 0, 1)).toEqual([]); +}); + +test("mergeMessages shows server human instead of optimistic duplicate after first response", () => { + const serverHuman = { + id: "server-human-1", + type: "human", + content: "hello", + } as Message; + const optimisticHuman = { + id: "opt-human-1", + type: "human", + content: "hello", + } as Message; + const visibleOptimistic = getVisibleOptimisticMessages( + [optimisticHuman], + 0, + 1, + ); + + expect(mergeMessages([], [serverHuman], visibleOptimistic)).toEqual([ + serverHuman, + ]); +}); + +test("getVisibleOptimisticMessages keeps optimistic user input until server human arrives", () => { + const optimisticHuman = { + id: "opt-human-1", + type: "human", + content: "hello", + } as Message; + + expect(getVisibleOptimisticMessages([optimisticHuman], 0, 0)).toEqual([ + optimisticHuman, + ]); +}); + +test("getVisibleOptimisticMessages keeps non-human optimistic status messages", () => { + const optimisticAi = { + id: "opt-ai-1", + type: "ai", + content: "Uploading files...", + } as Message; + + expect(getVisibleOptimisticMessages([optimisticAi], 0, 1)).toEqual([ + optimisticAi, + ]); +}); + +test("getVisibleOptimisticMessages hides the upload optimistic pair after server human arrives", () => { + const optimisticHuman = { + id: "opt-human-1", + type: "human", + content: "upload this", + } as Message; + const optimisticUploadingAi = { + id: "opt-ai-uploading", + type: "ai", + content: "Uploading files...", + } as Message; + + expect( + getVisibleOptimisticMessages( + [optimisticHuman, optimisticUploadingAi], + 0, + 1, + ), + ).toEqual([]); +}); + +test("getVisibleOptimisticMessages hides optimistic user input after later server turns", () => { + const optimisticHuman = { + id: "opt-human-2", + type: "human", + content: "follow up", + } as Message; + + expect(getVisibleOptimisticMessages([optimisticHuman], 3, 4)).toEqual([]); + expect(getVisibleOptimisticMessages([optimisticHuman], 3, 3)).toEqual([ + optimisticHuman, + ]); +});