diff --git a/src/agent/manager.test.ts b/src/agent/manager.test.ts new file mode 100644 index 0000000..bba6248 --- /dev/null +++ b/src/agent/manager.test.ts @@ -0,0 +1,420 @@ +/** + * ClaudeAgentManager Tests + * + * Unit tests for the ClaudeAgentManager adapter. + * Mocks execa since we can't spawn real Claude CLI in tests. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClaudeAgentManager } from './manager.js'; +import type { AgentRepository } from '../db/repositories/agent-repository.js'; +import type { WorktreeManager, Worktree } from '../git/types.js'; +import { EventEmitterBus } from '../events/index.js'; +import type { DomainEvent } from '../events/index.js'; + +// Mock execa +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import { execa } from 'execa'; +const mockExeca = vi.mocked(execa); + +describe('ClaudeAgentManager', () => { + let manager: ClaudeAgentManager; + let mockRepository: AgentRepository; + let mockWorktreeManager: WorktreeManager; + let eventBus: EventEmitterBus; + let capturedEvents: DomainEvent[]; + + const mockWorktree: Worktree = { + id: 'worktree-123', + branch: 'agent/gastown', + path: '/tmp/worktree', + isMainWorktree: false, + }; + + const mockAgent = { + id: 'agent-123', + name: 'gastown', + taskId: 'task-456', + sessionId: 'session-789', + worktreeId: 'worktree-123', + status: 'idle' as const, + 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]), + updateStatus: vi + .fn() + .mockResolvedValue({ ...mockAgent, status: 'running' }), + updateSessionId: vi + .fn() + .mockResolvedValue({ ...mockAgent, sessionId: 'new-session' }), + delete: vi.fn().mockResolvedValue(undefined), + }; + + mockWorktreeManager = { + create: vi.fn().mockResolvedValue(mockWorktree), + remove: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([mockWorktree]), + get: vi.fn().mockResolvedValue(mockWorktree), + diff: vi.fn().mockResolvedValue({ files: [], summary: '' }), + merge: vi.fn().mockResolvedValue({ success: true, message: 'ok' }), + }; + + 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 ClaudeAgentManager( + mockRepository, + mockWorktreeManager, + eventBus + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('spawn', () => { + it('creates worktree and agent record with name', async () => { + const mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => + Promise.resolve({ + stdout: + '{"type":"result","subtype":"success","session_id":"sess-123","result":"done"}', + stderr: '', + }), + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + const result = await manager.spawn({ + name: 'gastown', + taskId: 'task-456', + prompt: 'Test task', + }); + + expect(mockWorktreeManager.create).toHaveBeenCalledWith( + expect.any(String), + 'agent/gastown' + ); + 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 mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => + Promise.resolve({ + stdout: + '{"type":"result","subtype":"success","session_id":"sess-123","result":"done"}', + stderr: '', + }), + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + 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('uses custom cwd if provided', async () => { + const mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => + Promise.resolve({ + stdout: + '{"type":"result","subtype":"success","session_id":"sess-123","result":"done"}', + stderr: '', + }), + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + await manager.spawn({ + name: 'chinatown', + taskId: 'task-789', + prompt: 'Test task', + cwd: '/custom/path', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'claude', + ['-p', 'Test task', '--output-format', '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.updateStatus).toHaveBeenCalledWith( + mockAgent.id, + 'stopped' + ); + }); + + it('kills subprocess if running', async () => { + // Create a manager and spawn an agent first + const killFn = vi.fn(); + const mockSubprocess = { + pid: 123, + kill: killFn, + then: () => new Promise(() => {}), // Never resolves + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + // Spawn returns immediately, we get the agent id from create mock + const spawned = await manager.spawn({ + name: 'gastown', + taskId: 'task-456', + prompt: 'Test', + }); + + // Now stop using the returned agent ID + // But the spawned id comes from repository.create which returns mockAgent.id + await manager.stop(spawned.id); + + expect(killFn).toHaveBeenCalledWith('SIGTERM'); + expect(mockRepository.updateStatus).toHaveBeenCalledWith( + spawned.id, + 'stopped' + ); + }); + + 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 mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => new Promise(() => {}), + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + 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', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'waiting_for_input', + }); + + const mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => + Promise.resolve({ + stdout: + '{"type":"result","subtype":"success","session_id":"sess-123","result":"continued"}', + stderr: '', + }), + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + await manager.resume(mockAgent.id, 'User response'); + + expect(mockExeca).toHaveBeenCalledWith( + 'claude', + ['-p', 'User response', '--resume', 'session-789', '--output-format', '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, 'Response')).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, 'Response')).rejects.toThrow( + 'has no session to resume' + ); + }); + + it('rejects if worktree not found', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'waiting_for_input', + }); + mockWorktreeManager.get = vi.fn().mockResolvedValue(null); + + await expect(manager.resume(mockAgent.id, 'Response')).rejects.toThrow( + 'Worktree' + ); + }); + + it('emits AgentResumed event', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'waiting_for_input', + }); + + const mockSubprocess = { + pid: 123, + kill: vi.fn(), + then: () => + Promise.resolve({ + stdout: + '{"type":"result","subtype":"success","session_id":"sess-123","result":"continued"}', + stderr: '', + }), + catch: () => mockSubprocess, + }; + mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); + + await manager.resume(mockAgent.id, 'User response'); + + const resumedEvent = capturedEvents.find( + (e) => e.type === 'agent:resumed' + ); + expect(resumedEvent).toBeDefined(); + expect( + (resumedEvent as { payload: { sessionId: string } }).payload.sessionId + ).toBe('session-789'); + }); + }); + + describe('getResult', () => { + it('returns null when agent has no result', async () => { + const result = await manager.getResult('agent-123'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/agent/manager.ts b/src/agent/manager.ts index 44a885a..97fcfbd 100644 --- a/src/agent/manager.ts +++ b/src/agent/manager.ts @@ -68,7 +68,7 @@ export class ClaudeAgentManager implements AgentManager { */ async spawn(options: SpawnAgentOptions): Promise { const { name, taskId, prompt, cwd } = options; - const agentId = randomUUID(); + const worktreeId = randomUUID(); const branchName = `agent/${name}`; // Check name uniqueness @@ -78,7 +78,7 @@ export class ClaudeAgentManager implements AgentManager { } // 1. Create isolated worktree - const worktree = await this.worktreeManager.create(agentId, branchName); + const worktree = await this.worktreeManager.create(worktreeId, branchName); // 2. Create agent record (session ID null until first run completes) const agent = await this.repository.create({ @@ -89,6 +89,9 @@ export class ClaudeAgentManager implements AgentManager { status: 'running', }); + // Use agent.id from repository for all tracking + const agentId = agent.id; + // 3. Start Claude CLI in background const subprocess = execa( 'claude',