- 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
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|