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