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:
420
src/agent/manager.test.ts
Normal file
420
src/agent/manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user