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
co-authored by Claude Opus 4.5
parent cf82296be9
commit 99a5ac97fb
5 changed files with 694 additions and 1 deletions
@@ -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
});
@@ -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 = [];
}
}
@@ -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();
},
};
}
@@ -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";