/** * MultiProviderAgentManager Tests * * Unit tests for the MultiProviderAgentManager adapter. * Mocks child_process.spawn since we can't spawn real Claude CLI in tests. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MultiProviderAgentManager } from './manager.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; import { EventEmitterBus } from '../events/index.js'; import type { DomainEvent } from '../events/index.js'; // Mock child_process.spawn and execFile vi.mock('node:child_process', () => ({ spawn: vi.fn(), execFile: vi.fn((_cmd: string, _args: string[], _opts: unknown, cb?: Function) => { if (cb) cb(null, '', ''); }), })); // Import spawn to get the mock import { spawn } from 'node:child_process'; const mockSpawn = vi.mocked(spawn); // Mock SimpleGitWorktreeManager so spawn doesn't need a real git repo vi.mock('../git/manager.js', () => { return { SimpleGitWorktreeManager: class MockWorktreeManager { create = vi.fn().mockResolvedValue({ id: 'workspace', path: '/tmp/test-workspace/agent-workdirs/gastown/workspace', branch: 'agent/gastown' }); get = vi.fn().mockResolvedValue(null); list = vi.fn().mockResolvedValue([]); remove = vi.fn().mockResolvedValue(undefined); }, }; }); // Mock fs operations for file-based output vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); // Create a mock write stream const mockWriteStream = { write: vi.fn(), end: vi.fn(), on: vi.fn(), }; return { ...actual, openSync: vi.fn().mockReturnValue(99), closeSync: vi.fn(), mkdirSync: vi.fn(), writeFileSync: vi.fn(), createWriteStream: vi.fn().mockReturnValue(mockWriteStream), existsSync: vi.fn().mockReturnValue(true), // Default to true for our new validation }; }); vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); return { ...actual, readFile: vi.fn().mockResolvedValue(''), readdir: vi.fn().mockRejectedValue(new Error('ENOENT')), rm: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), }; }); // Mock FileTailer to avoid actual file watching vi.mock('./file-tailer.js', () => ({ FileTailer: class MockFileTailer { start = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined); isStopped = false; }, })); import type { ChildProcess } from 'node:child_process'; /** * Create a mock ChildProcess for detached spawning. * The process is spawned detached and unreferenced. */ function createMockChildProcess(options?: { pid?: number; }) { const { pid = 123 } = options ?? {}; // Create a minimal mock that satisfies the actual usage in spawnDetached const childProcess = { pid, unref: vi.fn(), on: vi.fn().mockReturnThis(), kill: vi.fn(), } as unknown as ChildProcess; return childProcess; } describe('MultiProviderAgentManager', () => { let manager: MultiProviderAgentManager; let mockRepository: AgentRepository; let mockProjectRepository: ProjectRepository; let eventBus: EventEmitterBus; let capturedEvents: DomainEvent[]; const mockAgent = { id: 'agent-123', name: 'gastown', taskId: 'task-456', initiativeId: null as string | null, sessionId: 'session-789', worktreeId: 'gastown', status: 'idle' as const, mode: 'execute' as const, provider: 'claude', accountId: null as string | null, pid: null as number | null, outputFilePath: null as string | null, result: null as string | null, pendingQuestions: null as string | null, createdAt: new Date(), updatedAt: new Date(), }; beforeEach(() => { vi.clearAllMocks(); capturedEvents = []; mockRepository = { create: vi.fn().mockResolvedValue(mockAgent), findById: vi.fn().mockResolvedValue(mockAgent), findByName: vi.fn().mockResolvedValue(null), // No duplicate by default findByTaskId: vi.fn().mockResolvedValue(mockAgent), findBySessionId: vi.fn().mockResolvedValue(mockAgent), findAll: vi.fn().mockResolvedValue([mockAgent]), findByStatus: vi.fn().mockResolvedValue([mockAgent]), update: vi.fn().mockResolvedValue(mockAgent), delete: vi.fn().mockResolvedValue(undefined), findWaitingWithContext: vi.fn().mockResolvedValue([]), }; mockProjectRepository = { create: vi.fn(), findById: vi.fn(), findByName: vi.fn(), findAll: vi.fn().mockResolvedValue([]), update: vi.fn(), delete: vi.fn(), addProjectToInitiative: vi.fn(), removeProjectFromInitiative: vi.fn(), findProjectsByInitiativeId: vi.fn().mockResolvedValue([]), setInitiativeProjects: vi.fn(), }; eventBus = new EventEmitterBus(); // Subscribe to all agent events eventBus.on('agent:spawned', (e) => capturedEvents.push(e)); eventBus.on('agent:stopped', (e) => capturedEvents.push(e)); eventBus.on('agent:crashed', (e) => capturedEvents.push(e)); eventBus.on('agent:resumed', (e) => capturedEvents.push(e)); eventBus.on('agent:waiting', (e) => capturedEvents.push(e)); manager = new MultiProviderAgentManager( mockRepository, '/tmp/test-workspace', mockProjectRepository, undefined, eventBus ); }); afterEach(() => { vi.restoreAllMocks(); }); describe('spawn', () => { it('creates agent record with provided name', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); const result = await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test task', }); expect(mockRepository.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'gastown' }) ); expect(result.name).toBe('gastown'); }); it('rejects duplicate agent names', async () => { mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent); await expect( manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', }) ).rejects.toThrow("Agent with name 'gastown' already exists"); }); it('emits AgentSpawned event with name', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', }); const spawnedEvent = capturedEvents.find( (e) => e.type === 'agent:spawned' ); expect(spawnedEvent).toBeDefined(); expect( (spawnedEvent as { payload: { name: string } }).payload.name ).toBe('gastown'); }); it('writes diagnostic files for workdir verification', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); // Mock fs/promises writeFile to capture diagnostic file writing const { writeFile } = await import('node:fs/promises'); const mockWriteFile = vi.mocked(writeFile); // The existsSync is already mocked globally to return true await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test task', }); // Verify diagnostic file was written const diagnosticCalls = mockWriteFile.mock.calls.filter(call => call[0].toString().includes('spawn-diagnostic.json') ); expect(diagnosticCalls).toHaveLength(1); // Parse the diagnostic data to verify structure const diagnosticCall = diagnosticCalls[0]; const diagnosticData = JSON.parse(diagnosticCall[1] as string); expect(diagnosticData).toMatchObject({ agentId: expect.any(String), alias: 'gastown', intendedCwd: expect.stringContaining('/agent-workdirs/gastown/workspace'), worktreeId: 'gastown', provider: 'claude', command: expect.any(String), args: expect.any(Array), env: expect.any(Object), cwdExistsAtSpawn: true, initiativeId: null, customCwdProvided: false, accountId: null, timestamp: expect.any(String), }); }); it('uses custom cwd if provided', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); await manager.spawn({ name: 'chinatown', taskId: 'task-789', prompt: 'Test task', cwd: '/custom/path', }); // Verify spawn was called with custom cwd expect(mockSpawn).toHaveBeenCalledWith( 'claude', expect.arrayContaining(['-p', expect.stringContaining('Test task'), '--output-format', 'stream-json']), expect.objectContaining({ cwd: '/custom/path' }) ); }); }); describe('stop', () => { it('stops running agent and updates status', async () => { // When we call stop, it looks up the agent by ID // The repository mock returns mockAgent which has id 'agent-123' await manager.stop(mockAgent.id); expect(mockRepository.update).toHaveBeenCalledWith( mockAgent.id, { status: 'stopped', pendingQuestions: null } ); }); it('kills detached process if running', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); // Spawn returns immediately since process is detached const spawned = await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', }); // Now stop using the returned agent ID await manager.stop(spawned.id); // Verify status was updated (process.kill is called internally, not on the child object) expect(mockRepository.update).toHaveBeenCalledWith( spawned.id, { status: 'stopped', pendingQuestions: null } ); }); it('throws if agent not found', async () => { mockRepository.findById = vi.fn().mockResolvedValue(null); await expect(manager.stop('nonexistent')).rejects.toThrow( "Agent 'nonexistent' not found" ); }); it('emits AgentStopped event with user_requested reason', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); const spawned = await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', }); await manager.stop(spawned.id); const stoppedEvent = capturedEvents.find( (e) => e.type === 'agent:stopped' ); expect(stoppedEvent).toBeDefined(); expect( (stoppedEvent as { payload: { reason: string } }).payload.reason ).toBe('user_requested'); }); }); describe('list', () => { it('returns all agents with names', async () => { const agents = await manager.list(); expect(agents).toHaveLength(1); expect(agents[0].name).toBe('gastown'); }); }); describe('get', () => { it('finds agent by id', async () => { const agent = await manager.get('agent-123'); expect(mockRepository.findById).toHaveBeenCalledWith('agent-123'); expect(agent?.id).toBe('agent-123'); }); it('returns null if agent not found', async () => { mockRepository.findById = vi.fn().mockResolvedValue(null); const agent = await manager.get('nonexistent'); expect(agent).toBeNull(); }); }); describe('getByName', () => { it('finds agent by name', async () => { mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent); const agent = await manager.getByName('gastown'); expect(mockRepository.findByName).toHaveBeenCalledWith('gastown'); expect(agent?.name).toBe('gastown'); }); it('returns null if agent not found', async () => { mockRepository.findByName = vi.fn().mockResolvedValue(null); const agent = await manager.getByName('nonexistent'); expect(agent).toBeNull(); }); }); describe('resume', () => { it('resumes agent waiting for input with answers map', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'waiting_for_input', }); const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); await manager.resume(mockAgent.id, { q1: 'Answer one', q2: 'Answer two' }); // Verify spawn was called with resume args expect(mockSpawn).toHaveBeenCalledWith( 'claude', expect.arrayContaining([ '--resume', 'session-789', '--output-format', 'stream-json', ]), expect.any(Object) ); }); it('rejects if agent not waiting for input', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'running', }); await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow( 'not waiting for input' ); }); it('rejects if agent has no session', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'waiting_for_input', sessionId: null, }); await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow( 'has no session to resume' ); }); it('emits AgentResumed event', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'waiting_for_input', }); const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); await manager.resume(mockAgent.id, { q1: 'User answer' }); const resumedEvent = capturedEvents.find( (e) => e.type === 'agent:resumed' ); expect(resumedEvent).toBeDefined(); expect( (resumedEvent as { payload: { sessionId: string } }).payload.sessionId ).toBe('session-789'); }); }); describe('sendUserMessage', () => { it('resumes errand agent in idle status', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'idle', }); const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); await expect(manager.sendUserMessage(mockAgent.id, 'my answer')).resolves.not.toThrow(); }); it('rejects if agent is stopped', async () => { mockRepository.findById = vi.fn().mockResolvedValue({ ...mockAgent, status: 'stopped', }); await expect(manager.sendUserMessage(mockAgent.id, 'message')).rejects.toThrow( 'Agent is not running' ); }); }); describe('getResult', () => { it('returns null when agent has no result', async () => { const result = await manager.getResult('agent-123'); expect(result).toBeNull(); }); }); describe('delete', () => { it('deletes agent and clears active state', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); // Spawn an agent first const spawned = await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', }); // Delete the agent await manager.delete(spawned.id); // Verify DB record was deleted expect(mockRepository.delete).toHaveBeenCalledWith(spawned.id); }); it('emits agent:deleted event', async () => { const mockChild = createMockChildProcess(); mockSpawn.mockReturnValue(mockChild); eventBus.on('agent:deleted', (e) => capturedEvents.push(e)); const spawned = await manager.spawn({ name: 'gastown', taskId: 'task-456', prompt: 'Test', }); await manager.delete(spawned.id); const deletedEvent = capturedEvents.find( (e) => e.type === 'agent:deleted' ); expect(deletedEvent).toBeDefined(); expect( (deletedEvent as { payload: { name: string } }).payload.name ).toBe('gastown'); }); it('throws if agent not found', async () => { mockRepository.findById = vi.fn().mockResolvedValue(null); await expect(manager.delete('nonexistent')).rejects.toThrow( "Agent 'nonexistent' not found" ); }); it('handles missing workdir gracefully', async () => { // Agent exists in DB but has no active state and workdir doesn't exist // The delete should succeed (best-effort cleanup) await manager.delete(mockAgent.id); expect(mockRepository.delete).toHaveBeenCalledWith(mockAgent.id); }); }); });