Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
907 lines
30 KiB
TypeScript
907 lines
30 KiB
TypeScript
/**
|
|
* 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, AgentStoppedEvent } 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', {
|
|
status: 'done',
|
|
delay: 100,
|
|
result: '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', {
|
|
status: 'done',
|
|
delay: 100,
|
|
result: '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 error scenario', () => {
|
|
it('should emit agent:crashed and set result.success=false', async () => {
|
|
manager.setScenario('crash-agent', {
|
|
status: 'error',
|
|
delay: 0,
|
|
error: '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 question scenario
|
|
// ===========================================================================
|
|
|
|
describe('spawn with questions scenario', () => {
|
|
it('should emit agent:waiting and set status to waiting_for_input', async () => {
|
|
manager.setScenario('waiting-agent', {
|
|
status: 'questions',
|
|
delay: 0,
|
|
questions: [{ id: 'q1', 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.questions[0].question).toBe('Should I continue?');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// resume() after waiting_for_input
|
|
// ===========================================================================
|
|
|
|
describe('resume after questions', () => {
|
|
it('should emit agent:resumed and continue with scenario', async () => {
|
|
manager.setScenario('resume-agent', {
|
|
status: 'questions',
|
|
delay: 0,
|
|
questions: [{ id: 'q1', 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 with answers map
|
|
await manager.resume(agent.id, { q1: '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, { q1: 'input' })).rejects.toThrow(
|
|
'is not waiting for input'
|
|
);
|
|
});
|
|
|
|
it('should throw if agent not found', async () => {
|
|
await expect(manager.resume('non-existent-id', { q1: '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', {
|
|
status: 'done',
|
|
delay: 1000,
|
|
result: '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 error scenario for one agent
|
|
manager.setScenario('crasher', {
|
|
status: 'error',
|
|
delay: 0,
|
|
error: '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', {
|
|
status: 'error',
|
|
delay: 0,
|
|
error: 'Crash for test',
|
|
});
|
|
|
|
// 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', { status: 'error', delay: 0, error: 'Crash' });
|
|
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', {
|
|
status: 'questions',
|
|
delay: 0,
|
|
questions: [{ id: 'q1', question: 'Test question' }],
|
|
});
|
|
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 = {
|
|
status: 'error',
|
|
delay: 0,
|
|
error: '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', { status: 'done', 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);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Agent modes (execute, discuss, plan)
|
|
// ===========================================================================
|
|
|
|
describe('agent modes', () => {
|
|
it('should spawn agent with default execute mode', async () => {
|
|
const agent = await manager.spawn({
|
|
name: 'exec-agent',
|
|
taskId: 't1',
|
|
prompt: 'test',
|
|
});
|
|
expect(agent.mode).toBe('execute');
|
|
});
|
|
|
|
it('should spawn agent in discuss mode', async () => {
|
|
manager.setScenario('discuss-agent', {
|
|
status: 'done',
|
|
delay: 0,
|
|
result: 'Auth discussion complete',
|
|
});
|
|
|
|
const agent = await manager.spawn({
|
|
name: 'discuss-agent',
|
|
taskId: 't1',
|
|
prompt: 'discuss auth',
|
|
mode: 'discuss',
|
|
});
|
|
|
|
expect(agent.mode).toBe('discuss');
|
|
});
|
|
|
|
it('should spawn agent in plan mode', async () => {
|
|
manager.setScenario('plan-agent', {
|
|
status: 'done',
|
|
delay: 0,
|
|
result: 'Plan complete',
|
|
});
|
|
|
|
const agent = await manager.spawn({
|
|
name: 'plan-agent',
|
|
taskId: 't1',
|
|
prompt: 'plan work',
|
|
mode: 'plan',
|
|
});
|
|
|
|
expect(agent.mode).toBe('plan');
|
|
});
|
|
|
|
it('should emit stopped event with context_complete reason for discuss mode', async () => {
|
|
manager.setScenario('discuss-done', {
|
|
status: 'done',
|
|
delay: 0,
|
|
result: 'Done',
|
|
});
|
|
|
|
await manager.spawn({
|
|
name: 'discuss-done',
|
|
taskId: 't1',
|
|
prompt: 'test',
|
|
mode: 'discuss',
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
|
|
expect(stopped?.payload.reason).toBe('context_complete');
|
|
});
|
|
|
|
it('should emit stopped event with plan_complete reason for plan mode', async () => {
|
|
manager.setScenario('plan-done', {
|
|
status: 'done',
|
|
delay: 0,
|
|
result: 'Plan complete',
|
|
});
|
|
|
|
await manager.spawn({
|
|
name: 'plan-done',
|
|
taskId: 't1',
|
|
prompt: 'test',
|
|
mode: 'plan',
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
|
|
expect(stopped?.payload.reason).toBe('plan_complete');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Detail mode (phase to tasks)
|
|
// ===========================================================================
|
|
|
|
describe('detail mode', () => {
|
|
it('should spawn agent in detail mode', async () => {
|
|
const agent = await manager.spawn({
|
|
name: 'detailer',
|
|
taskId: 'plan-1',
|
|
prompt: 'Detail this phase',
|
|
mode: 'detail',
|
|
});
|
|
expect(agent.mode).toBe('detail');
|
|
});
|
|
|
|
it('should complete with detail_complete reason in detail mode', async () => {
|
|
manager.setScenario('detailer', {
|
|
status: 'done',
|
|
result: 'Detail complete',
|
|
});
|
|
|
|
await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' });
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
// Verify agent:stopped event with detail_complete reason (derived from mode)
|
|
const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
|
|
expect(stoppedEvent).toBeDefined();
|
|
expect(stoppedEvent?.payload.reason).toBe('detail_complete');
|
|
});
|
|
|
|
it('should pause on questions in detail mode', async () => {
|
|
manager.setScenario('detailer', {
|
|
status: 'questions',
|
|
questions: [{ id: 'q1', question: 'How many tasks?' }],
|
|
});
|
|
|
|
await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' });
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
// Verify agent pauses for questions
|
|
const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting');
|
|
expect(stoppedEvent).toBeDefined();
|
|
|
|
// Check agent status
|
|
const agent = await manager.getByName('detailer');
|
|
expect(agent?.status).toBe('waiting_for_input');
|
|
});
|
|
|
|
it('should set result message for detail mode', async () => {
|
|
manager.setScenario('detailer', {
|
|
status: 'done',
|
|
result: 'Detail complete',
|
|
});
|
|
|
|
const agent = await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' });
|
|
await vi.runAllTimersAsync();
|
|
|
|
const result = await manager.getResult(agent.id);
|
|
expect(result?.success).toBe(true);
|
|
expect(result?.message).toBe('Detail complete');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Structured question data (new schema tests)
|
|
// ===========================================================================
|
|
|
|
describe('structured questions data', () => {
|
|
it('emits agent:waiting with structured questions data', async () => {
|
|
manager.setScenario('test-agent', {
|
|
status: 'questions',
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
question: 'Which database?',
|
|
options: [
|
|
{ label: 'PostgreSQL', description: 'Full-featured' },
|
|
{ label: 'SQLite', description: 'Lightweight' },
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' });
|
|
await vi.runAllTimersAsync();
|
|
|
|
const events = eventBus.emittedEvents.filter((e) => e.type === 'agent:waiting');
|
|
expect(events.length).toBe(1);
|
|
expect((events[0] as any).payload.questions).toHaveLength(1);
|
|
expect((events[0] as any).payload.questions[0].options).toHaveLength(2);
|
|
expect((events[0] as any).payload.questions[0].options[0].label).toBe('PostgreSQL');
|
|
expect((events[0] as any).payload.questions[0].multiSelect).toBe(false);
|
|
});
|
|
|
|
it('stores pending questions for retrieval', async () => {
|
|
manager.setScenario('test-agent', {
|
|
status: 'questions',
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
question: 'Which database?',
|
|
options: [{ label: 'PostgreSQL' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const agent = await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' });
|
|
await vi.runAllTimersAsync();
|
|
|
|
const pending = await manager.getPendingQuestions(agent.id);
|
|
expect(pending?.questions[0].question).toBe('Which database?');
|
|
expect(pending?.questions[0].options).toHaveLength(1);
|
|
expect(pending?.questions[0].options?.[0].label).toBe('PostgreSQL');
|
|
});
|
|
|
|
it('clears pending questions after resume', async () => {
|
|
manager.setScenario('resume-test', {
|
|
status: 'questions',
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
question: 'Need your input',
|
|
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const agent = await manager.spawn({ name: 'resume-test', taskId: 'task-1', prompt: 'test' });
|
|
await vi.runAllTimersAsync();
|
|
|
|
// Verify questions are pending
|
|
const pendingBefore = await manager.getPendingQuestions(agent.id);
|
|
expect(pendingBefore).not.toBeNull();
|
|
expect(pendingBefore?.questions[0].question).toBe('Need your input');
|
|
|
|
// Resume the agent with answers map
|
|
await manager.resume(agent.id, { q1: 'Option A' });
|
|
|
|
// Pending questions should be cleared
|
|
const pendingAfter = await manager.getPendingQuestions(agent.id);
|
|
expect(pendingAfter).toBeNull();
|
|
});
|
|
|
|
it('returns null for non-existent agent pending questions', async () => {
|
|
const pending = await manager.getPendingQuestions('non-existent-id');
|
|
expect(pending).toBeNull();
|
|
});
|
|
|
|
it('returns null for agent not in waiting state', async () => {
|
|
const agent = await manager.spawn({ name: 'running-agent', taskId: 'task-1', prompt: 'test' });
|
|
|
|
// Agent is running, not waiting
|
|
const pending = await manager.getPendingQuestions(agent.id);
|
|
expect(pending).toBeNull();
|
|
});
|
|
|
|
it('handles multiple questions in single scenario', async () => {
|
|
manager.setScenario('multi-q-agent', {
|
|
status: 'questions',
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
question: 'Which database should we use?',
|
|
options: [
|
|
{ label: 'PostgreSQL', description: 'Full-featured relational DB' },
|
|
{ label: 'SQLite', description: 'Lightweight embedded DB' },
|
|
],
|
|
},
|
|
{
|
|
id: 'q2',
|
|
question: 'Which ORM do you prefer?',
|
|
options: [
|
|
{ label: 'Drizzle', description: 'TypeScript-first ORM' },
|
|
{ label: 'Prisma', description: 'Popular Node.js ORM' },
|
|
],
|
|
},
|
|
{
|
|
id: 'q3',
|
|
question: 'Any additional notes?',
|
|
// No options - free-form text question
|
|
},
|
|
],
|
|
});
|
|
|
|
const agent = await manager.spawn({ name: 'multi-q-agent', taskId: 'task-1', prompt: 'test' });
|
|
await vi.runAllTimersAsync();
|
|
|
|
// Check status
|
|
const updated = await manager.get(agent.id);
|
|
expect(updated?.status).toBe('waiting_for_input');
|
|
|
|
// Check event has all questions
|
|
const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting');
|
|
expect(waitingEvent).toBeDefined();
|
|
expect((waitingEvent as any).payload.questions).toHaveLength(3);
|
|
expect((waitingEvent as any).payload.questions[0].id).toBe('q1');
|
|
expect((waitingEvent as any).payload.questions[1].id).toBe('q2');
|
|
expect((waitingEvent as any).payload.questions[2].id).toBe('q3');
|
|
|
|
// Check pending questions retrieval
|
|
const pending = await manager.getPendingQuestions(agent.id);
|
|
expect(pending?.questions).toHaveLength(3);
|
|
expect(pending?.questions[0].question).toBe('Which database should we use?');
|
|
expect(pending?.questions[1].question).toBe('Which ORM do you prefer?');
|
|
expect(pending?.questions[2].question).toBe('Any additional notes?');
|
|
expect(pending?.questions[2].options).toBeUndefined();
|
|
|
|
// Resume with answers to all questions
|
|
await manager.resume(agent.id, { q1: 'PostgreSQL', q2: 'Drizzle', q3: 'Use WAL mode' });
|
|
await vi.runAllTimersAsync();
|
|
|
|
// Agent should complete
|
|
const completed = await manager.get(agent.id);
|
|
expect(completed?.status).toBe('idle');
|
|
|
|
// Pending questions should be cleared
|
|
const clearedPending = await manager.getPendingQuestions(agent.id);
|
|
expect(clearedPending).toBeNull();
|
|
});
|
|
});
|
|
});
|