Add daemon E2E test for cancelAgent()

Test verifies:
- Agent cancel request is processed correctly
- Agent reaches idle/error state within 2 seconds
- No zombie processes left after cancel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-25 17:44:52 +07:00
parent 6ba5c466a1
commit 5ce0a742e7
2 changed files with 140 additions and 1 deletions

View File

@@ -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<void>((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<AgentSnapshotPayload>(
(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

View File

@@ -1082,7 +1082,7 @@ Build a new Codex MCP provider sidebyside 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 sidebyside 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()`.