From 6b28ffae0e117961a2be2d5e20e4d7e22ac3c879 Mon Sep 17 00:00:00 2001 From: adwibha Date: Thu, 7 May 2026 11:33:05 -0500 Subject: [PATCH 1/2] feat: add pagination and sorting to listSessions Add pagination and sorting capabilities to the session listing API. Changes: - ListSessionsRequest now accepts limit, offset, page, and order parameters - ListSessionsResponse now includes page, limit, totalItems, and totalPages - Sorting by updateTime (ascending or descending) with id as tie-breaker - Pagination logic: use limit if provided, fall back to offset-only slicing, or return all - Pagination metadata always included in response for consistency - Database layer counts total items efficiently for offset-only requests - Both DatabaseSessionService and InMemorySessionService implement identically - Added 23 test cases covering pagination edge cases and sorting Closes #324 --- core/src/sessions/base_session_service.ts | 18 + core/src/sessions/database_session_service.ts | 64 +++- .../src/sessions/in_memory_session_service.ts | 80 ++++- .../sessions/database_session_service_test.ts | 256 ++++++++++++++ .../in_memory_session_service_test.ts | 324 ++++++++++++++++++ 5 files changed, 735 insertions(+), 7 deletions(-) diff --git a/core/src/sessions/base_session_service.ts b/core/src/sessions/base_session_service.ts index beafb477..4d21a981 100644 --- a/core/src/sessions/base_session_service.ts +++ b/core/src/sessions/base_session_service.ts @@ -57,6 +57,14 @@ export interface ListSessionsRequest { appName: string; /** The ID of the user. */ userId: string; + /** Maximum number of sessions to return. */ + limit?: number; + /** Zero-based index of the first session to return. Ignored if `page` is set. */ + offset?: number; + /** 1-based page number. Requires `limit`. Takes precedence over `offset`. */ + page?: number; + /** Sort direction by last update time. No ordering is applied if omitted. */ + order?: 'asc' | 'desc'; } /** @@ -85,10 +93,20 @@ export interface AppendEventRequest { * The response of listing sessions. * * The events and states are not set within each Session object. + * When no pagination params were requested, `page` is 1, `limit` equals + * `totalItems`, and `totalPages` is 1 (or 0 when there are no sessions). */ export interface ListSessionsResponse { /** A list of sessions. */ sessions: Session[]; + /** Current page number (1-based). */ + page: number; + /** Page size used. Equals `totalItems` when no limit was requested. */ + limit: number; + /** Total number of sessions matching the request. */ + totalItems: number; + /** Total number of pages. */ + totalPages: number; } /** diff --git a/core/src/sessions/database_session_service.ts b/core/src/sessions/database_session_service.ts index 4d0d3f58..8754baf2 100644 --- a/core/src/sessions/database_session_service.ts +++ b/core/src/sessions/database_session_service.ts @@ -254,6 +254,10 @@ export class DatabaseSessionService extends BaseSessionService { async listSessions({ appName, userId, + limit, + offset, + page, + order, }: ListSessionsRequest): Promise { await this.init(); const em = this.orm!.em.fork(); @@ -263,7 +267,63 @@ export class DatabaseSessionService extends BaseSessionService { where.userId = userId; } - const storageSessions = await em.find(StorageSession, where); + const orderBy = + order === 'asc' + ? {updateTime: 'ASC' as const, id: 'ASC' as const} + : order === 'desc' + ? {updateTime: 'DESC' as const, id: 'ASC' as const} + : undefined; + + let storageSessions; + let paginationMeta: Pick< + ListSessionsResponse, + 'page' | 'limit' | 'totalItems' | 'totalPages' + >; + + if (limit !== undefined) { + const totalItems = await em.count(StorageSession, where); + const totalPages = limit === 0 ? 0 : Math.ceil(totalItems / limit); + + let effectiveOffset: number; + let effectivePage: number; + if (page !== undefined) { + effectiveOffset = (page - 1) * limit; + effectivePage = page; + } else { + effectiveOffset = offset ?? 0; + effectivePage = + limit === 0 ? 1 : Math.floor(effectiveOffset / limit) + 1; + } + + storageSessions = await em.find(StorageSession, where, { + orderBy, + limit, + offset: effectiveOffset, + }); + paginationMeta = {page: effectivePage, limit, totalItems, totalPages}; + } else if (offset) { + const totalItems = await em.count(StorageSession, where); + storageSessions = await em.find(StorageSession, where, { + orderBy, + offset, + }); + paginationMeta = { + page: 1, + limit: totalItems, + totalItems, + totalPages: totalItems === 0 ? 0 : 1, + }; + } else { + storageSessions = await em.find(StorageSession, where, {orderBy}); + const totalItems = storageSessions.length; + paginationMeta = { + page: 1, + limit: totalItems, + totalItems, + totalPages: totalItems === 0 ? 0 : 1, + }; + } + const appStateModel = await em.findOne(StorageAppState, {appName}); const appState = appStateModel?.state || {}; const userStateMap: Record> = {}; @@ -291,7 +351,7 @@ export class DatabaseSessionService extends BaseSessionService { }); }); - return {sessions}; + return {sessions, ...paginationMeta}; } async deleteSession({ diff --git a/core/src/sessions/in_memory_session_service.ts b/core/src/sessions/in_memory_session_service.ts index d505b64f..ca39af75 100644 --- a/core/src/sessions/in_memory_session_service.ts +++ b/core/src/sessions/in_memory_session_service.ts @@ -134,14 +134,40 @@ export class InMemorySessionService extends BaseSessionService { listSessions({ appName, userId, + limit, + offset, + page, + order, }: ListSessionsRequest): Promise { if (!this.sessions[appName] || !this.sessions[appName][userId]) { - return Promise.resolve({sessions: []}); + if (limit !== undefined) { + const effectiveOffset = + page !== undefined ? (page - 1) * limit : (offset ?? 0); + const effectivePage = + page !== undefined + ? page + : limit === 0 + ? 1 + : Math.floor(effectiveOffset / limit) + 1; + return Promise.resolve({ + sessions: [], + page: effectivePage, + limit, + totalItems: 0, + totalPages: 0, + }); + } + return Promise.resolve({ + sessions: [], + page: 1, + limit: 0, + totalItems: 0, + totalPages: 0, + }); } - const sessionsWithoutEvents: Session[] = []; - for (const session of Object.values(this.sessions[appName][userId])) { - sessionsWithoutEvents.push( + const all: Session[] = Object.values(this.sessions[appName][userId]).map( + (session) => createSession({ id: session.id, appName: session.appName, @@ -150,10 +176,54 @@ export class InMemorySessionService extends BaseSessionService { events: [], lastUpdateTime: session.lastUpdateTime, }), + ); + + if (order === 'asc') { + all.sort( + (a, b) => + a.lastUpdateTime - b.lastUpdateTime || a.id.localeCompare(b.id), ); + } else if (order === 'desc') { + all.sort( + (a, b) => + b.lastUpdateTime - a.lastUpdateTime || a.id.localeCompare(b.id), + ); + } + + if (limit === undefined) { + const totalItems = all.length; + const sliced = offset ? all.slice(offset) : all; + return Promise.resolve({ + sessions: sliced, + page: 1, + limit: totalItems, + totalItems, + totalPages: totalItems === 0 ? 0 : 1, + }); + } + + const totalItems = all.length; + const totalPages = limit === 0 ? 0 : Math.ceil(totalItems / limit); + + let effectiveOffset: number; + let effectivePage: number; + if (page !== undefined) { + effectiveOffset = (page - 1) * limit; + effectivePage = page; + } else { + effectiveOffset = offset ?? 0; + effectivePage = limit === 0 ? 1 : Math.floor(effectiveOffset / limit) + 1; } - return Promise.resolve({sessions: sessionsWithoutEvents}); + const paginated = all.slice(effectiveOffset, effectiveOffset + limit); + + return Promise.resolve({ + sessions: paginated, + page: effectivePage, + limit, + totalItems, + totalPages, + }); } async deleteSession({ diff --git a/core/test/sessions/database_session_service_test.ts b/core/test/sessions/database_session_service_test.ts index e9c1c4b6..e242b5e7 100644 --- a/core/test/sessions/database_session_service_test.ts +++ b/core/test/sessions/database_session_service_test.ts @@ -376,6 +376,262 @@ describe('DatabaseSessionService', () => { await orm.close(); }); + describe('listSessions pagination and sorting', () => { + const appName = 'test-app'; + const userId = 'test-user'; + + it('no pagination params → returns all sessions with page=1', async () => { + await service.createSession({appName, userId, sessionId: 's1'}); + await service.createSession({appName, userId, sessionId: 's2'}); + + const response = await service.listSessions({appName, userId}); + + expect(response.sessions).toHaveLength(2); + expect(response.page).toBe(1); + expect(response.limit).toBe(2); + expect(response.totalItems).toBe(2); + expect(response.totalPages).toBe(1); + }); + + it('order desc returns newest-first', async () => { + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + const s3 = await service.createSession({ + appName, + userId, + sessionId: 's3', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 1000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 3000}), + }); + await service.appendEvent({ + session: s3, + event: createEvent({timestamp: 2000}), + }); + + const response = await service.listSessions({ + appName, + userId, + order: 'desc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s2', 's3', 's1']); + }); + + it('order asc returns oldest-first', async () => { + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + const s3 = await service.createSession({ + appName, + userId, + sessionId: 's3', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 1000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 3000}), + }); + await service.appendEvent({ + session: s3, + event: createEvent({timestamp: 2000}), + }); + + const response = await service.listSessions({ + appName, + userId, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s1', 's3', 's2']); + }); + + it('limit returns only N sessions with correct metadata', async () => { + for (let i = 1; i <= 5; i++) { + const s = await service.createSession({ + appName, + userId, + sessionId: `s${i}`, + }); + await service.appendEvent({ + session: s, + event: createEvent({timestamp: i * 1000}), + }); + } + + const response = await service.listSessions({ + appName, + userId, + limit: 3, + order: 'asc', + }); + + expect(response.sessions).toHaveLength(3); + expect(response.totalItems).toBe(5); + expect(response.totalPages).toBe(2); + expect(response.limit).toBe(3); + }); + + it('page + limit returns correct slice', async () => { + for (let i = 1; i <= 5; i++) { + const s = await service.createSession({ + appName, + userId, + sessionId: `s${i}`, + }); + await service.appendEvent({ + session: s, + event: createEvent({timestamp: i * 1000}), + }); + } + + const response = await service.listSessions({ + appName, + userId, + page: 2, + limit: 2, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s3', 's4']); + expect(response.page).toBe(2); + expect(response.limit).toBe(2); + expect(response.totalItems).toBe(5); + expect(response.totalPages).toBe(3); + }); + + it('offset skips N sessions', async () => { + for (let i = 1; i <= 4; i++) { + const s = await service.createSession({ + appName, + userId, + sessionId: `s${i}`, + }); + await service.appendEvent({ + session: s, + event: createEvent({timestamp: i * 1000}), + }); + } + + const response = await service.listSessions({ + appName, + userId, + limit: 2, + offset: 2, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s3', 's4']); + }); + + it('offset beyond total → empty sessions with correct metadata', async () => { + await service.createSession({appName, userId, sessionId: 's1'}); + + const response = await service.listSessions({ + appName, + userId, + limit: 2, + offset: 10, + }); + + expect(response.sessions).toEqual([]); + expect(response.totalItems).toBe(1); + expect(response.totalPages).toBe(1); + }); + + it('limit=0 returns empty sessions and totalPages=0', async () => { + await service.createSession({appName, userId, sessionId: 's1'}); + + const response = await service.listSessions({appName, userId, limit: 0}); + + expect(response.sessions).toEqual([]); + expect(response.totalItems).toBe(1); + expect(response.totalPages).toBe(0); + }); + + it('order without limit returns all sessions sorted with page=1', async () => { + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 2000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 1000}), + }); + + const response = await service.listSessions({ + appName, + userId, + order: 'desc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s1', 's2']); + expect(response.page).toBe(1); + expect(response.limit).toBe(2); + expect(response.totalItems).toBe(2); + expect(response.totalPages).toBe(1); + }); + + it('page takes precedence over offset when both are provided', async () => { + for (let i = 1; i <= 5; i++) { + const s = await service.createSession({ + appName, + userId, + sessionId: `s${i}`, + }); + await service.appendEvent({ + session: s, + event: createEvent({timestamp: i * 1000}), + }); + } + + const response = await service.listSessions({ + appName, + userId, + page: 2, + limit: 2, + offset: 0, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s3', 's4']); + expect(response.page).toBe(2); + }); + }); + describe('Alignment Verification', () => { it('should trim temp state from event before persistence', async () => { const session = await service.createSession({ diff --git a/core/test/sessions/in_memory_session_service_test.ts b/core/test/sessions/in_memory_session_service_test.ts index eeed7f4b..6982b71f 100644 --- a/core/test/sessions/in_memory_session_service_test.ts +++ b/core/test/sessions/in_memory_session_service_test.ts @@ -196,6 +196,10 @@ describe('InMemorySessionService', () => { userId: 'user', }); expect(response.sessions).toEqual([]); + expect(response.page).toBe(1); + expect(response.limit).toBe(0); + expect(response.totalItems).toBe(0); + expect(response.totalPages).toBe(0); }); it('returns list of sessions without events', async () => { @@ -210,6 +214,326 @@ describe('InMemorySessionService', () => { expect(response.sessions[0].events).toEqual([]); expect(response.sessions[1].events).toEqual([]); }); + + it('limit on empty result → returns pagination metadata with zeros', async () => { + const response = await service.listSessions({ + appName: 'app', + userId: 'user', + limit: 10, + }); + expect(response.sessions).toEqual([]); + expect(response.totalItems).toBe(0); + expect(response.totalPages).toBe(0); + expect(response.page).toBe(1); + expect(response.limit).toBe(10); + }); + + it('no pagination params → returns all sessions with page=1', async () => { + const appName = 'app'; + const userId = 'user'; + await service.createSession({appName, userId}); + await service.createSession({appName, userId}); + + const response = await service.listSessions({appName, userId}); + + expect(response.page).toBe(1); + expect(response.limit).toBe(2); + expect(response.totalItems).toBe(2); + expect(response.totalPages).toBe(1); + }); + + it('order asc returns oldest-first', async () => { + const appName = 'app'; + const userId = 'user'; + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + const s3 = await service.createSession({ + appName, + userId, + sessionId: 's3', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 3000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 1000}), + }); + await service.appendEvent({ + session: s3, + event: createEvent({timestamp: 2000}), + }); + + const response = await service.listSessions({ + appName, + userId, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s2', 's3', 's1']); + }); + + it('order desc returns newest-first', async () => { + const appName = 'app'; + const userId = 'user'; + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + const s3 = await service.createSession({ + appName, + userId, + sessionId: 's3', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 3000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 1000}), + }); + await service.appendEvent({ + session: s3, + event: createEvent({timestamp: 2000}), + }); + + const response = await service.listSessions({ + appName, + userId, + order: 'desc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s1', 's3', 's2']); + }); + + it('tie-breaking by id when lastUpdateTime values are equal', async () => { + const appName = 'app'; + const userId = 'user'; + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 1000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 1000}), + }); + + const asc = await service.listSessions({appName, userId, order: 'asc'}); + expect(asc.sessions.map((s) => s.id)).toEqual(['s1', 's2']); + + const desc = await service.listSessions({appName, userId, order: 'desc'}); + expect(desc.sessions.map((s) => s.id)).toEqual(['s1', 's2']); + }); + + it('limit returns only N sessions', async () => { + const appName = 'app'; + const userId = 'user'; + for (let i = 1; i <= 5; i++) { + await service.createSession({appName, userId, sessionId: `s${i}`}); + } + + const response = await service.listSessions({ + appName, + userId, + limit: 3, + order: 'asc', + }); + + expect(response.sessions).toHaveLength(3); + expect(response.totalItems).toBe(5); + expect(response.totalPages).toBe(2); + }); + + it('offset skips N sessions', async () => { + const appName = 'app'; + const userId = 'user'; + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + const s3 = await service.createSession({ + appName, + userId, + sessionId: 's3', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 1000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 2000}), + }); + await service.appendEvent({ + session: s3, + event: createEvent({timestamp: 3000}), + }); + + const response = await service.listSessions({ + appName, + userId, + limit: 2, + offset: 1, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s2', 's3']); + }); + + it('page + limit returns correct slice', async () => { + const appName = 'app'; + const userId = 'user'; + for (let i = 1; i <= 5; i++) { + const s = await service.createSession({ + appName, + userId, + sessionId: `s${i}`, + }); + await service.appendEvent({ + session: s, + event: createEvent({timestamp: i * 1000}), + }); + } + + const response = await service.listSessions({ + appName, + userId, + page: 2, + limit: 2, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s3', 's4']); + expect(response.page).toBe(2); + expect(response.limit).toBe(2); + expect(response.totalItems).toBe(5); + expect(response.totalPages).toBe(3); + }); + + it('offset beyond total → empty sessions with correct metadata', async () => { + const appName = 'app'; + const userId = 'user'; + await service.createSession({appName, userId, sessionId: 's1'}); + + const response = await service.listSessions({ + appName, + userId, + limit: 2, + offset: 10, + }); + + expect(response.sessions).toEqual([]); + expect(response.totalItems).toBe(1); + expect(response.totalPages).toBe(1); + }); + + it('limit=0 returns empty sessions and totalPages=0', async () => { + const appName = 'app'; + const userId = 'user'; + await service.createSession({appName, userId, sessionId: 's1'}); + + const response = await service.listSessions({appName, userId, limit: 0}); + + expect(response.sessions).toEqual([]); + expect(response.totalItems).toBe(1); + expect(response.totalPages).toBe(0); + }); + + it('order without limit returns all sessions sorted with page=1', async () => { + const appName = 'app'; + const userId = 'user'; + const s1 = await service.createSession({ + appName, + userId, + sessionId: 's1', + }); + const s2 = await service.createSession({ + appName, + userId, + sessionId: 's2', + }); + await service.appendEvent({ + session: s1, + event: createEvent({timestamp: 2000}), + }); + await service.appendEvent({ + session: s2, + event: createEvent({timestamp: 1000}), + }); + + const response = await service.listSessions({ + appName, + userId, + order: 'desc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s1', 's2']); + expect(response.page).toBe(1); + expect(response.limit).toBe(2); + expect(response.totalItems).toBe(2); + expect(response.totalPages).toBe(1); + }); + + it('page takes precedence over offset when both are provided', async () => { + const appName = 'app'; + const userId = 'user'; + for (let i = 1; i <= 5; i++) { + const s = await service.createSession({ + appName, + userId, + sessionId: `s${i}`, + }); + await service.appendEvent({ + session: s, + event: createEvent({timestamp: i * 1000}), + }); + } + + // page=2, limit=2 → sessions 3,4; offset=0 should be ignored + const response = await service.listSessions({ + appName, + userId, + page: 2, + limit: 2, + offset: 0, + order: 'asc', + }); + + expect(response.sessions.map((s) => s.id)).toEqual(['s3', 's4']); + expect(response.page).toBe(2); + }); }); describe('deleteSession', () => { From dcde2db8f94411ea619a282ae875cec3a8e8bab1 Mon Sep 17 00:00:00 2001 From: adwibha Date: Tue, 12 May 2026 18:39:09 -0500 Subject: [PATCH 2/2] fix: apply pagination and sorting to VertexAiSessionService.listSessions --- .../src/sessions/vertex_ai_session_service.ts | 49 +++- .../vertex_ai_session_service_test.ts | 234 ++++++++++++++++++ 2 files changed, 282 insertions(+), 1 deletion(-) diff --git a/core/src/sessions/vertex_ai_session_service.ts b/core/src/sessions/vertex_ai_session_service.ts index 03ebdc59..ca78877e 100644 --- a/core/src/sessions/vertex_ai_session_service.ts +++ b/core/src/sessions/vertex_ai_session_service.ts @@ -244,6 +244,10 @@ export class VertexAiSessionService extends BaseSessionService { async listSessions({ appName, userId, + limit, + offset, + page, + order, }: ListSessionsRequest): Promise { const reasoningEngineId = this.getReasoningEngineId(appName); const adkSessions: Session[] = []; @@ -278,7 +282,50 @@ export class VertexAiSessionService extends BaseSessionService { pageToken = (response as {nextPageToken?: string}).nextPageToken; } while (pageToken); - return {sessions: adkSessions}; + if (order === 'asc') { + adkSessions.sort( + (a, b) => + a.lastUpdateTime - b.lastUpdateTime || a.id.localeCompare(b.id), + ); + } else if (order === 'desc') { + adkSessions.sort( + (a, b) => + b.lastUpdateTime - a.lastUpdateTime || a.id.localeCompare(b.id), + ); + } + + if (limit === undefined) { + const totalItems = adkSessions.length; + const sliced = offset ? adkSessions.slice(offset) : adkSessions; + return { + sessions: sliced, + page: 1, + limit: totalItems, + totalItems, + totalPages: totalItems === 0 ? 0 : 1, + }; + } + + const totalItems = adkSessions.length; + const totalPages = limit === 0 ? 0 : Math.ceil(totalItems / limit); + + let effectiveOffset: number; + let effectivePage: number; + if (page !== undefined) { + effectiveOffset = (page - 1) * limit; + effectivePage = page; + } else { + effectiveOffset = offset ?? 0; + effectivePage = limit === 0 ? 1 : Math.floor(effectiveOffset / limit) + 1; + } + + return { + sessions: adkSessions.slice(effectiveOffset, effectiveOffset + limit), + page: effectivePage, + limit, + totalItems, + totalPages, + }; } async deleteSession({ diff --git a/core/test/sessions/vertex_ai_session_service_test.ts b/core/test/sessions/vertex_ai_session_service_test.ts index 68f9e62d..3809d5c9 100644 --- a/core/test/sessions/vertex_ai_session_service_test.ts +++ b/core/test/sessions/vertex_ai_session_service_test.ts @@ -553,6 +553,240 @@ describe('VertexAiSessionService', () => { new Date('2026-04-09T13:00:00Z').getTime(), ); }); + + it('returns pagination metadata with page=1 and totalPages=1 for non-empty result', async () => { + mockClient.listInternal.mockResolvedValue({ + sessions: [ + {name: 'projects/p/locations/l/sessions/s1', userId: 'testUser'}, + {name: 'projects/p/locations/l/sessions/s2', userId: 'testUser'}, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + }); + + expect(result.page).toBe(1); + expect(result.limit).toBe(2); + expect(result.totalItems).toBe(2); + expect(result.totalPages).toBe(1); + }); + + it('returns pagination metadata with totalPages=0 for empty result', async () => { + mockClient.listInternal.mockResolvedValue({}); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + }); + + expect(result.sessions).toEqual([]); + expect(result.page).toBe(1); + expect(result.limit).toBe(0); + expect(result.totalItems).toBe(0); + expect(result.totalPages).toBe(0); + }); + + it('aggregates multi-page API responses into a single result with correct metadata', async () => { + mockClient.listInternal + .mockResolvedValueOnce({ + sessions: [ + {name: 'projects/p/locations/l/sessions/s1', userId: 'testUser'}, + ], + nextPageToken: 'token-page-2', + }) + .mockResolvedValueOnce({ + sessions: [ + {name: 'projects/p/locations/l/sessions/s2', userId: 'testUser'}, + {name: 'projects/p/locations/l/sessions/s3', userId: 'testUser'}, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + }); + + expect(result.sessions).toHaveLength(3); + expect(result.page).toBe(1); + expect(result.limit).toBe(3); + expect(result.totalItems).toBe(3); + expect(result.totalPages).toBe(1); + }); + + it('order asc sorts sessions by lastUpdateTime ascending', async () => { + mockClient.listInternal.mockResolvedValue({ + sessions: [ + { + name: 'projects/p/locations/l/sessions/s3', + userId: 'testUser', + updateTime: '2026-01-03T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s1', + userId: 'testUser', + updateTime: '2026-01-01T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s2', + userId: 'testUser', + updateTime: '2026-01-02T00:00:00Z', + }, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + order: 'asc', + }); + + expect(result.sessions.map((s) => s.id)).toEqual(['s1', 's2', 's3']); + }); + + it('order desc sorts sessions by lastUpdateTime descending', async () => { + mockClient.listInternal.mockResolvedValue({ + sessions: [ + { + name: 'projects/p/locations/l/sessions/s1', + userId: 'testUser', + updateTime: '2026-01-01T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s3', + userId: 'testUser', + updateTime: '2026-01-03T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s2', + userId: 'testUser', + updateTime: '2026-01-02T00:00:00Z', + }, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + order: 'desc', + }); + + expect(result.sessions.map((s) => s.id)).toEqual(['s3', 's2', 's1']); + }); + + it('limit returns correct slice and metadata', async () => { + mockClient.listInternal.mockResolvedValue({ + sessions: [ + { + name: 'projects/p/locations/l/sessions/s1', + userId: 'testUser', + updateTime: '2026-01-01T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s2', + userId: 'testUser', + updateTime: '2026-01-02T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s3', + userId: 'testUser', + updateTime: '2026-01-03T00:00:00Z', + }, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + limit: 2, + order: 'asc', + }); + + expect(result.sessions.map((s) => s.id)).toEqual(['s1', 's2']); + expect(result.totalItems).toBe(3); + expect(result.totalPages).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(2); + }); + + it('page + limit returns correct slice', async () => { + mockClient.listInternal.mockResolvedValue({ + sessions: [ + { + name: 'projects/p/locations/l/sessions/s1', + userId: 'testUser', + updateTime: '2026-01-01T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s2', + userId: 'testUser', + updateTime: '2026-01-02T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s3', + userId: 'testUser', + updateTime: '2026-01-03T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s4', + userId: 'testUser', + updateTime: '2026-01-04T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s5', + userId: 'testUser', + updateTime: '2026-01-05T00:00:00Z', + }, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + page: 2, + limit: 2, + order: 'asc', + }); + + expect(result.sessions.map((s) => s.id)).toEqual(['s3', 's4']); + expect(result.page).toBe(2); + expect(result.limit).toBe(2); + expect(result.totalItems).toBe(5); + expect(result.totalPages).toBe(3); + }); + + it('offset skips sessions correctly', async () => { + mockClient.listInternal.mockResolvedValue({ + sessions: [ + { + name: 'projects/p/locations/l/sessions/s1', + userId: 'testUser', + updateTime: '2026-01-01T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s2', + userId: 'testUser', + updateTime: '2026-01-02T00:00:00Z', + }, + { + name: 'projects/p/locations/l/sessions/s3', + userId: 'testUser', + updateTime: '2026-01-03T00:00:00Z', + }, + ], + }); + + const result = await service.listSessions({ + appName: '12345', + userId: 'testUser', + limit: 2, + offset: 1, + order: 'asc', + }); + + expect(result.sessions.map((s) => s.id)).toEqual(['s2', 's3']); + }); }); describe('deleteSession', () => {