diff --git a/packages/cli/src/lib/api/conversations.ts b/packages/cli/src/lib/api/conversations.ts index 1dbafa69b..e18c4e985 100644 --- a/packages/cli/src/lib/api/conversations.ts +++ b/packages/cli/src/lib/api/conversations.ts @@ -26,7 +26,9 @@ import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { + API_MAX_PER_PAGE, apiRequestToRegion, + autoPaginate, MAX_PAGINATION_PAGES, type PaginatedResponse, parseLinkHeader, @@ -34,22 +36,27 @@ import { const log = logger.withTag("api.conversations"); -export async function listConversations( +/** + * Fetch a single page of conversations from the AI-conversations endpoint. + * + * Internal helper used by {@link listConversations} for both single-page and + * multi-page (auto-paginating) fetches. + */ +async function fetchConversationsPage( + regionUrl: string, orgSlug: string, options: { query?: string; - limit?: number; cursor?: string; statsPeriod?: string; start?: string; end?: string; project?: string; - } = {} + }, + perPage: number ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); - const params: Record = { - per_page: String(options.limit ?? 10), + per_page: String(perPage), }; if (options.statsPeriod) { params.statsPeriod = options.statsPeriod; @@ -81,6 +88,45 @@ export async function listConversations( return { data, nextCursor }; } +/** + * List AI conversations for an organization. + * + * When `limit` exceeds {@link API_MAX_PER_PAGE}, transparently fetches multiple + * pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}). + * + * @param orgSlug - Organization slug + * @param options - Query options (query, limit, cursor, statsPeriod, etc.) + * @returns Paginated response with conversation items and optional next cursor + */ +export async function listConversations( + orgSlug: string, + options: { + query?: string; + limit?: number; + cursor?: string; + statsPeriod?: string; + start?: string; + end?: string; + project?: string; + } = {} +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const limit = options.limit ?? 10; + const perPage = Math.min(limit, API_MAX_PER_PAGE); + + return autoPaginate( + (cursor) => + fetchConversationsPage( + regionUrl, + orgSlug, + { ...options, cursor }, + perPage + ), + limit, + options.cursor + ); +} + export async function getConversationSpans( orgSlug: string, conversationId: string, diff --git a/packages/cli/test/lib/api/conversations.test.ts b/packages/cli/test/lib/api/conversations.test.ts index 8b7449768..ee68f88c7 100644 --- a/packages/cli/test/lib/api/conversations.test.ts +++ b/packages/cli/test/lib/api/conversations.test.ts @@ -236,6 +236,46 @@ describe("listConversations", () => { expect(result.data).toHaveLength(1); expect(result.data[0].conversationId).toBe("conv-abc"); }); + + test("caps per_page at API_MAX_PER_PAGE when limit exceeds it", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { limit: 200 }); + + expect(getCapturedUrl()).toContain("per_page=100"); + expect(getCapturedUrl()).not.toContain("per_page=200"); + }); + + test("accumulates results across pages when limit > API_MAX_PER_PAGE", async () => { + const item = (id: string) => ({ + conversationId: id, + flow: [], + errors: 0, + llmCalls: 0, + toolCalls: 0, + totalTokens: 0, + totalCost: 0, + startTimestamp: 1_716_500_000, + endTimestamp: 1_716_500_060, + traceCount: 0, + traceIds: [], + firstInput: "", + lastOutput: "", + toolNames: [], + toolErrors: 0, + }); + + const { getCapturedUrls } = mockSequential([ + { body: [item("c1")], headers: linkHeader("cursor1") }, + { body: [item("c2")], headers: linkHeader("cursor2", "false") }, + ]); + + const result = await listConversations(ORG, { limit: 200 }); + + expect(result.data).toHaveLength(2); + expect(result.data.map((c) => c.conversationId)).toEqual(["c1", "c2"]); + expect(getCapturedUrls()[0]).toContain("per_page=100"); + }); }); // ============================================================================