/** * 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, AgentMode, SpawnAgentOptions, AgentResult, AgentStatus, PendingQuestions, QuestionItem, } from './types.js'; import type { EventBus, AgentSpawnedEvent, AgentStoppedEvent, AgentCrashedEvent, AgentResumedEvent, AgentDeletedEvent, AgentWaitingEvent, } from '../events/index.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; /** * Scenario configuration for mock agent behavior. * Matches the simplified agent signal schema: done, questions, or error. * Mode-specific stopped reasons are derived from the agent's mode. */ export type MockAgentScenario = | { status: 'done'; result?: string; filesModified?: string[]; delay?: number; } | { status: 'questions'; questions: QuestionItem[]; delay?: number; } | { status: 'error'; error: string; delay?: number; }; /** * Internal agent record with scenario and timer tracking. */ interface MockAgentRecord { info: AgentInfo; scenario: MockAgentScenario; result?: AgentResult; pendingQuestions?: PendingQuestions; completionTimer?: ReturnType; } /** * Default scenario: immediate success with generic message. */ const DEFAULT_SCENARIO: MockAgentScenario = { status: 'done', result: 'Task completed successfully', filesModified: [], delay: 0, }; /** * 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; private agentRepository?: AgentRepository; constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario; agentRepository?: AgentRepository }) { this.eventBus = options?.eventBus; this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO; this.agentRepository = options?.agentRepository; } /** * 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 { taskId } = options; const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`; // 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 sessionId = randomUUID(); const worktreeId = randomUUID(); const now = new Date(); // Persist to agentRepository when provided (required for FK constraints in tests) let agentId: string; if (this.agentRepository) { const dbAgent = await this.agentRepository.create({ name, worktreeId, taskId: taskId ?? null, initiativeId: options.initiativeId ?? null, sessionId, status: 'running', mode: options.mode ?? 'execute', provider: options.provider ?? 'claude', accountId: null, }); agentId = dbAgent.id; } else { agentId = randomUUID(); } // Determine scenario (override takes precedence — use original name or generated) const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario; const info: AgentInfo = { id: agentId, name: name ?? `mock-${agentId.slice(0, 6)}`, taskId: taskId ?? null, initiativeId: options.initiativeId ?? null, sessionId, worktreeId, status: 'running', mode: options.mode ?? 'execute', provider: options.provider ?? 'claude', accountId: null, createdAt: now, updatedAt: now, exitCode: null, prompt: null, }; 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: taskId ?? null, worktreeId, provider: options.provider ?? 'claude', }, }; 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; } } /** * Map agent mode to stopped event reason. */ private getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] { switch (mode) { case 'discuss': return 'context_complete'; case 'plan': return 'plan_complete'; case 'detail': return 'detail_complete'; case 'refine': return 'refine_complete'; default: return 'task_complete'; } } /** * Complete agent based on scenario status. */ private completeAgent(agentId: string, scenario: MockAgentScenario): void { const record = this.agents.get(agentId); if (!record) return; const { info } = record; switch (scenario.status) { case 'done': record.result = { success: true, message: scenario.result ?? 'Task completed successfully', filesModified: scenario.filesModified, }; record.info.status = 'idle'; record.info.updatedAt = new Date(); if (this.eventBus) { const reason = this.getStoppedReason(info.mode); const event: AgentStoppedEvent = { type: 'agent:stopped', timestamp: new Date(), payload: { agentId, name: info.name, taskId: info.taskId, reason, }, }; this.eventBus.emit(event); } break; case 'error': record.result = { success: false, message: scenario.error, }; 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.error, }, }; this.eventBus.emit(event); } break; case 'questions': record.info.status = 'waiting_for_input'; record.info.updatedAt = new Date(); record.pendingQuestions = { questions: scenario.questions, }; if (this.eventBus) { const event: AgentWaitingEvent = { type: 'agent:waiting', timestamp: new Date(), payload: { agentId, name: info.name, taskId: info.taskId, sessionId: info.sessionId ?? '', questions: scenario.questions, }, }; 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); } } /** * Delete an agent and clean up. * Removes from internal map and emits agent:deleted event. */ async delete(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; } const name = record.info.name; this.agents.delete(agentId); if (this.eventBus) { const event: AgentDeletedEvent = { type: 'agent:deleted', timestamp: new Date(), payload: { agentId, name, }, }; 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. * * @param agentId - Agent to resume * @param answers - Map of question ID to user's answer */ async resume(agentId: string, answers: Record): 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, clear pending questions record.info.status = 'running'; record.info.updatedAt = new Date(); record.pendingQuestions = undefined; // 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 // Extract filesModified from original scenario if it was a 'done' type const originalFilesModified = record.scenario.status === 'done' ? record.scenario.filesModified : undefined; const resumeScenario: MockAgentScenario = { status: 'done', delay: record.scenario.delay ?? 0, result: 'Resumed and completed successfully', filesModified: originalFilesModified, }; 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; } /** * Get pending questions for an agent waiting for input. */ async getPendingQuestions(agentId: string): Promise { const record = this.agents.get(agentId); return record?.pendingQuestions ?? null; } /** * Dismiss an agent. * Mock implementation just marks the agent as dismissed. */ async dismiss(agentId: string): Promise { const record = this.agents.get(agentId); if (!record) { throw new Error(`Agent '${agentId}' not found`); } const now = new Date(); record.info.userDismissedAt = now; record.info.updatedAt = now; } /** * Resume an idle agent to answer an inter-agent conversation. * Mock implementation: marks agent as running and schedules immediate completion. */ async resumeForConversation( agentId: string, conversationId: string, question: string, fromAgentId: string, ): Promise { const record = this.agents.get(agentId); if (!record || record.info.status !== 'idle' || !record.info.sessionId) { return false; } record.info.status = 'running'; record.info.updatedAt = new Date(); 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); } this.scheduleCompletion(agentId, { status: 'done', delay: 0, result: 'Answered conversation' }); return true; } /** * Deliver a user message to a running errand agent. * Mock implementation: no-op (simulates message delivery without actual process interaction). */ async sendUserMessage(agentId: string, _message: string): Promise { const record = this.agents.get(agentId); if (!record) { throw new Error(`Agent '${agentId}' not found`); } // Mock: succeed silently — message delivery is a no-op in tests } /** * 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(); } }