From 6148af784ec3160ab9ced102b55a6625b072c570 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Sat, 31 Jan 2026 08:41:49 +0100 Subject: [PATCH] feat(07-01): implement MockAgentManager adapter - MockAgentScenario interface with outcome, delay, message, filesModified, question - Constructor takes optional eventBus and defaultScenario - setScenario() for per-agent scenario overrides - spawn() creates agent, schedules async completion based on scenario - Emits all lifecycle events: agent:spawned, agent:stopped, agent:crashed, agent:waiting, agent:resumed - stop() cancels pending completion, marks agent stopped - resume() re-runs scenario for waiting_for_input agents - getResult() returns stored result after completion - clear() for test cleanup --- src/agent/mock-manager.ts | 391 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 src/agent/mock-manager.ts diff --git a/src/agent/mock-manager.ts b/src/agent/mock-manager.ts new file mode 100644 index 0000000..bc6ea01 --- /dev/null +++ b/src/agent/mock-manager.ts @@ -0,0 +1,391 @@ +/** + * Mock Agent Manager Adapter + * + * Implementation of AgentManager port for test scenarios. + * Simulates configurable agent behaviors (success, crash, waiting_for_input) + * without spawning real Claude agents. + */ + +import { randomUUID } from 'crypto'; +import type { + AgentManager, + AgentInfo, + SpawnAgentOptions, + AgentResult, + AgentStatus, +} from './types.js'; +import type { + EventBus, + AgentSpawnedEvent, + AgentStoppedEvent, + AgentCrashedEvent, + AgentResumedEvent, + AgentWaitingEvent, +} from '../events/index.js'; + +/** + * Scenario configuration for mock agent behavior. + */ +export interface MockAgentScenario { + /** How agent completes: 'success' | 'crash' | 'waiting_for_input' */ + outcome: 'success' | 'crash' | 'waiting_for_input'; + /** Delay before completion (ms). Default 0 for synchronous tests. */ + delay?: number; + /** Result message for success/crash */ + message?: string; + /** Files modified (for success) */ + filesModified?: string[]; + /** Question to surface (for waiting_for_input) */ + question?: string; +} + +/** + * Internal agent record with scenario and timer tracking. + */ +interface MockAgentRecord { + info: AgentInfo; + scenario: MockAgentScenario; + result?: AgentResult; + completionTimer?: ReturnType; +} + +/** + * Default scenario: immediate success with generic message. + */ +const DEFAULT_SCENARIO: MockAgentScenario = { + outcome: 'success', + delay: 0, + message: 'Task completed successfully', + filesModified: [], +}; + +/** + * MockAgentManager - Adapter implementing AgentManager port for testing. + * + * Enables E2E testing of dispatch/coordination flows without spawning + * real Claude agents. Simulates configurable agent behaviors and + * emits proper lifecycle events. + */ +export class MockAgentManager implements AgentManager { + private agents: Map = new Map(); + private scenarioOverrides: Map = new Map(); + private defaultScenario: MockAgentScenario; + private eventBus?: EventBus; + + constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario }) { + this.eventBus = options?.eventBus; + this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO; + } + + /** + * Set scenario override for a specific agent name. + * When spawn() is called with this name, the override takes precedence. + */ + setScenario(agentName: string, scenario: MockAgentScenario): void { + this.scenarioOverrides.set(agentName, scenario); + } + + /** + * Clear scenario override for a specific agent name. + */ + clearScenario(agentName: string): void { + this.scenarioOverrides.delete(agentName); + } + + /** + * Spawn a new mock agent. + * + * Creates agent record in internal Map, schedules completion based on scenario. + * Completion happens async via setTimeout (even if delay=0). + */ + async spawn(options: SpawnAgentOptions): Promise { + const { name, taskId, prompt } = options; + + // Check name uniqueness + for (const record of this.agents.values()) { + if (record.info.name === name) { + throw new Error(`Agent with name '${name}' already exists`); + } + } + + const agentId = randomUUID(); + const sessionId = randomUUID(); + const worktreeId = randomUUID(); + const now = new Date(); + + // Determine scenario (override takes precedence) + const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario; + + const info: AgentInfo = { + id: agentId, + name, + taskId, + sessionId, + worktreeId, + status: 'running', + createdAt: now, + updatedAt: now, + }; + + const record: MockAgentRecord = { + info, + scenario, + }; + + this.agents.set(agentId, record); + + // Emit spawned event + if (this.eventBus) { + const event: AgentSpawnedEvent = { + type: 'agent:spawned', + timestamp: new Date(), + payload: { + agentId, + name, + taskId, + worktreeId, + }, + }; + this.eventBus.emit(event); + } + + // Schedule completion async (even with delay=0, uses setTimeout for async behavior) + this.scheduleCompletion(agentId, scenario); + + return info; + } + + /** + * Schedule agent completion based on scenario. + */ + private scheduleCompletion(agentId: string, scenario: MockAgentScenario): void { + const delay = scenario.delay ?? 0; + + const timer = setTimeout(() => { + this.completeAgent(agentId, scenario); + }, delay); + + const record = this.agents.get(agentId); + if (record) { + record.completionTimer = timer; + } + } + + /** + * Complete agent based on scenario outcome. + */ + private completeAgent(agentId: string, scenario: MockAgentScenario): void { + const record = this.agents.get(agentId); + if (!record) return; + + const { info } = record; + + switch (scenario.outcome) { + case 'success': + record.result = { + success: true, + message: scenario.message ?? 'Task completed successfully', + filesModified: scenario.filesModified, + }; + record.info.status = 'idle'; + record.info.updatedAt = new Date(); + + if (this.eventBus) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId, + name: info.name, + taskId: info.taskId, + reason: 'task_complete', + }, + }; + this.eventBus.emit(event); + } + break; + + case 'crash': + record.result = { + success: false, + message: scenario.message ?? 'Agent crashed', + }; + record.info.status = 'crashed'; + record.info.updatedAt = new Date(); + + if (this.eventBus) { + const event: AgentCrashedEvent = { + type: 'agent:crashed', + timestamp: new Date(), + payload: { + agentId, + name: info.name, + taskId: info.taskId, + error: scenario.message ?? 'Agent crashed', + }, + }; + this.eventBus.emit(event); + } + break; + + case 'waiting_for_input': + record.info.status = 'waiting_for_input'; + record.info.updatedAt = new Date(); + + if (this.eventBus) { + const event: AgentWaitingEvent = { + type: 'agent:waiting', + timestamp: new Date(), + payload: { + agentId, + name: info.name, + taskId: info.taskId, + sessionId: info.sessionId ?? '', + question: scenario.question ?? 'User input required', + }, + }; + this.eventBus.emit(event); + } + break; + } + } + + /** + * Stop a running agent. + * + * Cancels scheduled completion, marks agent stopped, emits agent:stopped event. + */ + async stop(agentId: string): Promise { + const record = this.agents.get(agentId); + if (!record) { + throw new Error(`Agent '${agentId}' not found`); + } + + // Cancel any pending completion + if (record.completionTimer) { + clearTimeout(record.completionTimer); + record.completionTimer = undefined; + } + + record.info.status = 'stopped'; + record.info.updatedAt = new Date(); + + if (this.eventBus) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId, + name: record.info.name, + taskId: record.info.taskId, + reason: 'user_requested', + }, + }; + this.eventBus.emit(event); + } + } + + /** + * List all agents with their current status. + */ + async list(): Promise { + return Array.from(this.agents.values()).map((record) => record.info); + } + + /** + * Get a specific agent by ID. + */ + async get(agentId: string): Promise { + const record = this.agents.get(agentId); + return record ? record.info : null; + } + + /** + * Get a specific agent by name. + */ + async getByName(name: string): Promise { + for (const record of this.agents.values()) { + if (record.info.name === name) { + return record.info; + } + } + return null; + } + + /** + * Resume an agent that's waiting for input. + * + * Re-runs the scenario for the resumed agent. Emits agent:resumed event. + * Agent must be in 'waiting_for_input' status. + */ + async resume(agentId: string, prompt: string): Promise { + const record = this.agents.get(agentId); + if (!record) { + throw new Error(`Agent '${agentId}' not found`); + } + + if (record.info.status !== 'waiting_for_input') { + throw new Error( + `Agent '${record.info.name}' is not waiting for input (status: ${record.info.status})` + ); + } + + if (!record.info.sessionId) { + throw new Error(`Agent '${record.info.name}' has no session to resume`); + } + + // Update status to running + record.info.status = 'running'; + record.info.updatedAt = new Date(); + + // Emit resumed event + if (this.eventBus) { + const event: AgentResumedEvent = { + type: 'agent:resumed', + timestamp: new Date(), + payload: { + agentId, + name: record.info.name, + taskId: record.info.taskId, + sessionId: record.info.sessionId, + }, + }; + this.eventBus.emit(event); + } + + // Re-run scenario (after resume, typically completes successfully) + // For testing, we use a new scenario that defaults to success + const resumeScenario: MockAgentScenario = { + outcome: 'success', + delay: record.scenario.delay ?? 0, + message: 'Resumed and completed successfully', + filesModified: record.scenario.filesModified, + }; + + this.scheduleCompletion(agentId, resumeScenario); + } + + /** + * Get the result of an agent's work. + * + * Only available after agent completes or crashes. + */ + async getResult(agentId: string): Promise { + const record = this.agents.get(agentId); + return record?.result ?? null; + } + + /** + * Clear all agents and pending timers. + * Useful for test cleanup. + */ + clear(): void { + for (const record of this.agents.values()) { + if (record.completionTimer) { + clearTimeout(record.completionTimer); + } + } + this.agents.clear(); + this.scenarioOverrides.clear(); + } +}