Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/web/src/lib/cloud-agent-next/message-ordering.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
25 changes: 25 additions & 0 deletions apps/web/src/lib/cloud-agent-next/message-ordering.ts
Original file line number Diff line number Diff line change
@@ -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));
}
7 changes: 5 additions & 2 deletions apps/web/src/routers/admin-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/routers/admin-session-traces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down