diff --git a/src/agent/index.ts b/src/agent/index.ts index 9e2e848..8da7466 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -14,5 +14,6 @@ export type { AgentManager, } from './types.js'; -// Adapter implementation +// Adapter implementations export { ClaudeAgentManager } from './manager.js'; +export { MockAgentManager, type MockAgentScenario } from './mock-manager.js'; diff --git a/src/agent/mock-manager.test.ts b/src/agent/mock-manager.test.ts new file mode 100644 index 0000000..fb48acd --- /dev/null +++ b/src/agent/mock-manager.test.ts @@ -0,0 +1,595 @@ +/** + * 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 } 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', { + outcome: 'success', + delay: 100, + message: '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', { + outcome: 'success', + delay: 100, + message: '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 crash scenario', () => { + it('should emit agent:crashed and set result.success=false', async () => { + manager.setScenario('crash-agent', { + outcome: 'crash', + delay: 0, + message: '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 waiting_for_input scenario + // =========================================================================== + + describe('spawn with waiting_for_input scenario', () => { + it('should emit agent:waiting and set status to waiting_for_input', async () => { + manager.setScenario('waiting-agent', { + outcome: 'waiting_for_input', + delay: 0, + 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.question).toBe('Should I continue?'); + }); + }); + + // =========================================================================== + // resume() after waiting_for_input + // =========================================================================== + + describe('resume after waiting_for_input', () => { + it('should emit agent:resumed and continue with scenario', async () => { + manager.setScenario('resume-agent', { + outcome: 'waiting_for_input', + delay: 0, + 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 + await manager.resume(agent.id, '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, 'input')).rejects.toThrow( + 'is not waiting for input' + ); + }); + + it('should throw if agent not found', async () => { + await expect(manager.resume('non-existent-id', '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', { + outcome: 'success', + delay: 1000, + message: '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 crash scenario for one agent + manager.setScenario('crasher', { + outcome: 'crash', + delay: 0, + message: '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', { + outcome: 'crash', + delay: 0, + }); + + // 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', { outcome: 'crash', delay: 0 }); + 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', { + outcome: 'waiting_for_input', + delay: 0, + }); + 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 = { + outcome: 'crash', + delay: 0, + message: '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', { outcome: 'success', 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); + }); + }); +});