diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 066dc3ee9..aaf0ab67e 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -820,6 +820,86 @@ describe("daemon E2E", () => { ); }); + describe("listAgents", () => { + test( + "returns current agents and reflects create/delete operations", + async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + + // Initially, there should be no agents (fresh session) + const initialAgents = ctx.client.listAgents(); + expect(initialAgents).toHaveLength(0); + + // Create first agent + const agent1 = await ctx.client.createAgent({ + provider: "codex", + cwd: cwd1, + title: "List Test Agent 1", + }); + + expect(agent1.id).toBeTruthy(); + expect(agent1.status).toBe("idle"); + + // listAgents should now return 1 agent + const afterFirst = ctx.client.listAgents(); + expect(afterFirst).toHaveLength(1); + expect(afterFirst[0].id).toBe(agent1.id); + // Title may or may not be set depending on timing + expect(afterFirst[0].cwd).toBe(cwd1); + + // Create second agent + const agent2 = await ctx.client.createAgent({ + provider: "codex", + cwd: cwd2, + title: "List Test Agent 2", + }); + + expect(agent2.id).toBeTruthy(); + expect(agent2.status).toBe("idle"); + + // listAgents should now return 2 agents + const afterSecond = ctx.client.listAgents(); + expect(afterSecond).toHaveLength(2); + + // Verify both agents are present with correct IDs and states + const ids = afterSecond.map((a) => a.id); + expect(ids).toContain(agent1.id); + expect(ids).toContain(agent2.id); + + const agent1State = afterSecond.find((a) => a.id === agent1.id); + const agent2State = afterSecond.find((a) => a.id === agent2.id); + + // Title may or may not be set depending on timing + expect(agent1State?.cwd).toBe(cwd1); + expect(agent1State?.status).toBe("idle"); + + // Title may or may not be set depending on timing + expect(agent2State?.cwd).toBe(cwd2); + expect(agent2State?.status).toBe("idle"); + + // Delete first agent + await ctx.client.deleteAgent(agent1.id); + + // listAgents should now return only 1 agent + const afterDelete = ctx.client.listAgents(); + expect(afterDelete).toHaveLength(1); + expect(afterDelete[0].id).toBe(agent2.id); + expect(afterDelete[0].cwd).toBe(cwd2); + + // Verify agent1 is no longer in the list + const deletedAgent = afterDelete.find((a) => a.id === agent1.id); + expect(deletedAgent).toBeUndefined(); + + // Cleanup + await ctx.client.deleteAgent(agent2.id); + rmSync(cwd1, { recursive: true, force: true }); + rmSync(cwd2, { recursive: true, force: true }); + }, + 60000 // 1 minute timeout + ); + }); + // Claude permission tests are skipped due to SDK behavior: // - The sandbox config IS passed correctly to Claude SDK // - Claude executes tool calls without requesting permission diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index f3e7320b1..0ccbb6fe4 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -142,6 +142,10 @@ export class DaemonClient { async createAgent(options: CreateAgentOptions): Promise { const requestId = nanoid(); + + // Record the current queue position so we only check NEW messages + const startPosition = this.messageQueue.length; + this.send({ type: "create_agent_request", requestId, @@ -157,31 +161,39 @@ export class DaemonClient { initialPrompt: options.initialPrompt, }); - // First get the agent ID from the initial state + // First get the agent ID from the initial state (only check new messages) let agentId: string | null = null; - await this.waitFor((msg) => { - if (msg.type === "agent_state") { - agentId = msg.payload.id; - return msg.payload; - } - return null; - }, 10000); + await this.waitFor( + (msg) => { + if (msg.type === "agent_state") { + agentId = msg.payload.id; + return msg.payload; + } + return null; + }, + 10000, + { skipQueueBefore: startPosition } + ); if (!agentId) { throw new Error("Failed to get agent ID from create response"); } - // Wait for the agent to be idle - return this.waitFor((msg) => { - if ( - msg.type === "agent_state" && - msg.payload.id === agentId && - msg.payload.status === "idle" - ) { - return msg.payload; - } - return null; - }, 60000); // 60 second timeout for initialization + // Wait for the agent to be idle (only check new messages from startPosition) + return this.waitFor( + (msg) => { + if ( + msg.type === "agent_state" && + msg.payload.id === agentId && + msg.payload.status === "idle" + ) { + return msg.payload; + } + return null; + }, + 60000, + { skipQueueBefore: startPosition } + ); // 60 second timeout for initialization } async deleteAgent(agentId: string): Promise { @@ -194,14 +206,36 @@ export class DaemonClient { }); } - async listAgents(): Promise { - // session_state is sent on connection, or we can wait for it - return this.waitFor((msg) => { + /** + * Returns the current list of agents by analyzing the message queue. + * This computes the latest state by: + * 1. Starting with agents from session_state (if any) + * 2. Updating with agent_state messages + * 3. Removing agents that have agent_deleted events + */ + listAgents(): AgentSnapshotPayload[] { + const agentMap = new Map(); + const deletedAgents = new Set(); + + for (const msg of this.messageQueue) { if (msg.type === "session_state") { - return msg.payload.agents; + // Initial agents from session state + for (const agent of msg.payload.agents) { + agentMap.set(agent.id, agent); + } + } else if (msg.type === "agent_state") { + // Update or add agent from state event + agentMap.set(msg.payload.id, msg.payload); + } else if (msg.type === "agent_deleted") { + // Mark agent as deleted + deletedAgents.add(msg.payload.agentId); } - return null; - }); + } + + // Filter out deleted agents and return + return Array.from(agentMap.values()).filter( + (agent) => !deletedAgents.has(agent.id) + ); } async listPersistedAgents(): Promise { diff --git a/plan.md b/plan.md index 14a6f57ec..d2df0dc9b 100644 --- a/plan.md +++ b/plan.md @@ -1117,7 +1117,7 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov - DaemonClient `setAgentMode()` method verified working - **Done (2025-12-25 17:48)**: WHAT: Added E2E test in `packages/server/src/server/daemon.e2e.test.ts:711-821` that creates Codex agent, verifies initial mode is "auto", switches to "read-only", verifies mode persists after sending a message, and switches to "full-access". FIXED BUG: `packages/server/src/server/agent/providers/codex-mcp-agent.ts:2763-2773` - `setMode()` was not updating `cachedRuntimeInfo`, so `getRuntimeInfo()` returned stale `modeId`. Fix: update `cachedRuntimeInfo.modeId` when mode changes. RESULT: Test passes - mode switch reflects in both `currentModeId` and `runtimeInfo.modeId`, persists across messages. EVIDENCE: `npm run test --workspace=@paseo/server -- daemon.e2e.test.ts -t "setAgentMode"` (1 passed, 10 skipped in 4154ms). -- [ ] **Test**: Add daemon E2E test for `listAgents()`. +- [x] **Test**: Add daemon E2E test for `listAgents()`. Verify session state returns current agents. @@ -1134,6 +1134,7 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov - Test passes - Agent list accurate after create/delete operations - DaemonClient `listAgents()` method verified working + - **Done (2025-12-25 17:53)**: WHAT: Added E2E test in `packages/server/src/server/daemon.e2e.test.ts:823-901` that creates two agents, calls `listAgents()` to verify both appear, deletes one, and verifies only the remaining agent is returned. Also updated `listAgents()` in `packages/server/src/server/test-utils/daemon-client.ts:197-227` to compute current agent list by processing `session_state`, `agent_state`, and `agent_deleted` messages. FIXED BUG: `packages/server/src/server/test-utils/daemon-client.ts:143-197` - `createAgent()` was not using `skipQueueBefore` option, causing second agent creation to match stale messages from the first agent. Fix: track queue position before sending request and only check new messages. RESULT: Test passes - listAgents correctly reflects agents after create/delete operations. EVIDENCE: `npm run test --workspace=@paseo/server -- daemon.e2e.test.ts -t "listAgents"` (1 passed, 11 skipped in 254ms). - [ ] **Investigate**: Claude provider permissions don't work in daemon E2E tests.