diff --git a/ui/src/ui/chat/history-merge.test.ts b/ui/src/ui/chat/history-merge.test.ts new file mode 100644 index 00000000000..9fc08f902a6 --- /dev/null +++ b/ui/src/ui/chat/history-merge.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { preserveOptimisticTailMessages } from "../controllers/chat.ts"; + +describe("preserveOptimisticTailMessages", () => { + it("keeps optimistic tail messages while history is stale", () => { + const persistedUser = { + role: "user", + content: [{ type: "text", text: "first" }], + __openclaw: { seq: 1 }, + }; + const optimisticUser = { + role: "user", + content: [{ type: "text", text: "latest ask" }], + timestamp: 10, + }; + const optimisticAssistant = { + role: "assistant", + content: [{ type: "text", text: "latest answer" }], + timestamp: 11, + }; + + expect( + preserveOptimisticTailMessages( + [persistedUser], + [persistedUser, optimisticUser, optimisticAssistant], + ), + ).toEqual([persistedUser, optimisticUser, optimisticAssistant]); + }); + + it("drops streamed assistant tail when final history has caught up past the shared user", () => { + const persistedUser = { + role: "user", + content: [{ type: "text", text: "latest ask" }], + __openclaw: { seq: 1 }, + }; + const streamedAssistant = { + role: "assistant", + content: [{ type: "text", text: "partial streamed answer" }], + timestamp: 10, + }; + const historyAssistant = { + role: "assistant", + content: [{ type: "text", text: "complete persisted answer" }], + __openclaw: { seq: 2 }, + }; + + expect( + preserveOptimisticTailMessages( + [persistedUser, historyAssistant], + [persistedUser, streamedAssistant], + ), + ).toEqual([persistedUser, historyAssistant]); + }); +}); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index b45fe6aa526..6c03716b531 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -264,7 +264,7 @@ function messageDisplaySignature(message: unknown): string | null { } } -function preserveOptimisticTailMessages( +export function preserveOptimisticTailMessages( historyMessages: unknown[], previousMessages: unknown[], ): unknown[] { @@ -279,29 +279,37 @@ function preserveOptimisticTailMessages( ? previousMessages : historyMessages; } - const historySignatures = new Set( - historyMessages - .map((message) => messageDisplaySignature(message)) - .filter((signature): signature is string => Boolean(signature)), - ); + const historySignatureIndexes = new Map(); + historyMessages.forEach((message, index) => { + const signature = messageDisplaySignature(message); + if (signature) { + historySignatureIndexes.set(signature, index); + } + }); let sharedPreviousIndex = -1; + let sharedHistoryIndex = -1; for (let index = previousMessages.length - 1; index >= 0; index--) { const signature = messageDisplaySignature(previousMessages[index]); - if (signature && historySignatures.has(signature)) { + const historyIndex = signature ? historySignatureIndexes.get(signature) : undefined; + if (typeof historyIndex === "number") { sharedPreviousIndex = index; + sharedHistoryIndex = historyIndex; break; } } if (sharedPreviousIndex < 0) { return historyMessages; } + if (sharedHistoryIndex < historyMessages.length - 1) { + return historyMessages; + } const optimisticTail: unknown[] = []; for (const message of previousMessages.slice(sharedPreviousIndex + 1)) { if (!isLocallyOptimisticHistoryMessage(message) || shouldHideHistoryMessage(message)) { return historyMessages; } const signature = messageDisplaySignature(message); - if (!signature || historySignatures.has(signature)) { + if (!signature || historySignatureIndexes.has(signature)) { return historyMessages; } optimisticTail.push(message); @@ -544,6 +552,9 @@ export async function sendChatMessage( if (!msg && !hasAttachments) { return null; } + if (state.chatSending) { + return state.chatRunId; + } const now = Date.now();