From 70fa9b1612f87fa1fe60cb42c16b3b7aaba1c1be Mon Sep 17 00:00:00 2001 From: eshurakov <54751+eshurakov@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:27:25 +0000 Subject: [PATCH] feat(web): sort session messages by time-ordered ID in admin traces Implement message and part re-ordering in the admin session traces router to ensure the display order matches the cloud-agent-next UI. This fixes issues where messages ingested out of order (e.g., assistant turns arriving before user prompts) would appear incorrectly in the admin trace view. - Add `sortSessionMessagesForDisplay` utility to handle message and part ordering based on IDs. - Update `adminRouter.sessionTraces.getMessages` to use the new sorting logic. - Add unit tests for message ordering logic and integration tests for the admin router. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../cloud-agent-next/message-ordering.test.ts | 60 +++++++++++++++++++ .../lib/cloud-agent-next/message-ordering.ts | 25 ++++++++ apps/web/src/routers/admin-router.ts | 7 ++- .../src/routers/admin-session-traces.test.ts | 37 ++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/cloud-agent-next/message-ordering.test.ts create mode 100644 apps/web/src/lib/cloud-agent-next/message-ordering.ts diff --git a/apps/web/src/lib/cloud-agent-next/message-ordering.test.ts b/apps/web/src/lib/cloud-agent-next/message-ordering.test.ts new file mode 100644 index 0000000000..45af0accc3 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-next/message-ordering.test.ts @@ -0,0 +1,60 @@ +import type { SessionMessage } from '@/lib/session-ingest-client'; +import { sortSessionMessagesForDisplay } from './message-ordering'; + +function message(id: string, partIds: string[] = []): SessionMessage { + return { + info: { id }, + parts: partIds.map(partId => ({ id: partId })), + }; +} + +describe('sortSessionMessagesForDisplay', () => { + it('sorts messages by info.id ascending, matching cloud-agent-next insertSorted order', () => { + const sorted = sortSessionMessagesForDisplay([ + message('msg_000000000003c'), + message('msg_000000000001a'), + message('msg_000000000002b'), + ]); + + expect(sorted.map(m => m.info.id)).toEqual([ + 'msg_000000000001a', + 'msg_000000000002b', + 'msg_000000000003c', + ]); + }); + + it('sorts parts within each message by part id', () => { + const sorted = sortSessionMessagesForDisplay([ + message('msg_1', ['part_000000000002b', 'part_000000000001a', 'part_000000000003c']), + ]); + + expect(sorted[0]?.parts.map(p => p.id)).toEqual([ + 'part_000000000001a', + 'part_000000000002b', + 'part_000000000003c', + ]); + }); + + it('does not mutate the input messages or parts arrays', () => { + const input = [message('msg_2', ['part_2', 'part_1']), message('msg_1')]; + + sortSessionMessagesForDisplay(input); + + expect(input.map(m => m.info.id)).toEqual(['msg_2', 'msg_1']); + expect(input[0]?.parts.map(p => p.id)).toEqual(['part_2', 'part_1']); + }); + + it('keeps already-ordered messages stable', () => { + const input = [message('msg_1'), message('msg_2'), message('msg_3')]; + + expect(sortSessionMessagesForDisplay(input).map(m => m.info.id)).toEqual([ + 'msg_1', + 'msg_2', + 'msg_3', + ]); + }); + + it('handles an empty message list', () => { + expect(sortSessionMessagesForDisplay([])).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-next/message-ordering.ts b/apps/web/src/lib/cloud-agent-next/message-ordering.ts new file mode 100644 index 0000000000..85a742ab99 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-next/message-ordering.ts @@ -0,0 +1,25 @@ +import type { SessionMessage } from '@/lib/session-ingest-client'; + +/** + * Message display ordering for V2 (StoredMessage-shaped) sessions. + * + * The session-ingest export endpoint streams messages and parts in ingest + * order (`ingested_at, id`), which can differ from conversation order when a + * session is re-ingested or history arrives out of order. The cloud-agent-next + * UI re-establishes display order by inserting each message/part into storage + * sorted by its time-ordered ID (`insertSorted` / `insertPartSorted` in + * `@kilocode/cloud-agent-sdk` storage). Message and part IDs are + * `msg_`/`part_` + big-endian hex timestamp + random suffix, so plain + * lexicographic ordering matches chronological order. + * + * Read-only views that render the raw export (e.g. the admin session trace + * viewer) must apply the same ordering to match what users see. + */ +export function sortSessionMessagesForDisplay(messages: SessionMessage[]): SessionMessage[] { + const byIdAscending = (a: { id: string }, b: { id: string }) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + + return messages + .map(message => ({ ...message, parts: [...message.parts].sort(byIdAscending) })) + .sort((a, b) => byIdAscending(a.info, b.info)); +} diff --git a/apps/web/src/routers/admin-router.ts b/apps/web/src/routers/admin-router.ts index c259dc8cc1..caf4ec3109 100644 --- a/apps/web/src/routers/admin-router.ts +++ b/apps/web/src/routers/admin-router.ts @@ -30,7 +30,8 @@ import { api_request_log, } from '@kilocode/db/schema'; import { isNewSession } from '@/lib/cloud-agent/session-type'; -import { fetchSessionSnapshot, type SessionMessage } from '@/lib/session-ingest-client'; +import { fetchSessionSnapshot } from '@/lib/session-ingest-client'; +import { sortSessionMessagesForDisplay } from '@/lib/cloud-agent-next/message-ordering'; import { syncAndStoreProviders } from '@/lib/ai-gateway/providers/openrouter/sync-providers'; import { adminAppBuilderRouter } from '@/routers/admin-app-builder-router'; import { adminDeploymentsRouter } from '@/routers/admin-deployments-router'; @@ -2261,8 +2262,10 @@ export const adminRouter = createTRPCRouter({ try { const snapshot = await fetchSessionSnapshot(input.session_id, session.kilo_user_id); + // The export streams messages/parts in ingest order; re-sort by the + // time-ordered IDs so the trace matches the cloud-agent-next UI. return { - messages: snapshot?.messages ?? ([] satisfies SessionMessage[]), + messages: sortSessionMessagesForDisplay(snapshot?.messages ?? []), format: 'v2' as const, }; } catch (error) { diff --git a/apps/web/src/routers/admin-session-traces.test.ts b/apps/web/src/routers/admin-session-traces.test.ts index 71f974a0fc..41fd572ece 100644 --- a/apps/web/src/routers/admin-session-traces.test.ts +++ b/apps/web/src/routers/admin-session-traces.test.ts @@ -192,6 +192,43 @@ describe('admin.sessionTraces authorization', () => { expect(mockFetchSessionSnapshot).toHaveBeenCalledWith(sessionId, owner.id); }); + test('getMessages sorts v2 messages and parts by time-ordered ID like the cloud-agent-next UI', async () => { + const owner = await insertTestUser(); + const viewer = await insertAdmin({ can_view_sessions: true }); + const sessionId = `ses_${crypto.randomUUID()}`; + await db.insert(cli_sessions_v2).values({ + session_id: sessionId, + kilo_user_id: owner.id, + }); + + const caller = await createCallerForUser(viewer.id); + // The session-ingest export streams in ingest order, which can differ from + // conversation order (assistant turn ingested before its user prompt). + mockFetchSessionSnapshot.mockResolvedValue({ + info: { id: sessionId }, + messages: [ + { + info: { id: 'msg_000000000002b', role: 'assistant' }, + parts: [{ id: 'part_000000000002b' }, { id: 'part_000000000001a' }], + }, + { info: { id: 'msg_000000000001a', role: 'user' }, parts: [] }, + ], + }); + + await expect( + caller.admin.sessionTraces.getMessages({ session_id: sessionId }) + ).resolves.toEqual({ + messages: [ + { info: { id: 'msg_000000000001a', role: 'user' }, parts: [] }, + { + info: { id: 'msg_000000000002b', role: 'assistant' }, + parts: [{ id: 'part_000000000001a' }, { id: 'part_000000000002b' }], + }, + ], + format: 'v2', + }); + }); + test('a session viewer can read Cloud Agent container identity, SKU, and recorded capacity', async () => { const owner = await insertTestUser(); const viewer = await insertAdmin({ can_view_sessions: true });