test(04-03): add comprehensive tests for ClaudeAgentManager

- Test spawn with worktree and agent record creation
- Test duplicate name rejection
- Test AgentSpawned event emission
- Test stop with subprocess kill and status update
- Test list, get, getByName operations
- Test resume with session_id and --resume flag
- Test AgentResumed event emission
- Fix: use agent.id from repository for activeAgents tracking
This commit is contained in:
Lukas May
2026-01-30 20:07:28 +01:00
parent 81934237ca
commit b718d59cbf
2 changed files with 425 additions and 2 deletions

420
src/agent/manager.test.ts Normal file
View File

@@ -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<typeof execa>);
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<typeof execa>);
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<typeof execa>);
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<typeof execa>);
// 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<typeof execa>);
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<typeof execa>);
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<typeof execa>);
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();
});
});
});

View File

@@ -68,7 +68,7 @@ export class ClaudeAgentManager implements AgentManager {
*/
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
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',