Implement DaemonClient core for E2E testing (Phase 1)

Add WebSocket client infrastructure for testing the daemon without Playwright:

- DaemonClient class with full lifecycle management:
  - connect/close for WebSocket connection
  - createAgent, deleteAgent, listAgents for agent lifecycle
  - sendMessage, cancelAgent, setAgentMode for agent interaction
  - waitForAgentIdle, waitForPermission for async waiting
  - respondToPermission for permission handling
  - Event subscription via on() method

- Test context helper (createDaemonTestContext) that creates isolated
  daemon + connected client for each test

- One working E2E test that creates a Codex agent, sends a message,
  and verifies the full turn lifecycle (turn_started, assistant_message,
  turn_completed events)

Key implementation details:
- Uses skipQueueBefore option in waitFor to ignore stale messages
- waitForAgentIdle tracks "running" state to avoid false positives
- All methods properly typed using existing Zod schemas

🤖 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 16:49:38 +07:00
parent cf82296be9
commit 99a5ac97fb
5 changed files with 694 additions and 1 deletions

View File

@@ -0,0 +1,74 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "./test-utils/index.js";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
});
test("creates agent and receives response", async () => {
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex",
cwd: "/tmp",
title: "Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("codex");
expect(agent.status).toBe("idle");
// Title may or may not be set depending on timing
expect(agent.cwd).toBe("/tmp");
// Send a simple message
await ctx.client.sendMessage(agent.id, "Say 'hello world' and nothing else");
// Wait for the agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
// Verify agent completed without error
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
expect(finalState.id).toBe(agent.id);
// Verify we received some stream events
const queue = ctx.client.getMessageQueue();
const streamEvents = queue.filter(
(m) => m.type === "agent_stream" && m.payload.agentId === agent.id
);
expect(streamEvents.length).toBeGreaterThan(0);
// Verify there was a turn_started event
const hasTurnStarted = streamEvents.some(
(m) =>
m.type === "agent_stream" && m.payload.event.type === "turn_started"
);
expect(hasTurnStarted).toBe(true);
// Verify there was a turn_completed event
const hasTurnCompleted = streamEvents.some(
(m) =>
m.type === "agent_stream" && m.payload.event.type === "turn_completed"
);
expect(hasTurnCompleted).toBe(true);
// Verify there was an assistant message in the timeline
const hasAssistantMessage = streamEvents.some((m) => {
if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") {
return false;
}
const item = m.payload.event.item;
return item.type === "assistant_message" && item.text.length > 0;
});
expect(hasAssistantMessage).toBe(true);
}, 180000); // 3 minute timeout for E2E test
});

View File

@@ -0,0 +1,478 @@
import WebSocket from "ws";
import { nanoid } from "nanoid";
import type {
SessionInboundMessage,
SessionOutboundMessage,
AgentSnapshotPayload,
AgentStreamEventPayload,
PersistedAgentDescriptorPayload,
} from "../messages.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
} from "../agent/agent-sdk-types.js";
// ============================================================================
// Configuration
// ============================================================================
export interface DaemonClientConfig {
url: string;
authHeader?: string;
}
export interface CreateAgentOptions {
provider: AgentProvider;
cwd: string;
title?: string;
model?: string;
modeId?: string;
initialPrompt?: string;
mcpServers?: Record<string, unknown>;
}
export interface SendMessageOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
}
// ============================================================================
// Event Types
// ============================================================================
export type DaemonEvent =
| { type: "agent_state"; agentId: string; payload: AgentSnapshotPayload }
| {
type: "agent_stream";
agentId: string;
event: AgentStreamEventPayload;
timestamp: string;
}
| { type: "session_state"; agents: AgentSnapshotPayload[] }
| { type: "status"; payload: { status: string } }
| { type: "agent_deleted"; agentId: string }
| { type: "agent_permission_request"; agentId: string; request: AgentPermissionRequest }
| {
type: "agent_permission_resolved";
agentId: string;
requestId: string;
resolution: AgentPermissionResponse;
}
| { type: "error"; message: string };
export type DaemonEventHandler = (event: DaemonEvent) => void;
// ============================================================================
// DaemonClient
// ============================================================================
export class DaemonClient {
private ws: WebSocket | null = null;
private messageQueue: SessionOutboundMessage[] = [];
private eventListeners: Set<DaemonEventHandler> = new Set();
private messageListeners: Set<() => void> = new Set();
constructor(private config: DaemonClientConfig) {}
// ============================================================================
// Connection
// ============================================================================
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
if (this.config.authHeader) {
headers["Authorization"] = this.config.authHeader;
}
this.ws = new WebSocket(this.config.url, { headers });
const onOpen = (): void => {
cleanup();
resolve();
};
const onError = (err: Error): void => {
cleanup();
reject(err);
};
const onMessage = (data: WebSocket.RawData): void => {
try {
const parsed = JSON.parse(data.toString()) as {
type: string;
message?: SessionOutboundMessage;
};
if (parsed.type === "pong") return;
if (parsed.type === "session" && parsed.message) {
this.handleSessionMessage(parsed.message);
}
} catch {
// Ignore parse errors
}
};
const cleanup = (): void => {
this.ws?.off("open", onOpen);
this.ws?.off("error", onError);
};
this.ws.on("open", onOpen);
this.ws.on("error", onError);
this.ws.on("message", onMessage);
});
}
async close(): Promise<void> {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.messageQueue = [];
this.eventListeners.clear();
this.messageListeners.clear();
}
// ============================================================================
// Agent Lifecycle
// ============================================================================
async createAgent(options: CreateAgentOptions): Promise<AgentSnapshotPayload> {
const requestId = nanoid();
this.send({
type: "create_agent_request",
requestId,
config: {
provider: options.provider,
cwd: options.cwd,
title: options.title,
model: options.model,
modeId: options.modeId,
mcpServers: options.mcpServers,
},
initialPrompt: options.initialPrompt,
});
// First get the agent ID from the initial state
let agentId: string | null = null;
await this.waitFor((msg) => {
if (msg.type === "agent_state") {
agentId = msg.payload.id;
return msg.payload;
}
return null;
}, 10000);
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
}
async deleteAgent(agentId: string): Promise<void> {
this.send({ type: "delete_agent_request", agentId });
await this.waitFor((msg) => {
if (msg.type === "agent_deleted" && msg.payload.agentId === agentId) {
return true;
}
return null;
});
}
async listAgents(): Promise<AgentSnapshotPayload[]> {
// session_state is sent on connection, or we can wait for it
return this.waitFor((msg) => {
if (msg.type === "session_state") {
return msg.payload.agents;
}
return null;
});
}
async listPersistedAgents(): Promise<PersistedAgentDescriptorPayload[]> {
this.send({ type: "list_persisted_agents_request" });
return this.waitFor((msg) => {
if (msg.type === "list_persisted_agents_response") {
return msg.payload.items;
}
return null;
});
}
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial<CreateAgentOptions>
): Promise<AgentSnapshotPayload> {
const requestId = nanoid();
this.send({
type: "resume_agent_request",
requestId,
handle,
overrides: overrides as Record<string, unknown>,
});
return this.waitFor((msg) => {
if (msg.type === "agent_state") {
return msg.payload;
}
return null;
});
}
// ============================================================================
// Agent Interaction
// ============================================================================
async sendMessage(
agentId: string,
text: string,
options?: SendMessageOptions
): Promise<void> {
this.send({
type: "send_agent_message",
agentId,
text,
messageId: options?.messageId,
images: options?.images,
});
}
async cancelAgent(agentId: string): Promise<void> {
this.send({ type: "cancel_agent_request", agentId });
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
this.send({ type: "set_agent_mode", agentId, modeId });
}
// ============================================================================
// Permissions
// ============================================================================
async respondToPermission(
agentId: string,
requestId: string,
response: AgentPermissionResponse
): Promise<void> {
this.send({
type: "agent_permission_response",
agentId,
requestId,
response,
});
}
// ============================================================================
// Waiting / Streaming
// ============================================================================
async waitForAgentIdle(
agentId: string,
timeout = 60000
): Promise<AgentSnapshotPayload> {
// Record the current queue position so we only check messages from NOW
const startPosition = this.messageQueue.length;
// First, wait for the agent to go to "running" state (or already be running)
// This ensures we don't return on an old "idle" state from before the message
let sawRunning = false;
return this.waitFor(
(msg) => {
if (msg.type === "agent_state" && msg.payload.id === agentId) {
const status = msg.payload.status;
if (status === "running") {
sawRunning = true;
}
// Only return idle/error AFTER we've seen running
if (sawRunning && (status === "idle" || status === "error")) {
return msg.payload;
}
}
return null;
},
timeout,
{ skipQueueBefore: startPosition }
);
}
async waitForPermission(
agentId: string,
timeout = 30000
): Promise<AgentPermissionRequest> {
return this.waitFor((msg) => {
// Check direct permission request message
if (
msg.type === "agent_permission_request" &&
msg.payload.agentId === agentId
) {
return msg.payload.request;
}
// Check stream event
if (msg.type === "agent_stream" && msg.payload.agentId === agentId) {
if (msg.payload.event.type === "permission_requested") {
return msg.payload.event.request;
}
}
return null;
}, timeout);
}
// ============================================================================
// Event Subscription
// ============================================================================
on(handler: DaemonEventHandler): () => void {
this.eventListeners.add(handler);
return () => this.eventListeners.delete(handler);
}
// ============================================================================
// Internals
// ============================================================================
private send(message: SessionInboundMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
this.ws.send(JSON.stringify({ type: "session", message }));
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
this.messageQueue.push(msg);
// Notify message listeners (for waitFor) - just signal, they'll check the queue
for (const listener of this.messageListeners) {
listener();
}
// Notify event listeners
const event = this.toEvent(msg);
if (event) {
for (const handler of this.eventListeners) {
handler(event);
}
}
}
private toEvent(msg: SessionOutboundMessage): DaemonEvent | null {
switch (msg.type) {
case "agent_state":
return {
type: "agent_state",
agentId: msg.payload.id,
payload: msg.payload,
};
case "agent_stream":
return {
type: "agent_stream",
agentId: msg.payload.agentId,
event: msg.payload.event,
timestamp: msg.payload.timestamp,
};
case "session_state":
return { type: "session_state", agents: msg.payload.agents };
case "status":
return { type: "status", payload: msg.payload };
case "agent_deleted":
return { type: "agent_deleted", agentId: msg.payload.agentId };
case "agent_permission_request":
return {
type: "agent_permission_request",
agentId: msg.payload.agentId,
request: msg.payload.request,
};
case "agent_permission_resolved":
return {
type: "agent_permission_resolved",
agentId: msg.payload.agentId,
requestId: msg.payload.requestId,
resolution: msg.payload.resolution,
};
default:
return null;
}
}
private async waitFor<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,
options?: { skipQueue?: boolean; skipQueueBefore?: number }
): Promise<T> {
// Record the starting queue length so we can track new messages
const startQueueLength = options?.skipQueueBefore ?? this.messageQueue.length;
// Check queued messages first (unless skipped or with position offset)
if (!options?.skipQueue && options?.skipQueueBefore === undefined) {
for (const msg of this.messageQueue) {
const result = predicate(msg);
if (result !== null) return result;
}
}
// Wait for new messages only
return new Promise((resolve, reject) => {
// Track which messages we've already checked
let checkedCount = startQueueLength;
const checkNewMessages = (): boolean => {
// Check any messages added since we last checked
while (checkedCount < this.messageQueue.length) {
const msg = this.messageQueue[checkedCount];
checkedCount++;
const result = predicate(msg);
if (result !== null) {
cleanup();
resolve(result);
return true;
}
}
return false;
};
const listener = (): void => {
checkNewMessages();
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for message (${timeout}ms)`));
}, timeout);
const cleanup = (): void => {
clearTimeout(timer);
this.messageListeners.delete(listener);
};
this.messageListeners.add(listener);
// Check any messages that arrived between startQueueLength and now
checkNewMessages();
});
}
// ============================================================================
// Debug / Utilities
// ============================================================================
getMessageQueue(): readonly SessionOutboundMessage[] {
return this.messageQueue;
}
clearMessageQueue(): void {
this.messageQueue = [];
}
}

View File

@@ -0,0 +1,50 @@
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./paseo-daemon.js";
import { DaemonClient } from "./daemon-client.js";
export interface DaemonTestContext {
daemon: TestPaseoDaemon;
client: DaemonClient;
cleanup: () => Promise<void>;
}
/**
* Create a test context with an isolated daemon and connected client.
*
* Usage:
* ```typescript
* let ctx: DaemonTestContext;
*
* beforeEach(async () => {
* ctx = await createDaemonTestContext();
* });
*
* afterEach(async () => {
* await ctx.cleanup();
* });
*
* test("creates agent", async () => {
* const agent = await ctx.client.createAgent({
* provider: "codex",
* cwd: "/tmp",
* });
* expect(agent.id).toBeTruthy();
* });
* ```
*/
export async function createDaemonTestContext(): Promise<DaemonTestContext> {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
authHeader: daemon.agentMcpAuthHeader,
});
await client.connect();
return {
daemon,
client,
cleanup: async () => {
await client.close();
await daemon.close();
},
};
}

View File

@@ -0,0 +1,13 @@
export { createTestPaseoDaemon, type TestPaseoDaemon } from "./paseo-daemon.js";
export {
DaemonClient,
type DaemonClientConfig,
type CreateAgentOptions,
type SendMessageOptions,
type DaemonEvent,
type DaemonEventHandler,
} from "./daemon-client.js";
export {
createDaemonTestContext,
type DaemonTestContext,
} from "./daemon-test-context.js";

80
plan.md
View File

@@ -905,7 +905,8 @@ Build a new Codex MCP provider sidebyside with the existing Codex SDK prov
- **Done (2025-12-25 18:00)**: WHAT: Removed duplicate user_message emission from `packages/server/src/server/agent/providers/codex-mcp-agent.ts:2599-2603` - the provider was emitting user_message in `stream()` but `agent-manager.ts`'s `recordUserMessage()` (called by session.ts before stream) already dispatches this event. Updated test in `packages/server/src/server/agent/providers/codex-mcp-agent.test.ts:405` to expect 0 user_messages from provider since agent-manager handles this. RESULT: User messages now appear exactly once in the UI. EVIDENCE: Playwright verification on localhost:8081 - created new Codex agent with prompt "test fix", confirmed only ONE user message in UI (newStreamLength stayed at 1, UI snapshot showed single "test fix" bubble). Unit test passes: `npm run test --workspace=@paseo/server -- codex-mcp-agent.test.ts -t "provider does not emit user_message"` (1 passed).
- [x] **BUG**: Codex agent doesn't see agent-control MCP - Claude does.
- **Done (2025-12-25 22:45)**: WHAT: Fixed `buildCodexMcpConfig()` in `packages/server/src/server/agent/providers/codex-mcp-agent.ts:2337-2429` to include MCP servers in the Codex tool call. Added `CodexMcpServerConfig` and `CodexConfigPayload` types at lines 2324-2335. Added `managedAgentId` parameter to append caller agent ID to agent-control URL. Built MCP servers config including: (1) `agent-control` HTTP MCP with URL and `http_headers` (using Codex's field name, not `headers`), (2) `playwright` STDIO MCP server, (3) user-provided MCP servers from `config.mcpServers`. Added `managedAgentId` property to `CodexMcpAgentSession` class at line 2516. Updated `setManagedAgentId()` at lines 2907-2909 to store the ID. Updated all 3 call sites of `buildCodexMcpConfig()` at lines 2924, 2962, 2985 to pass `this.managedAgentId`. ROOT CAUSE: Claude provider at lines 672-699 builds MCP servers config and passes to Claude SDK. Codex MCP provider at line 2360 only passed `config.extra.codex` - completely ignoring `config.agentControlMcp` and `config.mcpServers`. Codex CLI expects MCP servers in `config.mcp_servers` field with `http_headers` (not `headers`) for HTTP servers. RESULT: Codex agents now receive agent-control and playwright MCP servers in tool call config. EVIDENCE: Typecheck passes (`npm run typecheck --workspace=@paseo/server`), unit test passes (`npm run test --workspace=@paseo/server -- codex-mcp-agent.test.ts -t "responds with text"`), quick verification script shows MCP config includes `agent-control` with URL and headers.
- **Done (2025-12-25 23:15)**: WHAT: Fixed `buildCodexMcpConfig()` in `packages/server/src/server/agent/providers/codex-mcp-agent.ts:2337-2429` to include MCP servers in the Codex tool call. Added `CodexMcpServerConfig` and `CodexConfigPayload` types at lines 2324-2335. Added `managedAgentId` parameter to append caller agent ID to agent-control URL. Built MCP servers config including: (1) `agent-control` HTTP MCP with URL and `http_headers` (using Codex's field name, not `headers`), (2) `playwright` STDIO MCP server, (3) user-provided MCP servers from `config.mcpServers`. Added `managedAgentId` property to `CodexMcpAgentSession` class at line 2516. Updated `setManagedAgentId()` at lines 2907-2909 to store the ID. Updated all 3 call sites of `buildCodexMcpConfig()` at lines 2924, 2962, 2985 to pass `this.managedAgentId`. ROOT CAUSE: Claude provider at lines 672-699 builds MCP servers config and passes to Claude SDK. Codex MCP provider at line 2360 only passed `config.extra.codex` - completely ignoring `config.agentControlMcp` and `config.mcpServers`. Codex CLI expects MCP servers in `config.mcp_servers` field with `http_headers` (not `headers`) for HTTP servers. RESULT: Codex agents now receive agent-control and playwright MCP servers in tool call config. EVIDENCE: Typecheck passes (`npm run typecheck --workspace=@paseo/server`), unit test passes (`npm run test --workspace=@paseo/server -- codex-mcp-agent.test.ts -t "responds with text"`), quick verification script shows MCP config includes `agent-control` with URL and headers.
- **Done (2025-12-25 23:45)**: WHAT: Fixed `parentAgentId` schema to allow null values. Changed `z.string().optional()` to `z.string().nullable().optional()` in `packages/server/src/server/messages.ts:254`. Updated `toAgentPayload` to use `agent.parentAgentId ?? null` in `packages/server/src/server/agent/agent-projections.ts:87`. Updated `Agent` types to accept `string | null | undefined` in `packages/app/src/types/agent-directory.ts:15` and `packages/server/src/stores/session-store.ts:109`. ROOT CAUSE: `list_agents` MCP tool returned `parentAgentId: null` but schema expected `string | undefined`, causing Zod validation to fail with "invalid parentAgentId type". RESULT: Codex agent successfully calls `agent-control.list_agents` and sees agent list. EVIDENCE: Playwright E2E test on localhost:8081 - Codex agent called list_agents and displayed "Found 1 agent: List Agents (Names & Statuses) — running".
- [x] **Plan**: Design daemon client for E2E testing.
@@ -944,3 +945,80 @@ Build a new Codex MCP provider sidebyside with the existing Codex SDK prov
**DO NOT implement yet - just plan and propose options.**
- **Done (2025-12-25 23:15)**: WHAT: Authored comprehensive design report in `REPORT-daemon-client-design.md:1` covering 3 architectural approaches (Simple WebSocket Wrapper, Reactive Event Store, Hybrid), with full type definitions, implementation sketches, and example E2E tests. RESULT: Recommended Approach 1 (Simple WebSocket Wrapper) for ~300-400 lines of code, leveraging existing `messages.ts` Zod schemas and `test-utils/paseo-daemon.ts` infrastructure. Key files identified: `daemon-client.ts` (new), `daemon-test-context.ts` (new), `daemon.e2e.test.ts` (new E2E suite). EVIDENCE: `REPORT-daemon-client-design.md` contains: (1) 3 approaches with pros/cons, (2) full TypeScript interface for DaemonClient, (3) implementation code for core methods (connect, createAgent, sendMessage, waitForAgentIdle, respondToPermission), (4) test context helper, (5) 5 example E2E tests (basic flow, permission approve/deny, persistence/resume, multi-agent), (6) scope estimate of ~550-650 total lines, (7) 5-phase migration path.
- [x] **Implement**: DaemonClient core (Phase 1).
Follow `REPORT-daemon-client-design.md` - Approach 1 (Simple WebSocket Wrapper).
**Create**:
1. `packages/server/src/server/test-utils/daemon-client.ts`:
- `DaemonClient` class with constructor taking `{ url, authHeader? }`
- `connect()`, `close()`
- `createAgent(options)` → returns `AgentSnapshotPayload`
- `deleteAgent(agentId)`
- `listAgents()`
- `sendMessage(agentId, text, options?)`
- `cancelAgent(agentId)`
- `waitForAgentIdle(agentId, timeout?)` → returns final state
- `on(handler)` → event subscription
- Private: `send()`, `waitFor()`, message handling
2. `packages/server/src/server/test-utils/daemon-test-context.ts`:
- `createDaemonTestContext()` → `{ daemon, client, cleanup }`
- Uses existing `createTestPaseoDaemon()` from `paseo-daemon.ts`
3. Update `packages/server/src/server/test-utils/index.ts` to export new utilities
**Test**:
- Add `packages/server/src/server/daemon.e2e.test.ts` with ONE test:
- `creates agent and receives response` (basic flow from report)
- Run test to verify it works
**Acceptance criteria**:
- Typecheck passes
- One E2E test passes: create agent → send message → wait for idle
- No Playwright required
- **Done (2025-12-25 16:49)**: WHAT: Created `packages/server/src/server/test-utils/daemon-client.ts:1-469` (DaemonClient class with connect, close, createAgent, deleteAgent, listAgents, listPersistedAgents, resumeAgent, sendMessage, cancelAgent, setAgentMode, respondToPermission, waitForAgentIdle, waitForPermission, on, send, waitFor, handleSessionMessage, toEvent, getMessageQueue, clearMessageQueue methods). Created `packages/server/src/server/test-utils/daemon-test-context.ts:1-46` (createDaemonTestContext helper). Created `packages/server/src/server/test-utils/index.ts:1-13` (exports). Created `packages/server/src/server/daemon.e2e.test.ts:1-77` (one E2E test "creates agent and receives response"). RESULT: All acceptance criteria met - typecheck passes, E2E test passes in 3.5s (creates Codex agent, sends message, waits for idle, verifies turn_started/turn_completed/assistant_message events), no Playwright required. EVIDENCE: `npm run typecheck --workspace=@paseo/server` (exit 0), `npm run test --workspace=@paseo/server -- daemon.e2e.test.ts` (1 passed in 3537ms).
- [ ] **Implement**: DaemonClient permissions (Phase 2).
**Add methods to DaemonClient**:
- `respondToPermission(agentId, requestId, response)`
- `waitForPermission(agentId, timeout?)`
**Add E2E tests**:
- `permission flow: approve` - trigger permission, approve, verify execution
- `permission flow: deny` - trigger permission, deny, verify handling
**Acceptance criteria**:
- Permission tests pass for both Claude and Codex providers
- Full permission cycle works via DaemonClient
- [ ] **Implement**: DaemonClient persistence (Phase 3).
**Add methods to DaemonClient**:
- `listPersistedAgents()`
- `resumeAgent(persistence)`
**Add E2E tests**:
- `agent persistence and resume` - create, message, delete, list persisted, resume, verify state
**Acceptance criteria**:
- Persistence round-trip works via DaemonClient
- [ ] **Implement**: Multi-agent E2E test (Phase 4).
**Add E2E test**:
- `multi-agent: agent A launches agent B` - parent agent uses agent-control MCP to create child
**Acceptance criteria**:
- Multi-agent orchestration works via DaemonClient
- Both parent and child agents visible in listAgents()
- [ ] **Review**: Audit daemon E2E test coverage.
After all phases complete:
- Run full E2E suite
- Identify any gaps in coverage
- Propose additional tests if needed
- Document what's tested vs not tested