diff --git a/packages/cli/src/commands/agent/ls.test.ts b/packages/cli/src/commands/agent/ls.test.ts index 8aedf73fad..76b1787ce3 100644 --- a/packages/cli/src/commands/agent/ls.test.ts +++ b/packages/cli/src/commands/agent/ls.test.ts @@ -1,5 +1,60 @@ import { describe, expect, it } from "vitest"; -import { buildAgentLsFetchOptions } from "./ls.js"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { buildAgentLsFetchOptions, listAgents } from "./ls.js"; + +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + +function createPagingClient() { + const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({ + id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`, + provider: "codex", + title: `agent ${n}`, + status: "closed", + archivedAt: null, + cwd: "/tmp/project", + createdAt: `2026-10-0${n}T00:00:00.000Z`, + updatedAt: `2026-10-0${n}T00:00:00.000Z`, + labels: {}, + model: null, + lastUserMessageAt: null, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + })); + + // The fake daemon pages two agents at a time, newest first by the requested key + // (updated_at by default), and agent 1 is updated right after the first page is served. + const fetchAgents = async (options?: { + sort?: { key: string }[]; + page?: { cursor?: string }; + }) => { + const key: "createdAt" | "updatedAt" = + options?.sort?.[0]?.key === "created_at" ? "createdAt" : "updatedAt"; + const cursor = options?.page?.cursor; + const ordered = daemonAgents + .filter((agent) => cursor === undefined || agent[key] < cursor) + .sort((left, right) => right[key].localeCompare(left[key])); + const page = ordered.slice(0, 2); + const hasMore = ordered.length > 2; + if (cursor === undefined) { + daemonAgents[2]!.updatedAt = "2026-10-09T00:00:00.000Z"; + } + return { + entries: page.map((agent) => ({ agent })), + pageInfo: { nextCursor: hasMore ? page[page.length - 1]![key] : null }, + }; + }; + return { fetchAgents, close: async () => {} }; +} describe("buildAgentLsFetchOptions", () => { it("fetches active agents by default", () => { @@ -66,3 +121,13 @@ describe("buildAgentLsFetchOptions", () => { }); }); }); + +describe("runLsCommand", () => { + it("lists agents on every page, including one updated during the listing", async () => { + const result = await listAgents({ daemonTarget, all: true, global: true }, async () => + createPagingClient(), + ); + + expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]); + }); +}); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 0990446bc5..55674a2f87 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -165,6 +165,14 @@ export function buildAgentLsFetchOptions( return fetchOptions; } +interface AgentLsClient { + fetchAgents(options?: FetchAgentsOptions): Promise<{ + entries: { agent: AgentSnapshotPayload }[]; + pageInfo: { nextCursor: string | null }; + }>; + close(): Promise; +} + /** * Agent ls command semantics: * - `paseo agent ls` → active non-archived agents @@ -176,7 +184,16 @@ export async function runLsCommand( options: AgentLsOptions, _command: Command, ): Promise { - const client = await connectToDaemon({ target: options.daemonTarget }); + return listAgents(options); +} + +export async function listAgents( + options: AgentLsOptions, + connect: ( + options: Parameters[0], + ) => Promise = connectToDaemon, +): Promise { + const client = await connect({ target: options.daemonTarget }); try { const normalizedThinkingOptionId = options.thinking?.trim(); @@ -189,8 +206,19 @@ export async function runLsCommand( } const labelFilters = parseLabelFilters(options.label); - const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options)); - let agents = fetchPayload.entries.map((entry) => entry.agent); + const fetchOptions = buildAgentLsFetchOptions(options); + let agents: AgentSnapshotPayload[] = []; + let cursor: string | undefined; + // createdAt never changes, so agents cannot move past the cursor while the pages are read. + do { + const payload = await client.fetchAgents({ + ...fetchOptions, + sort: [{ key: "created_at", direction: "desc" }], + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push(...payload.entries.map((entry) => entry.agent)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); // By default, exclude archived agents. `-a` includes them. if (!options.all) {