/** * MockAgentManager Tests * * Comprehensive test suite for the MockAgentManager adapter covering * all scenario types: success, crash, waiting_for_input. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { MockAgentManager, type MockAgentScenario } from './mock-manager.js'; import type { EventBus, DomainEvent, AgentStoppedEvent } from '../events/types.js'; // ============================================================================= // Test Helpers // ============================================================================= /** * Create a mock EventBus that captures emitted events. */ function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } { const emittedEvents: DomainEvent[] = []; return { emittedEvents, emit(event: T): void { emittedEvents.push(event); }, on: vi.fn(), off: vi.fn(), once: vi.fn(), }; } // ============================================================================= // Tests // ============================================================================= describe('MockAgentManager', () => { let manager: MockAgentManager; let eventBus: ReturnType; beforeEach(() => { vi.useFakeTimers(); eventBus = createMockEventBus(); manager = new MockAgentManager({ eventBus }); }); afterEach(() => { manager.clear(); vi.useRealTimers(); }); // =========================================================================== // spawn() with default scenario (immediate success) // =========================================================================== describe('spawn with default scenario', () => { it('should create agent with running status', async () => { const agent = await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'Do something', }); expect(agent.name).toBe('test-agent'); expect(agent.taskId).toBe('task-1'); expect(agent.status).toBe('running'); expect(agent.id).toBeDefined(); expect(agent.sessionId).toBeDefined(); expect(agent.worktreeId).toBeDefined(); }); it('should emit agent:spawned event', async () => { await manager.spawn({ name: 'spawned-test', taskId: 'task-1', prompt: 'Do something', }); expect(eventBus.emittedEvents.length).toBeGreaterThanOrEqual(1); const spawnedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:spawned'); expect(spawnedEvent).toBeDefined(); expect((spawnedEvent as any).payload.name).toBe('spawned-test'); expect((spawnedEvent as any).payload.taskId).toBe('task-1'); }); it('should complete with success after timer fires', async () => { const agent = await manager.spawn({ name: 'success-test', taskId: 'task-1', prompt: 'Do something', }); // Timer hasn't fired yet expect(agent.status).toBe('running'); // Advance timers await vi.advanceTimersByTimeAsync(0); // Check status changed const updated = await manager.get(agent.id); expect(updated?.status).toBe('idle'); // Check result available const result = await manager.getResult(agent.id); expect(result).not.toBeNull(); expect(result?.success).toBe(true); expect(result?.message).toBe('Task completed successfully'); }); it('should emit agent:stopped event on success completion', async () => { await manager.spawn({ name: 'stop-event-test', taskId: 'task-1', prompt: 'Do something', }); await vi.advanceTimersByTimeAsync(0); const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped'); expect(stoppedEvent).toBeDefined(); expect((stoppedEvent as any).payload.reason).toBe('task_complete'); }); }); // =========================================================================== // spawn() with configured delay // =========================================================================== describe('spawn with configured delay', () => { it('should not complete before delay expires', async () => { manager.setScenario('delayed-agent', { status: 'done', delay: 100, result: 'Delayed completion', }); const agent = await manager.spawn({ name: 'delayed-agent', taskId: 'task-1', prompt: 'Do something slowly', }); // Advance by less than delay await vi.advanceTimersByTimeAsync(50); const updated = await manager.get(agent.id); expect(updated?.status).toBe('running'); }); it('should complete after delay expires', async () => { manager.setScenario('delayed-agent', { status: 'done', delay: 100, result: 'Delayed completion', }); const agent = await manager.spawn({ name: 'delayed-agent', taskId: 'task-1', prompt: 'Do something slowly', }); // Advance past delay await vi.advanceTimersByTimeAsync(100); const updated = await manager.get(agent.id); expect(updated?.status).toBe('idle'); const result = await manager.getResult(agent.id); expect(result?.message).toBe('Delayed completion'); }); }); // =========================================================================== // spawn() with crash scenario // =========================================================================== describe('spawn with error scenario', () => { it('should emit agent:crashed and set result.success=false', async () => { manager.setScenario('crash-agent', { status: 'error', delay: 0, error: 'Something went terribly wrong', }); const agent = await manager.spawn({ name: 'crash-agent', taskId: 'task-1', prompt: 'Do something risky', }); await vi.advanceTimersByTimeAsync(0); // Check status const updated = await manager.get(agent.id); expect(updated?.status).toBe('crashed'); // Check result const result = await manager.getResult(agent.id); expect(result?.success).toBe(false); expect(result?.message).toBe('Something went terribly wrong'); // Check event const crashedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:crashed'); expect(crashedEvent).toBeDefined(); expect((crashedEvent as any).payload.error).toBe('Something went terribly wrong'); }); }); // =========================================================================== // spawn() with question scenario // =========================================================================== describe('spawn with questions scenario', () => { it('should emit agent:waiting and set status to waiting_for_input', async () => { manager.setScenario('waiting-agent', { status: 'questions', delay: 0, questions: [{ id: 'q1', question: 'Should I continue?' }], }); const agent = await manager.spawn({ name: 'waiting-agent', taskId: 'task-1', prompt: 'Ask a question', }); await vi.advanceTimersByTimeAsync(0); // Check status const updated = await manager.get(agent.id); expect(updated?.status).toBe('waiting_for_input'); // Check event const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting'); expect(waitingEvent).toBeDefined(); expect((waitingEvent as any).payload.questions[0].question).toBe('Should I continue?'); }); }); // =========================================================================== // resume() after waiting_for_input // =========================================================================== describe('resume after questions', () => { it('should emit agent:resumed and continue with scenario', async () => { manager.setScenario('resume-agent', { status: 'questions', delay: 0, questions: [{ id: 'q1', question: 'Need your input' }], }); const agent = await manager.spawn({ name: 'resume-agent', taskId: 'task-1', prompt: 'Start working', }); // Let agent reach waiting state await vi.advanceTimersByTimeAsync(0); const waitingAgent = await manager.get(agent.id); expect(waitingAgent?.status).toBe('waiting_for_input'); // Resume the agent with answers map await manager.resume(agent.id, { q1: 'Continue with this input' }); // Check agent:resumed event emitted const resumedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:resumed'); expect(resumedEvent).toBeDefined(); expect((resumedEvent as any).payload.agentId).toBe(agent.id); expect((resumedEvent as any).payload.sessionId).toBe(agent.sessionId); // Status should be running again const runningAgent = await manager.get(agent.id); expect(runningAgent?.status).toBe('running'); // Let it complete await vi.advanceTimersByTimeAsync(0); const completedAgent = await manager.get(agent.id); expect(completedAgent?.status).toBe('idle'); const result = await manager.getResult(agent.id); expect(result?.success).toBe(true); }); it('should throw if agent not waiting for input', async () => { const agent = await manager.spawn({ name: 'not-waiting', taskId: 'task-1', prompt: 'Work', }); await expect(manager.resume(agent.id, { q1: 'input' })).rejects.toThrow( 'is not waiting for input' ); }); it('should throw if agent not found', async () => { await expect(manager.resume('non-existent-id', { q1: 'input' })).rejects.toThrow( 'not found' ); }); }); // =========================================================================== // stop() kills scheduled completion // =========================================================================== describe('stop', () => { it('should cancel scheduled completion and emit agent:stopped', async () => { manager.setScenario('stoppable-agent', { status: 'done', delay: 1000, result: 'Should not see this', }); const agent = await manager.spawn({ name: 'stoppable-agent', taskId: 'task-1', prompt: 'Long running task', }); // Stop before completion await manager.stop(agent.id); // Check status const updated = await manager.get(agent.id); expect(updated?.status).toBe('stopped'); // Check event const stoppedEvent = eventBus.emittedEvents.find( (e) => e.type === 'agent:stopped' && (e as any).payload.reason === 'user_requested' ); expect(stoppedEvent).toBeDefined(); // Advance time - should not complete now await vi.advanceTimersByTimeAsync(1000); const stillStopped = await manager.get(agent.id); expect(stillStopped?.status).toBe('stopped'); }); it('should throw if agent not found', async () => { await expect(manager.stop('non-existent-id')).rejects.toThrow('not found'); }); }); // =========================================================================== // list() returns all agents with correct status // =========================================================================== describe('list', () => { it('should return all agents', async () => { await manager.spawn({ name: 'agent-1', taskId: 't1', prompt: 'p1' }); await manager.spawn({ name: 'agent-2', taskId: 't2', prompt: 'p2' }); await manager.spawn({ name: 'agent-3', taskId: 't3', prompt: 'p3' }); const agents = await manager.list(); expect(agents.length).toBe(3); expect(agents.map((a) => a.name).sort()).toEqual(['agent-1', 'agent-2', 'agent-3']); }); it('should return empty array when no agents', async () => { const agents = await manager.list(); expect(agents).toEqual([]); }); }); // =========================================================================== // get() and getByName() lookups // =========================================================================== describe('get and getByName', () => { it('get should return agent by ID', async () => { const spawned = await manager.spawn({ name: 'get-test', taskId: 't1', prompt: 'p1', }); const found = await manager.get(spawned.id); expect(found).not.toBeNull(); expect(found?.name).toBe('get-test'); }); it('get should return null for unknown ID', async () => { const found = await manager.get('unknown-id'); expect(found).toBeNull(); }); it('getByName should return agent by name', async () => { await manager.spawn({ name: 'named-agent', taskId: 't1', prompt: 'p1' }); const found = await manager.getByName('named-agent'); expect(found).not.toBeNull(); expect(found?.name).toBe('named-agent'); }); it('getByName should return null for unknown name', async () => { const found = await manager.getByName('unknown-name'); expect(found).toBeNull(); }); }); // =========================================================================== // setScenario() overrides for specific agent names // =========================================================================== describe('setScenario overrides', () => { it('should use scenario override for specific agent name', async () => { // Set error scenario for one agent manager.setScenario('crasher', { status: 'error', delay: 0, error: 'Intentional crash', }); // Spawn two agents - one with override, one with default const crasher = await manager.spawn({ name: 'crasher', taskId: 't1', prompt: 'p1', }); const normal = await manager.spawn({ name: 'normal', taskId: 't2', prompt: 'p2', }); await vi.advanceTimersByTimeAsync(0); // Crasher should have crashed const crasherUpdated = await manager.get(crasher.id); expect(crasherUpdated?.status).toBe('crashed'); // Normal should have succeeded const normalUpdated = await manager.get(normal.id); expect(normalUpdated?.status).toBe('idle'); }); it('should allow clearing scenario override', async () => { manager.setScenario('flip-flop', { status: 'error', delay: 0, error: 'Crash for test', }); // First spawn crashes const first = await manager.spawn({ name: 'flip-flop', taskId: 't1', prompt: 'p1', }); await vi.advanceTimersByTimeAsync(0); expect((await manager.get(first.id))?.status).toBe('crashed'); // Clear scenario and remove agent manager.clearScenario('flip-flop'); manager.clear(); // Second spawn succeeds (default scenario) const second = await manager.spawn({ name: 'flip-flop', taskId: 't2', prompt: 'p2', }); await vi.advanceTimersByTimeAsync(0); expect((await manager.get(second.id))?.status).toBe('idle'); }); }); // =========================================================================== // Event emission order verification // =========================================================================== describe('event emission order', () => { it('should emit spawned before completion events', async () => { await manager.spawn({ name: 'order-test', taskId: 't1', prompt: 'p1' }); await vi.advanceTimersByTimeAsync(0); const eventTypes = eventBus.emittedEvents.map((e) => e.type); const spawnedIndex = eventTypes.indexOf('agent:spawned'); const stoppedIndex = eventTypes.indexOf('agent:stopped'); expect(spawnedIndex).toBeLessThan(stoppedIndex); }); it('should emit spawned before crashed', async () => { manager.setScenario('crash-order', { status: 'error', delay: 0, error: 'Crash' }); await manager.spawn({ name: 'crash-order', taskId: 't1', prompt: 'p1' }); await vi.advanceTimersByTimeAsync(0); const eventTypes = eventBus.emittedEvents.map((e) => e.type); const spawnedIndex = eventTypes.indexOf('agent:spawned'); const crashedIndex = eventTypes.indexOf('agent:crashed'); expect(spawnedIndex).toBeLessThan(crashedIndex); }); it('should emit spawned before waiting', async () => { manager.setScenario('wait-order', { status: 'questions', delay: 0, questions: [{ id: 'q1', question: 'Test question' }], }); await manager.spawn({ name: 'wait-order', taskId: 't1', prompt: 'p1' }); await vi.advanceTimersByTimeAsync(0); const eventTypes = eventBus.emittedEvents.map((e) => e.type); const spawnedIndex = eventTypes.indexOf('agent:spawned'); const waitingIndex = eventTypes.indexOf('agent:waiting'); expect(spawnedIndex).toBeLessThan(waitingIndex); }); }); // =========================================================================== // Name uniqueness validation // =========================================================================== describe('name uniqueness', () => { it('should throw when spawning agent with duplicate name', async () => { await manager.spawn({ name: 'unique-name', taskId: 't1', prompt: 'p1' }); await expect( manager.spawn({ name: 'unique-name', taskId: 't2', prompt: 'p2' }) ).rejects.toThrow("Agent with name 'unique-name' already exists"); }); }); // =========================================================================== // Constructor options // =========================================================================== describe('constructor options', () => { it('should work without eventBus', async () => { const noEventManager = new MockAgentManager(); const agent = await noEventManager.spawn({ name: 'no-events', taskId: 't1', prompt: 'p1', }); expect(agent.name).toBe('no-events'); noEventManager.clear(); }); it('should use provided default scenario', async () => { const customDefault: MockAgentScenario = { status: 'error', delay: 0, error: 'Default crash', }; const customManager = new MockAgentManager({ eventBus, defaultScenario: customDefault, }); const agent = await customManager.spawn({ name: 'custom-default', taskId: 't1', prompt: 'p1', }); await vi.advanceTimersByTimeAsync(0); expect((await customManager.get(agent.id))?.status).toBe('crashed'); customManager.clear(); }); }); // =========================================================================== // clear() cleanup // =========================================================================== describe('clear', () => { it('should remove all agents and cancel pending timers', async () => { manager.setScenario('pending', { status: 'done', delay: 1000 }); await manager.spawn({ name: 'pending', taskId: 't1', prompt: 'p1' }); await manager.spawn({ name: 'another', taskId: 't2', prompt: 'p2' }); expect((await manager.list()).length).toBe(2); manager.clear(); expect((await manager.list()).length).toBe(0); }); }); // =========================================================================== // Agent modes (execute, discuss, plan) // =========================================================================== describe('agent modes', () => { it('should spawn agent with default execute mode', async () => { const agent = await manager.spawn({ name: 'exec-agent', taskId: 't1', prompt: 'test', }); expect(agent.mode).toBe('execute'); }); it('should spawn agent in discuss mode', async () => { manager.setScenario('discuss-agent', { status: 'done', delay: 0, result: 'Auth discussion complete', }); const agent = await manager.spawn({ name: 'discuss-agent', taskId: 't1', prompt: 'discuss auth', mode: 'discuss', }); expect(agent.mode).toBe('discuss'); }); it('should spawn agent in plan mode', async () => { manager.setScenario('plan-agent', { status: 'done', delay: 0, result: 'Plan complete', }); const agent = await manager.spawn({ name: 'plan-agent', taskId: 't1', prompt: 'plan work', mode: 'plan', }); expect(agent.mode).toBe('plan'); }); it('should emit stopped event with context_complete reason for discuss mode', async () => { manager.setScenario('discuss-done', { status: 'done', delay: 0, result: 'Done', }); await manager.spawn({ name: 'discuss-done', taskId: 't1', prompt: 'test', mode: 'discuss', }); await vi.runAllTimersAsync(); const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined; expect(stopped?.payload.reason).toBe('context_complete'); }); it('should emit stopped event with plan_complete reason for plan mode', async () => { manager.setScenario('plan-done', { status: 'done', delay: 0, result: 'Plan complete', }); await manager.spawn({ name: 'plan-done', taskId: 't1', prompt: 'test', mode: 'plan', }); await vi.runAllTimersAsync(); const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined; expect(stopped?.payload.reason).toBe('plan_complete'); }); }); // =========================================================================== // Detail mode (phase to tasks) // =========================================================================== describe('detail mode', () => { it('should spawn agent in detail mode', async () => { const agent = await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'Detail this phase', mode: 'detail', }); expect(agent.mode).toBe('detail'); }); it('should complete with detail_complete reason in detail mode', async () => { manager.setScenario('detailer', { status: 'done', result: 'Detail complete', }); await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' }); await vi.advanceTimersByTimeAsync(100); // Verify agent:stopped event with detail_complete reason (derived from mode) const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined; expect(stoppedEvent).toBeDefined(); expect(stoppedEvent?.payload.reason).toBe('detail_complete'); }); it('should pause on questions in detail mode', async () => { manager.setScenario('detailer', { status: 'questions', questions: [{ id: 'q1', question: 'How many tasks?' }], }); await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' }); await vi.advanceTimersByTimeAsync(100); // Verify agent pauses for questions const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting'); expect(stoppedEvent).toBeDefined(); // Check agent status const agent = await manager.getByName('detailer'); expect(agent?.status).toBe('waiting_for_input'); }); it('should set result message for detail mode', async () => { manager.setScenario('detailer', { status: 'done', result: 'Detail complete', }); const agent = await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' }); await vi.runAllTimersAsync(); const result = await manager.getResult(agent.id); expect(result?.success).toBe(true); expect(result?.message).toBe('Detail complete'); }); }); // =========================================================================== // Structured question data (new schema tests) // =========================================================================== describe('structured questions data', () => { it('emits agent:waiting with structured questions data', async () => { manager.setScenario('test-agent', { status: 'questions', questions: [ { id: 'q1', question: 'Which database?', options: [ { label: 'PostgreSQL', description: 'Full-featured' }, { label: 'SQLite', description: 'Lightweight' }, ], multiSelect: false, }, ], }); await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' }); await vi.runAllTimersAsync(); const events = eventBus.emittedEvents.filter((e) => e.type === 'agent:waiting'); expect(events.length).toBe(1); expect((events[0] as any).payload.questions).toHaveLength(1); expect((events[0] as any).payload.questions[0].options).toHaveLength(2); expect((events[0] as any).payload.questions[0].options[0].label).toBe('PostgreSQL'); expect((events[0] as any).payload.questions[0].multiSelect).toBe(false); }); it('stores pending questions for retrieval', async () => { manager.setScenario('test-agent', { status: 'questions', questions: [ { id: 'q1', question: 'Which database?', options: [{ label: 'PostgreSQL' }], }, ], }); const agent = await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' }); await vi.runAllTimersAsync(); const pending = await manager.getPendingQuestions(agent.id); expect(pending?.questions[0].question).toBe('Which database?'); expect(pending?.questions[0].options).toHaveLength(1); expect(pending?.questions[0].options?.[0].label).toBe('PostgreSQL'); }); it('clears pending questions after resume', async () => { manager.setScenario('resume-test', { status: 'questions', questions: [ { id: 'q1', question: 'Need your input', options: [{ label: 'Option A' }, { label: 'Option B' }], }, ], }); const agent = await manager.spawn({ name: 'resume-test', taskId: 'task-1', prompt: 'test' }); await vi.runAllTimersAsync(); // Verify questions are pending const pendingBefore = await manager.getPendingQuestions(agent.id); expect(pendingBefore).not.toBeNull(); expect(pendingBefore?.questions[0].question).toBe('Need your input'); // Resume the agent with answers map await manager.resume(agent.id, { q1: 'Option A' }); // Pending questions should be cleared const pendingAfter = await manager.getPendingQuestions(agent.id); expect(pendingAfter).toBeNull(); }); it('returns null for non-existent agent pending questions', async () => { const pending = await manager.getPendingQuestions('non-existent-id'); expect(pending).toBeNull(); }); it('returns null for agent not in waiting state', async () => { const agent = await manager.spawn({ name: 'running-agent', taskId: 'task-1', prompt: 'test' }); // Agent is running, not waiting const pending = await manager.getPendingQuestions(agent.id); expect(pending).toBeNull(); }); it('handles multiple questions in single scenario', async () => { manager.setScenario('multi-q-agent', { status: 'questions', questions: [ { id: 'q1', question: 'Which database should we use?', options: [ { label: 'PostgreSQL', description: 'Full-featured relational DB' }, { label: 'SQLite', description: 'Lightweight embedded DB' }, ], }, { id: 'q2', question: 'Which ORM do you prefer?', options: [ { label: 'Drizzle', description: 'TypeScript-first ORM' }, { label: 'Prisma', description: 'Popular Node.js ORM' }, ], }, { id: 'q3', question: 'Any additional notes?', // No options - free-form text question }, ], }); const agent = await manager.spawn({ name: 'multi-q-agent', taskId: 'task-1', prompt: 'test' }); await vi.runAllTimersAsync(); // Check status const updated = await manager.get(agent.id); expect(updated?.status).toBe('waiting_for_input'); // Check event has all questions const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting'); expect(waitingEvent).toBeDefined(); expect((waitingEvent as any).payload.questions).toHaveLength(3); expect((waitingEvent as any).payload.questions[0].id).toBe('q1'); expect((waitingEvent as any).payload.questions[1].id).toBe('q2'); expect((waitingEvent as any).payload.questions[2].id).toBe('q3'); // Check pending questions retrieval const pending = await manager.getPendingQuestions(agent.id); expect(pending?.questions).toHaveLength(3); expect(pending?.questions[0].question).toBe('Which database should we use?'); expect(pending?.questions[1].question).toBe('Which ORM do you prefer?'); expect(pending?.questions[2].question).toBe('Any additional notes?'); expect(pending?.questions[2].options).toBeUndefined(); // Resume with answers to all questions await manager.resume(agent.id, { q1: 'PostgreSQL', q2: 'Drizzle', q3: 'Use WAL mode' }); await vi.runAllTimersAsync(); // Agent should complete const completed = await manager.get(agent.id); expect(completed?.status).toBe('idle'); // Pending questions should be cleared const clearedPending = await manager.getPendingQuestions(agent.id); expect(clearedPending).toBeNull(); }); }); });