diff --git a/src/server/services/agent/AdminService.ts b/src/server/services/agent/AdminService.ts index 19949c7d..abbdc8f3 100644 --- a/src/server/services/agent/AdminService.ts +++ b/src/server/services/agent/AdminService.ts @@ -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, @@ -220,6 +221,7 @@ function serializeSessionSummary( pendingActionsCount?: number; lastRunAt?: string | null; latestThread?: ReviewThread | null; + title?: string | null; } ) { return { @@ -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, @@ -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(); - 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) { @@ -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 }>), @@ -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()), ]); const messageCountByThreadId = new Map(); @@ -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: diff --git a/src/server/services/agent/SessionReadService.ts b/src/server/services/agent/SessionReadService.ts index 702557bc..dbe6b4ef 100644 --- a/src/server/services/agent/SessionReadService.ts +++ b/src/server/services/agent/SessionReadService.ts @@ -240,7 +240,7 @@ function latestTimestamp(values: Array): strin return latest?.iso || null; } -function readUsefulThreadTitle(thread: AgentThread | null): string | null { +export function readUsefulThreadTitle(thread: Pick | null | undefined): string | null { const title = thread?.title?.trim(); if (!title || title.toLowerCase() === 'default thread') { return null; @@ -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; } @@ -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> { +export async function loadFirstUserMessageParts(sessionIds: number[]): Promise> { const rows = (await AgentThread.knex().raw( ` select distinct on (t."sessionId") t."sessionId" as "sessionId", m.parts as parts diff --git a/src/server/services/agent/__tests__/AdminService.test.ts b/src/server/services/agent/__tests__/AdminService.test.ts index 0b3e93e3..eb807642 100644 --- a/src/server/services/agent/__tests__/AdminService.test.ts +++ b/src/server/services/agent/__tests__/AdminService.test.ts @@ -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', @@ -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) }, @@ -155,6 +161,7 @@ import AgentAdminService from '../AdminService'; describe('AgentAdminService.listSessions', () => { beforeEach(() => { jest.clearAllMocks(); + mockLoadFirstUserMessageParts.mockResolvedValue(new Map()); mockListThreadFeedback.mockResolvedValue([]); }); @@ -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 }); }); @@ -424,8 +434,13 @@ 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]); @@ -433,6 +448,7 @@ describe('AgentAdminService.listSessions', () => { 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', @@ -440,6 +456,7 @@ describe('AgentAdminService.listSessions', () => { }), expect.objectContaining({ id: '3e81553b-b8d4-4d2b-88d0-8d5775bcffde', + title: null, threadCount: 0, pendingActionsCount: 0, lastRunAt: null, @@ -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: { @@ -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, @@ -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([ @@ -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', diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index c042cf02..30bda796 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -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',