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:
co-authored by
Claude Opus 4.5
parent
cf82296be9
commit
99a5ac97fb
@@ -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";
|
||||||
@@ -905,7 +905,8 @@ Build a new Codex MCP provider side‑by‑side 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).
|
- **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.
|
- [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.
|
- [x] **Plan**: Design daemon client for E2E testing.
|
||||||
|
|
||||||
@@ -944,3 +945,80 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov
|
|||||||
|
|
||||||
**DO NOT implement yet - just plan and propose options.**
|
**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.
|
- **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
|
||||||
|
|||||||
Reference in New Issue
Block a user