test(07-01): add comprehensive tests for MockAgentManager

28 test cases covering:
- spawn() with default scenario (immediate success)
- spawn() with configured delay
- spawn() with crash scenario (agent:crashed, result.success=false)
- spawn() with waiting_for_input (agent:waiting, status='waiting_for_input')
- resume() after waiting_for_input (agent:resumed, continues scenario)
- stop() kills scheduled completion, emits agent:stopped
- list() returns all agents with correct status
- get() and getByName() lookups
- setScenario() overrides for specific agent names
- Event emission order verification (spawned before completion)
- Name uniqueness validation
- Constructor options (eventBus, defaultScenario)
- clear() cleanup

Export MockAgentManager and MockAgentScenario from src/agent/index.ts
This commit is contained in:
Lukas May
2026-01-31 08:43:15 +01:00
parent 6148af784e
commit e305375820
2 changed files with 597 additions and 1 deletions

View File

@@ -14,5 +14,6 @@ export type {
AgentManager,
} from './types.js';
// Adapter implementation
// Adapter implementations
export { ClaudeAgentManager } from './manager.js';
export { MockAgentManager, type MockAgentScenario } from './mock-manager.js';

View File

@@ -0,0 +1,595 @@
/**
* MockAgentManager Tests
*
* Comprehensive test suite for the MockAgentManager adapter covering
* all scenario types: success, crash, waiting_for_input.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MockAgentManager, type MockAgentScenario } from './mock-manager.js';
import type { EventBus, DomainEvent } from '../events/types.js';
// =============================================================================
// Test Helpers
// =============================================================================
/**
* Create a mock EventBus that captures emitted events.
*/
function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } {
const emittedEvents: DomainEvent[] = [];
return {
emittedEvents,
emit<T extends DomainEvent>(event: T): void {
emittedEvents.push(event);
},
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
// =============================================================================
// Tests
// =============================================================================
describe('MockAgentManager', () => {
let manager: MockAgentManager;
let eventBus: ReturnType<typeof createMockEventBus>;
beforeEach(() => {
vi.useFakeTimers();
eventBus = createMockEventBus();
manager = new MockAgentManager({ eventBus });
});
afterEach(() => {
manager.clear();
vi.useRealTimers();
});
// ===========================================================================
// spawn() with default scenario (immediate success)
// ===========================================================================
describe('spawn with default scenario', () => {
it('should create agent with running status', async () => {
const agent = await manager.spawn({
name: 'test-agent',
taskId: 'task-1',
prompt: 'Do something',
});
expect(agent.name).toBe('test-agent');
expect(agent.taskId).toBe('task-1');
expect(agent.status).toBe('running');
expect(agent.id).toBeDefined();
expect(agent.sessionId).toBeDefined();
expect(agent.worktreeId).toBeDefined();
});
it('should emit agent:spawned event', async () => {
await manager.spawn({
name: 'spawned-test',
taskId: 'task-1',
prompt: 'Do something',
});
expect(eventBus.emittedEvents.length).toBeGreaterThanOrEqual(1);
const spawnedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:spawned');
expect(spawnedEvent).toBeDefined();
expect((spawnedEvent as any).payload.name).toBe('spawned-test');
expect((spawnedEvent as any).payload.taskId).toBe('task-1');
});
it('should complete with success after timer fires', async () => {
const agent = await manager.spawn({
name: 'success-test',
taskId: 'task-1',
prompt: 'Do something',
});
// Timer hasn't fired yet
expect(agent.status).toBe('running');
// Advance timers
await vi.advanceTimersByTimeAsync(0);
// Check status changed
const updated = await manager.get(agent.id);
expect(updated?.status).toBe('idle');
// Check result available
const result = await manager.getResult(agent.id);
expect(result).not.toBeNull();
expect(result?.success).toBe(true);
expect(result?.message).toBe('Task completed successfully');
});
it('should emit agent:stopped event on success completion', async () => {
await manager.spawn({
name: 'stop-event-test',
taskId: 'task-1',
prompt: 'Do something',
});
await vi.advanceTimersByTimeAsync(0);
const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped');
expect(stoppedEvent).toBeDefined();
expect((stoppedEvent as any).payload.reason).toBe('task_complete');
});
});
// ===========================================================================
// spawn() with configured delay
// ===========================================================================
describe('spawn with configured delay', () => {
it('should not complete before delay expires', async () => {
manager.setScenario('delayed-agent', {
outcome: 'success',
delay: 100,
message: 'Delayed completion',
});
const agent = await manager.spawn({
name: 'delayed-agent',
taskId: 'task-1',
prompt: 'Do something slowly',
});
// Advance by less than delay
await vi.advanceTimersByTimeAsync(50);
const updated = await manager.get(agent.id);
expect(updated?.status).toBe('running');
});
it('should complete after delay expires', async () => {
manager.setScenario('delayed-agent', {
outcome: 'success',
delay: 100,
message: 'Delayed completion',
});
const agent = await manager.spawn({
name: 'delayed-agent',
taskId: 'task-1',
prompt: 'Do something slowly',
});
// Advance past delay
await vi.advanceTimersByTimeAsync(100);
const updated = await manager.get(agent.id);
expect(updated?.status).toBe('idle');
const result = await manager.getResult(agent.id);
expect(result?.message).toBe('Delayed completion');
});
});
// ===========================================================================
// spawn() with crash scenario
// ===========================================================================
describe('spawn with crash scenario', () => {
it('should emit agent:crashed and set result.success=false', async () => {
manager.setScenario('crash-agent', {
outcome: 'crash',
delay: 0,
message: 'Something went terribly wrong',
});
const agent = await manager.spawn({
name: 'crash-agent',
taskId: 'task-1',
prompt: 'Do something risky',
});
await vi.advanceTimersByTimeAsync(0);
// Check status
const updated = await manager.get(agent.id);
expect(updated?.status).toBe('crashed');
// Check result
const result = await manager.getResult(agent.id);
expect(result?.success).toBe(false);
expect(result?.message).toBe('Something went terribly wrong');
// Check event
const crashedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:crashed');
expect(crashedEvent).toBeDefined();
expect((crashedEvent as any).payload.error).toBe('Something went terribly wrong');
});
});
// ===========================================================================
// spawn() with waiting_for_input scenario
// ===========================================================================
describe('spawn with waiting_for_input scenario', () => {
it('should emit agent:waiting and set status to waiting_for_input', async () => {
manager.setScenario('waiting-agent', {
outcome: 'waiting_for_input',
delay: 0,
question: 'Should I continue?',
});
const agent = await manager.spawn({
name: 'waiting-agent',
taskId: 'task-1',
prompt: 'Ask a question',
});
await vi.advanceTimersByTimeAsync(0);
// Check status
const updated = await manager.get(agent.id);
expect(updated?.status).toBe('waiting_for_input');
// Check event
const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting');
expect(waitingEvent).toBeDefined();
expect((waitingEvent as any).payload.question).toBe('Should I continue?');
});
});
// ===========================================================================
// resume() after waiting_for_input
// ===========================================================================
describe('resume after waiting_for_input', () => {
it('should emit agent:resumed and continue with scenario', async () => {
manager.setScenario('resume-agent', {
outcome: 'waiting_for_input',
delay: 0,
question: 'Need your input',
});
const agent = await manager.spawn({
name: 'resume-agent',
taskId: 'task-1',
prompt: 'Start working',
});
// Let agent reach waiting state
await vi.advanceTimersByTimeAsync(0);
const waitingAgent = await manager.get(agent.id);
expect(waitingAgent?.status).toBe('waiting_for_input');
// Resume the agent
await manager.resume(agent.id, 'Continue with this input');
// Check agent:resumed event emitted
const resumedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:resumed');
expect(resumedEvent).toBeDefined();
expect((resumedEvent as any).payload.agentId).toBe(agent.id);
expect((resumedEvent as any).payload.sessionId).toBe(agent.sessionId);
// Status should be running again
const runningAgent = await manager.get(agent.id);
expect(runningAgent?.status).toBe('running');
// Let it complete
await vi.advanceTimersByTimeAsync(0);
const completedAgent = await manager.get(agent.id);
expect(completedAgent?.status).toBe('idle');
const result = await manager.getResult(agent.id);
expect(result?.success).toBe(true);
});
it('should throw if agent not waiting for input', async () => {
const agent = await manager.spawn({
name: 'not-waiting',
taskId: 'task-1',
prompt: 'Work',
});
await expect(manager.resume(agent.id, 'input')).rejects.toThrow(
'is not waiting for input'
);
});
it('should throw if agent not found', async () => {
await expect(manager.resume('non-existent-id', 'input')).rejects.toThrow(
'not found'
);
});
});
// ===========================================================================
// stop() kills scheduled completion
// ===========================================================================
describe('stop', () => {
it('should cancel scheduled completion and emit agent:stopped', async () => {
manager.setScenario('stoppable-agent', {
outcome: 'success',
delay: 1000,
message: 'Should not see this',
});
const agent = await manager.spawn({
name: 'stoppable-agent',
taskId: 'task-1',
prompt: 'Long running task',
});
// Stop before completion
await manager.stop(agent.id);
// Check status
const updated = await manager.get(agent.id);
expect(updated?.status).toBe('stopped');
// Check event
const stoppedEvent = eventBus.emittedEvents.find(
(e) => e.type === 'agent:stopped' && (e as any).payload.reason === 'user_requested'
);
expect(stoppedEvent).toBeDefined();
// Advance time - should not complete now
await vi.advanceTimersByTimeAsync(1000);
const stillStopped = await manager.get(agent.id);
expect(stillStopped?.status).toBe('stopped');
});
it('should throw if agent not found', async () => {
await expect(manager.stop('non-existent-id')).rejects.toThrow('not found');
});
});
// ===========================================================================
// list() returns all agents with correct status
// ===========================================================================
describe('list', () => {
it('should return all agents', async () => {
await manager.spawn({ name: 'agent-1', taskId: 't1', prompt: 'p1' });
await manager.spawn({ name: 'agent-2', taskId: 't2', prompt: 'p2' });
await manager.spawn({ name: 'agent-3', taskId: 't3', prompt: 'p3' });
const agents = await manager.list();
expect(agents.length).toBe(3);
expect(agents.map((a) => a.name).sort()).toEqual(['agent-1', 'agent-2', 'agent-3']);
});
it('should return empty array when no agents', async () => {
const agents = await manager.list();
expect(agents).toEqual([]);
});
});
// ===========================================================================
// get() and getByName() lookups
// ===========================================================================
describe('get and getByName', () => {
it('get should return agent by ID', async () => {
const spawned = await manager.spawn({
name: 'get-test',
taskId: 't1',
prompt: 'p1',
});
const found = await manager.get(spawned.id);
expect(found).not.toBeNull();
expect(found?.name).toBe('get-test');
});
it('get should return null for unknown ID', async () => {
const found = await manager.get('unknown-id');
expect(found).toBeNull();
});
it('getByName should return agent by name', async () => {
await manager.spawn({ name: 'named-agent', taskId: 't1', prompt: 'p1' });
const found = await manager.getByName('named-agent');
expect(found).not.toBeNull();
expect(found?.name).toBe('named-agent');
});
it('getByName should return null for unknown name', async () => {
const found = await manager.getByName('unknown-name');
expect(found).toBeNull();
});
});
// ===========================================================================
// setScenario() overrides for specific agent names
// ===========================================================================
describe('setScenario overrides', () => {
it('should use scenario override for specific agent name', async () => {
// Set crash scenario for one agent
manager.setScenario('crasher', {
outcome: 'crash',
delay: 0,
message: 'Intentional crash',
});
// Spawn two agents - one with override, one with default
const crasher = await manager.spawn({
name: 'crasher',
taskId: 't1',
prompt: 'p1',
});
const normal = await manager.spawn({
name: 'normal',
taskId: 't2',
prompt: 'p2',
});
await vi.advanceTimersByTimeAsync(0);
// Crasher should have crashed
const crasherUpdated = await manager.get(crasher.id);
expect(crasherUpdated?.status).toBe('crashed');
// Normal should have succeeded
const normalUpdated = await manager.get(normal.id);
expect(normalUpdated?.status).toBe('idle');
});
it('should allow clearing scenario override', async () => {
manager.setScenario('flip-flop', {
outcome: 'crash',
delay: 0,
});
// First spawn crashes
const first = await manager.spawn({
name: 'flip-flop',
taskId: 't1',
prompt: 'p1',
});
await vi.advanceTimersByTimeAsync(0);
expect((await manager.get(first.id))?.status).toBe('crashed');
// Clear scenario and remove agent
manager.clearScenario('flip-flop');
manager.clear();
// Second spawn succeeds (default scenario)
const second = await manager.spawn({
name: 'flip-flop',
taskId: 't2',
prompt: 'p2',
});
await vi.advanceTimersByTimeAsync(0);
expect((await manager.get(second.id))?.status).toBe('idle');
});
});
// ===========================================================================
// Event emission order verification
// ===========================================================================
describe('event emission order', () => {
it('should emit spawned before completion events', async () => {
await manager.spawn({ name: 'order-test', taskId: 't1', prompt: 'p1' });
await vi.advanceTimersByTimeAsync(0);
const eventTypes = eventBus.emittedEvents.map((e) => e.type);
const spawnedIndex = eventTypes.indexOf('agent:spawned');
const stoppedIndex = eventTypes.indexOf('agent:stopped');
expect(spawnedIndex).toBeLessThan(stoppedIndex);
});
it('should emit spawned before crashed', async () => {
manager.setScenario('crash-order', { outcome: 'crash', delay: 0 });
await manager.spawn({ name: 'crash-order', taskId: 't1', prompt: 'p1' });
await vi.advanceTimersByTimeAsync(0);
const eventTypes = eventBus.emittedEvents.map((e) => e.type);
const spawnedIndex = eventTypes.indexOf('agent:spawned');
const crashedIndex = eventTypes.indexOf('agent:crashed');
expect(spawnedIndex).toBeLessThan(crashedIndex);
});
it('should emit spawned before waiting', async () => {
manager.setScenario('wait-order', {
outcome: 'waiting_for_input',
delay: 0,
});
await manager.spawn({ name: 'wait-order', taskId: 't1', prompt: 'p1' });
await vi.advanceTimersByTimeAsync(0);
const eventTypes = eventBus.emittedEvents.map((e) => e.type);
const spawnedIndex = eventTypes.indexOf('agent:spawned');
const waitingIndex = eventTypes.indexOf('agent:waiting');
expect(spawnedIndex).toBeLessThan(waitingIndex);
});
});
// ===========================================================================
// Name uniqueness validation
// ===========================================================================
describe('name uniqueness', () => {
it('should throw when spawning agent with duplicate name', async () => {
await manager.spawn({ name: 'unique-name', taskId: 't1', prompt: 'p1' });
await expect(
manager.spawn({ name: 'unique-name', taskId: 't2', prompt: 'p2' })
).rejects.toThrow("Agent with name 'unique-name' already exists");
});
});
// ===========================================================================
// Constructor options
// ===========================================================================
describe('constructor options', () => {
it('should work without eventBus', async () => {
const noEventManager = new MockAgentManager();
const agent = await noEventManager.spawn({
name: 'no-events',
taskId: 't1',
prompt: 'p1',
});
expect(agent.name).toBe('no-events');
noEventManager.clear();
});
it('should use provided default scenario', async () => {
const customDefault: MockAgentScenario = {
outcome: 'crash',
delay: 0,
message: 'Default crash',
};
const customManager = new MockAgentManager({
eventBus,
defaultScenario: customDefault,
});
const agent = await customManager.spawn({
name: 'custom-default',
taskId: 't1',
prompt: 'p1',
});
await vi.advanceTimersByTimeAsync(0);
expect((await customManager.get(agent.id))?.status).toBe('crashed');
customManager.clear();
});
});
// ===========================================================================
// clear() cleanup
// ===========================================================================
describe('clear', () => {
it('should remove all agents and cancel pending timers', async () => {
manager.setScenario('pending', { outcome: 'success', delay: 1000 });
await manager.spawn({ name: 'pending', taskId: 't1', prompt: 'p1' });
await manager.spawn({ name: 'another', taskId: 't2', prompt: 'p2' });
expect((await manager.list()).length).toBe(2);
manager.clear();
expect((await manager.list()).length).toBe(0);
});
});
});