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
44 changes: 33 additions & 11 deletions src/server/services/agent/AdminService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import AgentThreadService from './ThreadService';
import AgentMessageStore from './MessageStore';
import AgentFeedbackService from './FeedbackService';
import type { CanonicalAgentMessage } from './canonicalMessages';
import { deriveTitleFromMessageParts, loadFirstUserMessageParts, readUsefulThreadTitle } from './SessionReadService';
import type {
McpAuthConfig,
McpDiscoveredTool,
Expand Down Expand Up @@ -220,6 +221,7 @@ function serializeSessionSummary(
pendingActionsCount?: number;
lastRunAt?: string | null;
latestThread?: ReviewThread | null;
title?: string | null;
}
) {
return {
Expand Down Expand Up @@ -250,6 +252,7 @@ function serializeSessionSummary(
threadCount: counts?.threadCount ?? 0,
pendingActionsCount: counts?.pendingActionsCount ?? 0,
lastRunAt: counts?.lastRunAt ?? null,
title: counts?.title ?? null,
latestThreadId: counts?.latestThread?.uuid ?? null,
latestThreadTitle: counts?.latestThread?.title ?? null,
createdAt: session.createdAt || null,
Expand Down Expand Up @@ -350,16 +353,29 @@ export default class AgentAdminService {
pendingCountBySessionId.set(sessionId, (pendingCountBySessionId.get(sessionId) || 0) + 1);
}

const serialized = filteredSessions.map((session) =>
serializeSessionSummary(session, {
threadCount: threadCountBySessionId.get(sessionDbIdByUuid.get(session.uuid) || -1) || 0,
pendingActionsCount: pendingCountBySessionId.get(sessionDbIdByUuid.get(session.uuid) || -1) || 0,
lastRunAt: lastRunAtBySessionId.get(sessionDbIdByUuid.get(session.uuid) || -1) || null,
latestThread: latestThreadBySessionId.get(sessionDbIdByUuid.get(session.uuid) || -1),
})
);
const page = paginateArray(filteredSessions, filters.page, filters.limit);
const untitledPageSessionIds = page.data
.map((session) => sessionDbIdByUuid.get(session.uuid) || -1)
.filter((sessionDbId) => sessionDbId > 0 && !readUsefulThreadTitle(latestThreadBySessionId.get(sessionDbId)));
const firstUserMessagePartsBySessionId = untitledPageSessionIds.length
? await loadFirstUserMessageParts(untitledPageSessionIds)
: new Map<number, unknown>();

return paginateArray(serialized, filters.page, filters.limit);
return {
...page,
data: page.data.map((session) => {
const sessionDbId = sessionDbIdByUuid.get(session.uuid) || -1;
return serializeSessionSummary(session, {
threadCount: threadCountBySessionId.get(sessionDbId) || 0,
pendingActionsCount: pendingCountBySessionId.get(sessionDbId) || 0,
lastRunAt: lastRunAtBySessionId.get(sessionDbId) || null,
latestThread: latestThreadBySessionId.get(sessionDbId),
title:
readUsefulThreadTitle(latestThreadBySessionId.get(sessionDbId)) ||
deriveTitleFromMessageParts(firstUserMessagePartsBySessionId.get(sessionDbId)),
});
}),
};
}

static async getSession(sessionUuid: string) {
Expand All @@ -375,7 +391,7 @@ export default class AgentAdminService {
.orderBy('isDefault', 'desc');

const threadIds = threads.map((thread) => thread.id);
const [messageRows, runRows, pendingRows] = await Promise.all([
const [messageRows, runRows, pendingRows, firstUserMessagePartsBySessionId] = await Promise.all([
threadIds.length
? AgentMessage.query().whereIn('threadId', threadIds).select('threadId')
: Promise.resolve([] as Array<{ threadId: number }>),
Expand All @@ -385,6 +401,7 @@ export default class AgentAdminService {
threadIds.length
? AgentPendingAction.query().whereIn('threadId', threadIds).where('status', 'pending').select('threadId')
: Promise.resolve([] as Array<{ threadId: number }>),
threadIds.length ? loadFirstUserMessageParts([session.id]) : Promise.resolve(new Map<number, unknown>()),
]);

const messageCountByThreadId = new Map<number, number>();
Expand Down Expand Up @@ -422,9 +439,14 @@ export default class AgentAdminService {
: null,
}));

const latestThread = latestReviewThread(threads);

return {
session: serializeSessionSummary(enrichedSession, {
latestThread: latestReviewThread(threads),
latestThread,
title:
readUsefulThreadTitle(latestThread) ||
deriveTitleFromMessageParts(firstUserMessagePartsBySessionId.get(session.id)),
threadCount: serializedThreads.length,
pendingActionsCount: serializedThreads.reduce((total, thread) => total + thread.pendingActionsCount, 0),
lastRunAt:
Expand Down
6 changes: 3 additions & 3 deletions src/server/services/agent/SessionReadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ function latestTimestamp(values: Array<string | Date | null | undefined>): strin
return latest?.iso || null;
}

function readUsefulThreadTitle(thread: AgentThread | null): string | null {
export function readUsefulThreadTitle(thread: Pick<AgentThread, 'title'> | null | undefined): string | null {
const title = thread?.title?.trim();
if (!title || title.toLowerCase() === 'default thread') {
return null;
Expand All @@ -256,7 +256,7 @@ interface FirstUserMessageRow {
parts: unknown;
}

function deriveTitleFromMessageParts(parts: unknown): string | null {
export function deriveTitleFromMessageParts(parts: unknown): string | null {
if (!Array.isArray(parts)) {
return null;
}
Expand Down Expand Up @@ -285,7 +285,7 @@ function deriveTitleFromMessageParts(parts: unknown): string | null {
}

/** First user message per session, as the title fallback when no thread was explicitly titled. */
async function loadFirstUserMessageParts(sessionIds: number[]): Promise<Map<number, unknown>> {
export async function loadFirstUserMessageParts(sessionIds: number[]): Promise<Map<number, unknown>> {
const rows = (await AgentThread.knex().raw(
`
select distinct on (t."sessionId") t."sessionId" as "sessionId", m.parts as parts
Expand Down
23 changes: 23 additions & 0 deletions src/server/services/agent/__tests__/AdminService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const mockSerializeRunEvent = jest.fn();
const mockSerializeThread = jest.fn();
const mockSerializeCanonicalMessage = jest.fn();
const mockListMaskedUsersForServer = jest.fn();
const mockLoadFirstUserMessageParts = jest.fn();

const canonicalStartupFailure = {
stage: 'connect_runtime',
Expand All @@ -40,6 +41,11 @@ const canonicalStartupFailure = {
origin: 'agent_session',
};

jest.mock('../SessionReadService', () => ({
...jest.requireActual('../SessionReadService'),
loadFirstUserMessageParts: (...args: unknown[]) => mockLoadFirstUserMessageParts(...args),
}));

jest.mock('../FeedbackService', () => ({
__esModule: true,
default: { listThreadFeedback: (...args: unknown[]) => mockListThreadFeedback(...args) },
Expand Down Expand Up @@ -155,6 +161,7 @@ import AgentAdminService from '../AdminService';
describe('AgentAdminService.listSessions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockLoadFirstUserMessageParts.mockResolvedValue(new Map());
mockListThreadFeedback.mockResolvedValue([]);
});

Expand Down Expand Up @@ -236,8 +243,11 @@ describe('AgentAdminService.listSessions', () => {
expect(result.metadata.pagination).toEqual({ current: index + 1, total: 3, items: 3, limit: 1 });
expect(mockEnrichSessions).toHaveBeenLastCalledWith(sessions.slice(4));
expect(threadWhereIn).toHaveBeenLastCalledWith('sessionId', [5, 6, 7]);
expect(mockLoadFirstUserMessageParts).toHaveBeenLastCalledWith([[5, 6, 7][index]]);
}
expect(mockLoadFirstUserMessageParts).toHaveBeenCalledTimes(3);
const pastLastPage = await AgentAdminService.listSessions({ page: 4, limit: 1 });
expect(mockLoadFirstUserMessageParts).toHaveBeenCalledTimes(3);
expect(pastLastPage.data).toEqual([]);
expect(pastLastPage.metadata.pagination).toEqual({ current: 4, total: 3, items: 3, limit: 1 });
});
Expand Down Expand Up @@ -424,22 +434,29 @@ describe('AgentAdminService.listSessions', () => {
select: pendingSelect,
});

mockLoadFirstUserMessageParts.mockResolvedValue(
new Map([[101, [{ type: 'text', text: ' How do I deploy?\n' }]]])
);

const result = await AgentAdminService.listSessions({});

expect(mockLoadFirstUserMessageParts).toHaveBeenCalledWith([101, 202]);
expect(sessionQueryBuilder.orderBy).toHaveBeenNthCalledWith(1, 'createdAt', 'desc');
expect(sessionQueryBuilder.orderBy).toHaveBeenNthCalledWith(2, 'id', 'desc');
expect(threadWhereIn).toHaveBeenCalledWith('sessionId', [101, 202]);
expect(pendingWhereIn).toHaveBeenCalledWith('thread.sessionId', [101, 202]);
expect(result.data).toEqual([
expect.objectContaining({
id: 'eda50b6f-f421-42c4-8d7e-7b38d1c7c362',
title: 'How do I deploy?',
threadCount: 1,
pendingActionsCount: 1,
lastRunAt: '2026-04-05T18:00:00.000Z',
startupFailure: canonicalStartupFailure,
}),
expect.objectContaining({
id: '3e81553b-b8d4-4d2b-88d0-8d5775bcffde',
title: null,
threadCount: 0,
pendingActionsCount: 0,
lastRunAt: null,
Expand Down Expand Up @@ -604,6 +621,7 @@ describe('AgentAdminService.listSessions', () => {
expect(sessionQueryBuilder.where).not.toHaveBeenCalled();
expect(mockThreadQuery).not.toHaveBeenCalled();
expect(mockPendingActionQuery).not.toHaveBeenCalled();
expect(mockLoadFirstUserMessageParts).not.toHaveBeenCalled();
expect(result).toEqual({
data: [],
metadata: {
Expand All @@ -621,6 +639,7 @@ describe('AgentAdminService.listSessions', () => {
describe('AgentAdminService.getSession', () => {
beforeEach(() => {
jest.clearAllMocks();
mockLoadFirstUserMessageParts.mockResolvedValue(new Map());
mockListThreadFeedback.mockResolvedValue([]);
mockSerializeThread.mockImplementation((thread, sessionId) => ({
id: thread.uuid,
Expand Down Expand Up @@ -807,8 +826,11 @@ describe('AgentAdminService.getSession', () => {
select: jest.fn().mockResolvedValue([{ threadId: 7 }]),
});

mockLoadFirstUserMessageParts.mockResolvedValue(new Map([[17, [{ type: 'text', text: 'First question' }]]]));

const result = await AgentAdminService.getSession('session-1');

expect(mockLoadFirstUserMessageParts).toHaveBeenCalledWith([17]);
expect(threadQuery.where).toHaveBeenCalledWith({ sessionId: 17 });
expect(threadQuery.whereNull).not.toHaveBeenCalled();
expect(result.threads).toEqual([
Expand Down Expand Up @@ -846,6 +868,7 @@ describe('AgentAdminService.getSession', () => {
expect.objectContaining({
id: 'session-1',
latestThreadId: 'thread-fresh-debug',
title: 'Fresh Debug diagnosis',
threadCount: 3,
pendingActionsCount: 1,
lastRunAt: '2026-05-09T18:00:00.000Z',
Expand Down
5 changes: 5 additions & 0 deletions src/shared/openApiSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3286,6 +3286,11 @@ export const openApiSpecificationForV2Api: OAS3Options = {
lastActivity: { type: 'string', format: 'date-time', nullable: true },
archivedAt: { type: 'string', format: 'date-time', nullable: true },
threadCount: { type: 'integer' },
title: {
type: 'string',
nullable: true,
description: 'Named latest conversation title, or the first user message when no conversation is named.',
},
latestThreadId: {
type: 'string',
format: 'uuid',
Expand Down
Loading