diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 3e6daffb4..070263a1e 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -7,6 +7,7 @@ import { type DaemonTestContext, } from "./test-utils/index.js"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; +import type { AgentSnapshotPayload } from "./messages.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); @@ -570,6 +571,143 @@ describe("daemon E2E", () => { ); }); + describe("cancelAgent", () => { + test( + "cancels a running agent mid-execution", + async () => { + const cwd = tmpCwd(); + + // Create Codex agent + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Cancel Test Agent", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Clear message queue before sending prompt + ctx.client.clearMessageQueue(); + + // Send a prompt that triggers a long-running operation + await ctx.client.sendMessage(agent.id, "Run: sleep 30"); + + // Wait for the agent to start running (tool call starts) + let sawRunning = false; + const startPosition = ctx.client.getMessageQueue().length; + + // Wait for running state + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for agent to start running")); + }, 30000); + + const checkForRunning = (): void => { + const queue = ctx.client.getMessageQueue(); + for (let i = startPosition; i < queue.length; i++) { + const msg = queue[i]; + if (msg.type === "agent_state" && msg.payload.id === agent.id) { + if (msg.payload.status === "running") { + sawRunning = true; + clearTimeout(timeout); + resolve(); + return; + } + } + } + }; + + // Check periodically + const interval = setInterval(checkForRunning, 50); + const cleanup = (): void => { + clearInterval(interval); + clearTimeout(timeout); + }; + + // Override reject to cleanup + const originalReject = reject; + reject = (err): void => { + cleanup(); + originalReject(err); + }; + }); + + expect(sawRunning).toBe(true); + + // Record timestamp before cancel + const cancelStart = Date.now(); + + // Cancel the agent + await ctx.client.cancelAgent(agent.id); + + // Wait for agent to reach idle or error state + const finalState = await new Promise( + (resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error( + "Timeout waiting for agent to stop after cancel (>2 seconds)" + ) + ); + }, 5000); // Give extra margin, but test should complete in 2s + + const queueStart = ctx.client.getMessageQueue().length; + const checkForStopped = (): void => { + const queue = ctx.client.getMessageQueue(); + for (let i = queueStart; i < queue.length; i++) { + const msg = queue[i]; + if (msg.type === "agent_state" && msg.payload.id === agent.id) { + if ( + msg.payload.status === "idle" || + msg.payload.status === "error" + ) { + clearTimeout(timeout); + clearInterval(interval); + resolve(msg.payload); + return; + } + } + } + }; + + const interval = setInterval(checkForStopped, 50); + } + ); + + // Calculate how long the cancel took + const cancelDuration = Date.now() - cancelStart; + + // Verify agent stopped within reasonable time (2 seconds) + expect(cancelDuration).toBeLessThan(2000); + + // Verify agent is now idle or error + expect(["idle", "error"]).toContain(finalState.status); + + // Verify no zombie sleep processes left (check for sleep 30) + const { execSync } = await import("child_process"); + try { + const result = execSync("pgrep -f 'sleep 30'", { + encoding: "utf8", + timeout: 2000, + }); + // If pgrep succeeds, there are zombie processes + if (result.trim()) { + // Kill them and fail the test + execSync("pkill -f 'sleep 30'"); + expect.fail("Found zombie sleep processes after cancel"); + } + } catch { + // pgrep returns non-zero when no processes found - this is expected + } + + // Cleanup + rmSync(cwd, { recursive: true, force: true }); + }, + 60000 + ); + }); + // 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/plan.md b/plan.md index 976c06eeb..93e3fdd81 100644 --- a/plan.md +++ b/plan.md @@ -1082,7 +1082,7 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov - Agent list order reflects actual last interaction time - **Done (2025-12-25 17:42)**: WHAT: (1) Bug was already fixed in commit `7b29978` (Dec 2, 2025) which removed timestamp thrashing - previously `lastActivityAt`/`updatedAt` was updated on every `agent_stream` event (15+ times/second during streaming); (2) Added `initializeAgent()` and `clearAgentAttention()` methods to `packages/server/src/server/test-utils/daemon-client.ts:268-304`; (3) Added E2E tests in `packages/server/src/server/daemon.e2e.test.ts:482-570` for timestamp behavior - "opening agent without interaction does not update timestamp" and "sending message DOES update timestamp". RESULT: Bug verified as fixed - clicking/opening agent does NOT update timestamp, only actual interactions (sending messages) update it. Server only sets `agent.updatedAt` in `recordUserMessage` (`packages/server/src/server/agent/agent-manager.ts:436`) and `handleStreamEvent` (`packages/server/src/server/agent/agent-manager.ts:864`), not in `clearAgentAttention` or `initializeAgent` flows. EVIDENCE: `npm run test --workspace=@paseo/server -- daemon.e2e.test.ts -t "timestamp"` (2 passed, 7 skipped in 9.08s), Playwright test showed agent stayed in position 4 with unchanged timestamp after clicking. -- [ ] **Test**: Add daemon E2E test for `cancelAgent()`. +- [x] **Test**: Add daemon E2E test for `cancelAgent()`. Cancel an agent mid-execution and verify it stops properly. @@ -1098,6 +1098,7 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov - Test passes - Agent stops within reasonable time after cancel - DaemonClient `cancelAgent()` method verified working + - **Done (2025-12-25 17:44)**: WHAT: Added E2E test in `packages/server/src/server/daemon.e2e.test.ts:573-708` that creates a Codex agent, sends "Run: sleep 30" to trigger a long-running operation, waits for "running" status, calls `cancelAgent(agentId)`, verifies the agent stops within 2 seconds, and checks for no zombie "sleep 30" processes. RESULT: Test passes - agent cancel request is received, turn_failed is emitted, and agent becomes idle/error within milliseconds (test completed in 291ms). EVIDENCE: `npm run test --workspace=@paseo/server -- daemon.e2e.test.ts -t "cancelAgent"` (1 passed, 9 skipped in 846ms). - [ ] **Test**: Add daemon E2E test for `setAgentMode()`.