From 63d10881cae583b757659511b67695051a7a75f6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:27:13 +0000 Subject: [PATCH 1/3] fix(site/src/pages/AgentsPage): order chat transcript by message id buildOrderedMessageIDs sorted by created_at, which is the transaction start time and is shared across an insert batch. Because the sort is stable, a merge that appends newly fetched lower ids to the existing message map preserved the map order, rendering a later batch ahead of an earlier one. --- .../chatStore.createStore.test.ts | 45 +++++++++++++++---- .../components/ChatConversation/chatStore.ts | 13 +----- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 801524047e1..53b1a30f9e7 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -6,8 +6,8 @@ import { createChatStore, selectIsAwaitingFirstStreamChunk } from "./chatStore"; // Helpers // --------------------------------------------------------------------------- -/** Minimal ChatMessage factory. `created_at` is derived from `id` to make - * ordering deterministic in tests that care about sort order. */ +/** Minimal ChatMessage factory. `created_at` is derived from `id` so the two + * agree unless a test deliberately makes them disagree. */ const makeMessage = ( id: number, role: string, @@ -53,19 +53,19 @@ describe("replaceMessages", () => { expect(state.orderedMessageIDs).toEqual([1, 2]); }); - it("sorts messages by created_at", () => { + it("sorts messages by id when created_at disagrees with append order", () => { const store = createChatStore(); - const older = { + const first = { ...makeMessage(1, "user", "first"), - created_at: "2025-01-01T00:00:01.000Z", + created_at: "2025-01-01T00:00:05.000Z", } as TypesGen.ChatMessage; - const newer = { + const second = { ...makeMessage(2, "assistant", "second"), - created_at: "2025-01-01T00:00:05.000Z", + created_at: "2025-01-01T00:00:01.000Z", } as TypesGen.ChatMessage; // Insert in reverse order. - store.replaceMessages([newer, older]); + store.replaceMessages([second, first]); expect(store.getSnapshot().orderedMessageIDs).toEqual([1, 2]); }); @@ -98,6 +98,35 @@ describe("replaceMessages", () => { }); }); +// --------------------------------------------------------------------------- +// upsertDurableMessages +// --------------------------------------------------------------------------- + +describe("upsertDurableMessages", () => { + it("orders merged messages by id rather than by arrival", () => { + const store = createChatStore(); + // A batch shares one created_at, so the merge order is all that + // distinguishes these once the later ids are already present. + const sharedCreatedAt = "2025-01-01T00:00:00.000Z"; + const withSharedCreatedAt = (id: number, role: string) => + ({ + ...makeMessage(id, role, `message-${id}`), + created_at: sharedCreatedAt, + }) as TypesGen.ChatMessage; + + store.replaceMessages([ + withSharedCreatedAt(3, "assistant"), + withSharedCreatedAt(4, "tool"), + ]); + store.upsertDurableMessages([ + withSharedCreatedAt(1, "user"), + withSharedCreatedAt(2, "assistant"), + ]); + + expect(store.getSnapshot().orderedMessageIDs).toEqual([1, 2, 3, 4]); + }); +}); + // --------------------------------------------------------------------------- // upsertDurableMessage // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 428e93cc08a..886524feed3 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -7,15 +7,6 @@ import { import { applyMessagePartToStreamState } from "./streamState"; import type { ReconnectState, RetryState, StreamState } from "./types"; -const byMessageCreatedAt = ( - left: TypesGen.ChatMessage, - right: TypesGen.ChatMessage, -): number => { - return ( - new Date(left.created_at).getTime() - new Date(right.created_at).getTime() - ); -}; - const buildMessageMap = ( messages: readonly TypesGen.ChatMessage[], ): Map => @@ -24,8 +15,8 @@ const buildMessageMap = ( const buildOrderedMessageIDs = ( messages: readonly TypesGen.ChatMessage[], ): readonly number[] => { - const sorted = [...messages]; - sorted.sort(byMessageCreatedAt); + // created_at is shared across an insert batch, so only id tracks append order. + const sorted = messages.toSorted((left, right) => left.id - right.id); // Deduplicate by ID. The input can contain duplicate IDs when // cross-page duplication occurs in the React Query cache (e.g. // upsertCacheMessages writes to page 0 while the same message From 44d13d0e11f42c6bd5bad2434e96520f06206ef5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:51:51 +0000 Subject: [PATCH 2/3] test(site/src/pages/AgentsPage): drop redundant comments from chat store tests --- .../chatStore.createStore.test.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 53b1a30f9e7..2087397b8eb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -6,8 +6,6 @@ import { createChatStore, selectIsAwaitingFirstStreamChunk } from "./chatStore"; // Helpers // --------------------------------------------------------------------------- -/** Minimal ChatMessage factory. `created_at` is derived from `id` so the two - * agree unless a test deliberately makes them disagree. */ const makeMessage = ( id: number, role: string, @@ -98,21 +96,17 @@ describe("replaceMessages", () => { }); }); -// --------------------------------------------------------------------------- -// upsertDurableMessages -// --------------------------------------------------------------------------- - describe("upsertDurableMessages", () => { it("orders merged messages by id rather than by arrival", () => { const store = createChatStore(); - // A batch shares one created_at, so the merge order is all that - // distinguishes these once the later ids are already present. const sharedCreatedAt = "2025-01-01T00:00:00.000Z"; - const withSharedCreatedAt = (id: number, role: string) => - ({ - ...makeMessage(id, role, `message-${id}`), - created_at: sharedCreatedAt, - }) as TypesGen.ChatMessage; + const withSharedCreatedAt = ( + id: number, + role: string, + ): TypesGen.ChatMessage => ({ + ...makeMessage(id, role, `message-${id}`), + created_at: sharedCreatedAt, + }); store.replaceMessages([ withSharedCreatedAt(3, "assistant"), From c3ab8f035c08441a7a5159e207e9afb5fba852b7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:15:38 +0000 Subject: [PATCH 3/3] test(site/src/pages/AgentsPage): cover merged transcript order in storybook The comparator fix only had store-level coverage. This renders a merge that appends lower ids after higher ones, all sharing one created_at, and asserts the rendered rows follow id order. --- .../components/ChatPageContent.stories.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 17e3f183401..0ffe7e873c1 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -113,3 +113,36 @@ export const HiddenAssistantPlaceholderDoesNotRender: Story = { expect(rows[1]).toHaveTextContent("Done."); }, }; + +export const MergedMessagesRenderInIDOrder: Story = { + render: () => { + const store = createChatStore(); + // One created_at for all four, so id is the only ordering signal. + const batchCreatedAt = new Date(FIXTURE_NOW).toISOString(); + const batched = ( + id: number, + role: TypesGen.ChatMessageRole, + text: string, + ): TypesGen.ChatMessage => ({ + ...buildMessage(id, role, [{ type: "text", text }]), + created_at: batchCreatedAt, + }); + + store.replaceMessages([ + batched(3, "user", "charlie"), + batched(4, "assistant", "delta"), + ]); + store.upsertDurableMessages([ + batched(1, "user", "alpha"), + batched(2, "assistant", "bravo"), + ]); + + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByTestId("conversation-timeline")).toHaveTextContent( + /alpha[\s\S]*bravo[\s\S]*charlie[\s\S]*delta/, + ); + }, +};