From 711289a6061022768cd0c2da5df470bfeeec3c2c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Jul 2026 00:48:21 +0300 Subject: [PATCH] fix(chat): retain latest overlapping message data --- .../ui/src/components/chat/MessageList.tsx | 17 +++++++---- .../baseDisplayMessagesDedup.test.ts | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 3ffb37fc..e5f89abb 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1345,12 +1345,15 @@ const MessageList = React.forwardRef(({ const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => { const seenIds = new Set(); + const latestById = new Map(); const dedupedMessages: ChatMessageEntry[] = []; - // Deduplicate from head to tail (oldest to newest) to preserve chronological - // order during history pagination (prepend). Older messages have smaller - // time-sortable IDs and appear first in the array. Keeping the first - // occurrence ensures prepended history isn't incorrectly discarded in favor - // of newer duplicates from the existing view. + for (const message of messages) { + const messageId = message.info?.id; + if (typeof messageId === 'string') latestById.set(messageId, message); + } + + // Preserve the first occurrence's chronological position, but use the last + // value because prepended history can overlap with newer live store data. for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; const messageId = message.info?.id; @@ -1360,7 +1363,9 @@ const MessageList = React.forwardRef(({ } seenIds.add(messageId); } - dedupedMessages.push(getNormalizedMessageForDisplay(message)); + dedupedMessages.push(getNormalizedMessageForDisplay( + typeof messageId === 'string' ? latestById.get(messageId) ?? message : message, + )); } const output: ChatMessageEntry[] = []; diff --git a/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts b/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts index b2c275f5..5445d24e 100644 --- a/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts +++ b/packages/ui/src/components/chat/__tests__/baseDisplayMessagesDedup.test.ts @@ -4,12 +4,18 @@ import type { ChatMessageEntry } from '../lib/turns/types'; /** * Dedup logic extracted from MessageList.tsx baseDisplayMessages. * Tests verify that deduplication preserves chronological order - * and keeps the first occurrence of each message ID. + * while keeping the latest value for each message ID. */ function deduplicateMessages(messages: ChatMessageEntry[]): ChatMessageEntry[] { const seenIds = new Set(); + const latestById = new Map(); const dedupedMessages: ChatMessageEntry[] = []; + for (const message of messages) { + const messageId = message.info?.id; + if (typeof messageId === 'string') latestById.set(messageId, message); + } + for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; const messageId = message.info?.id; @@ -19,7 +25,9 @@ function deduplicateMessages(messages: ChatMessageEntry[]): ChatMessageEntry[] { } seenIds.add(messageId); } - dedupedMessages.push(message); + dedupedMessages.push( + typeof messageId === 'string' ? latestById.get(messageId) ?? message : message, + ); } return dedupedMessages; @@ -48,7 +56,7 @@ function createMessageEntry({ } describe('baseDisplayMessages dedup', () => { - test('removes duplicate message IDs, keeping the first occurrence', () => { + test('removes duplicate message IDs, keeping the latest value', () => { const msg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 }); const msg2 = createMessageEntry({ id: 'msg-2', role: 'assistant', createdAt: 2 }); const msg1Duplicate = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 3 }); @@ -57,6 +65,7 @@ describe('baseDisplayMessages dedup', () => { expect(result).toHaveLength(2); expect(result[0]?.info.id).toBe('msg-1'); + expect(result[0]?.info.time.created).toBe(3); expect(result[1]?.info.id).toBe('msg-2'); }); @@ -106,7 +115,7 @@ describe('baseDisplayMessages dedup', () => { expect(result[0]?.info.id).toBe('msg-1'); }); - test('all messages sharing same ID keeps only first', () => { + test('all messages sharing same ID keeps only the latest value', () => { const msg1 = createMessageEntry({ id: 'same-id', role: 'user', createdAt: 1 }); const msg2 = createMessageEntry({ id: 'same-id', role: 'assistant', createdAt: 2 }); const msg3 = createMessageEntry({ id: 'same-id', role: 'user', createdAt: 3 }); @@ -116,6 +125,7 @@ describe('baseDisplayMessages dedup', () => { expect(result).toHaveLength(1); expect(result[0]?.info.id).toBe('same-id'); expect(result[0]?.info.role).toBe('user'); + expect(result[0]?.info.time.created).toBe(3); }); test('deduplication scenario: prepend history with overlapping IDs', () => { @@ -133,10 +143,10 @@ describe('baseDisplayMessages dedup', () => { const result = deduplicateMessages(messages); - // Should keep all unique IDs, first occurrence wins + // Keep the prepended ordering position while retaining the existing value. expect(result).toHaveLength(3); expect(result[0]?.info.id).toBe('msg-0'); // prepended (oldest) - expect(result[1]?.info.id).toBe('msg-1'); // first occurrence from prepend + expect(result[1]).toBe(existingMsg1); expect(result[2]?.info.id).toBe('msg-2'); // existing }); @@ -149,16 +159,16 @@ describe('baseDisplayMessages dedup', () => { expect(result).toHaveLength(1); expect(result[0]?.info.id).toBe('msg-1'); + expect(result[0]?.info.time.created).toBe(3); }); - test('preserves first occurrence when duplicates appear later', () => { + test('preserves first position while using a later duplicate value', () => { const msg1First = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 }); const msg1Later = createMessageEntry({ id: 'msg-1', role: 'assistant', createdAt: 5 }); const result = deduplicateMessages([msg1First, msg1Later]); expect(result).toHaveLength(1); - // Should keep the first occurrence (createdAt: 1), not the later one - expect(result[0]?.info.role).toBe('user'); + expect(result[0]?.info.role).toBe('assistant'); }); });