/** * @fileoverview Deep integration tests for OrchestratorLoop state machine. * * Tests the full lifecycle: start → plan → approve → execute → verify → complete. * Uses mocked dependencies (SessionManager, TaskQueue, StateStore, Planner, Verifier) * to exercise state transitions, event emissions, error handling, and edge cases. * * Port: N/A (no HTTP) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; // ═══════════════════════════════════════════════════════════════ // Mock Setup — must be before imports // ═══════════════════════════════════════════════════════════════ // Mock session function createMockSession(id = 'session-1') { const session = new EventEmitter() as EventEmitter & { id: string; sendInput: ReturnType; assignTask: ReturnType; clearTask: ReturnType; writeViaMux: ReturnType; isIdle: ReturnType; isBusy: ReturnType; }; session.id = id; session.sendInput = vi.fn(async () => {}); session.assignTask = vi.fn(); session.clearTask = vi.fn(); session.writeViaMux = vi.fn(async () => true); session.isIdle = vi.fn(() => true); session.isBusy = vi.fn(() => false); return session; } // Mock session manager const mockSessionManager = new EventEmitter() as EventEmitter & { getIdleSessions: ReturnType; getAllSessions: ReturnType; getSession: ReturnType; }; mockSessionManager.getIdleSessions = vi.fn(() => [createMockSession()]); mockSessionManager.getAllSessions = vi.fn(() => [createMockSession()]); mockSessionManager.getSession = vi.fn(() => createMockSession()); vi.mock('../src/session-manager.js', () => ({ getSessionManager: () => mockSessionManager, SessionManager: vi.fn(), })); // Mock task queue with real Task-like objects let taskIdCounter = 0; function createMockTask(options: { prompt: string; completionPhrase?: string }) { const id = `task-${++taskIdCounter}`; let status = 'pending' as string; let assignedSessionId: string | null = null; let error: string | null = null; return { id, prompt: options.prompt, completionPhrase: options.completionPhrase, status, assignedSessionId, error, assign(sessionId: string) { status = 'running'; assignedSessionId = sessionId; this.status = status; this.assignedSessionId = assignedSessionId; }, fail(err?: string) { status = 'failed'; error = err || 'Unknown error'; this.status = status; this.error = error; }, complete() { status = 'completed'; this.status = status; }, isCompleted() { return this.status === 'completed'; }, isFailed() { return this.status === 'failed'; }, isPending() { return this.status === 'pending'; }, }; } const mockTaskQueue = new EventEmitter() as EventEmitter & { addTask: ReturnType; getTask: ReturnType; next: ReturnType; updateTask: ReturnType; _tasks: Map>; }; mockTaskQueue._tasks = new Map(); mockTaskQueue.addTask = vi.fn((options) => { const task = createMockTask(options); mockTaskQueue._tasks.set(task.id, task); return task; }); mockTaskQueue.getTask = vi.fn((id: string) => mockTaskQueue._tasks.get(id)); mockTaskQueue.next = vi.fn(() => null); mockTaskQueue.updateTask = vi.fn(); vi.mock('../src/task-queue.js', () => ({ getTaskQueue: () => mockTaskQueue, TaskQueue: vi.fn(), })); // Mock state store const mockStore = { getOrchestratorState: vi.fn(() => null), setOrchestratorState: vi.fn(), clearOrchestratorState: vi.fn(), }; vi.mock('../src/state-store.js', () => ({ getStore: () => mockStore, StateStore: vi.fn(), })); // Mock planner & verifier — vi.hoisted() so they exist when vi.mock factories run const { mockPlannerInstance, mockVerifierInstance } = vi.hoisted(() => ({ mockPlannerInstance: { generatePlan: vi.fn(), cancel: vi.fn().mockResolvedValue(undefined), }, mockVerifierInstance: { verifyPhase: vi.fn(), }, })); vi.mock('../src/orchestrator-planner.js', () => ({ OrchestratorPlanner: vi.fn().mockImplementation(function () { return mockPlannerInstance; }), })); vi.mock('../src/orchestrator-verifier.js', () => ({ OrchestratorVerifier: vi.fn().mockImplementation(function () { return mockVerifierInstance; }), })); // ═══════════════════════════════════════════════════════════════ // Import after mocks // ═══════════════════════════════════════════════════════════════ import { OrchestratorLoop } from '../src/orchestrator-loop.js'; import type { OrchestratorPlan } from '../src/types.js'; // ═══════════════════════════════════════════════════════════════ // Test helpers // ═══════════════════════════════════════════════════════════════ function createTestPlan(overrides?: Partial): OrchestratorPlan { return { id: 'plan-1', goal: 'Build a REST API', createdAt: Date.now(), phases: [ { id: 'phase-1', name: 'Phase 1: Setup', description: 'Setup the project', order: 0, status: 'pending', tasks: [ { id: 'phase-1-task-1', phaseId: 'phase-1', prompt: 'Create project structure', status: 'pending', assignedSessionId: null, queueTaskId: null, parallel: false, completionPhrase: 'ORCH_P1_T1', timeoutMs: 600000, startedAt: null, completedAt: null, error: null, retries: 0, }, ], verificationCriteria: [], testCommands: [], maxAttempts: 3, attempts: 0, startedAt: null, completedAt: null, durationMs: null, teamStrategy: { type: 'single' }, }, { id: 'phase-2', name: 'Phase 2: Implementation', description: 'Implement the API', order: 1, status: 'pending', tasks: [ { id: 'phase-2-task-1', phaseId: 'phase-2', prompt: 'Implement endpoints', status: 'pending', assignedSessionId: null, queueTaskId: null, parallel: false, completionPhrase: 'ORCH_P2_T1', timeoutMs: 600000, startedAt: null, completedAt: null, error: null, retries: 0, }, ], verificationCriteria: ['All endpoints respond with 200'], testCommands: ['npm test'], maxAttempts: 3, attempts: 0, startedAt: null, completedAt: null, durationMs: null, teamStrategy: { type: 'single' }, }, ], metadata: { totalTasks: 2, estimatedComplexity: 'low', modelUsed: 'opus', planDurationMs: 1000, }, ...overrides, }; } function createMockMux() { return { createSession: vi.fn(), killSession: vi.fn(), listSessions: vi.fn(() => []), sendInput: vi.fn(async () => true), } as never; } // Wait for a specific event to be emitted function waitForEvent(emitter: EventEmitter, event: string, timeout = 5000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Timeout waiting for event "${event}"`)), timeout); emitter.once(event, (...args: unknown[]) => { clearTimeout(timer); resolve(args); }); }); } // ═══════════════════════════════════════════════════════════════ // Tests // ═══════════════════════════════════════════════════════════════ describe('OrchestratorLoop', () => { let loop: OrchestratorLoop; beforeEach(() => { vi.clearAllMocks(); taskIdCounter = 0; mockTaskQueue._tasks.clear(); mockStore.getOrchestratorState.mockReturnValue(null); mockSessionManager.getIdleSessions.mockReturnValue([createMockSession()]); loop = new OrchestratorLoop(createMockMux(), '/test/dir'); }); afterEach(() => { loop.destroy(); }); // ═══════════════════════════════════════════════════════════════ // Initial State // ═══════════════════════════════════════════════════════════════ describe('initial state', () => { it('starts in idle state', () => { expect(loop.state).toBe('idle'); expect(loop.isRunning()).toBe(false); }); it('has no plan', () => { expect(loop.getPlan()).toBeNull(); expect(loop.getCurrentPhase()).toBeNull(); }); it('has zeroed stats', () => { const stats = loop.getStats(); expect(stats.phasesCompleted).toBe(0); expect(stats.phasesFailed).toBe(0); expect(stats.totalTasksCompleted).toBe(0); expect(stats.totalTasksFailed).toBe(0); }); it('reports full status', () => { const status = loop.getStatus(); expect(status.state).toBe('idle'); expect(status.plan).toBeNull(); expect(status.currentPhaseIndex).toBe(0); expect(status.startedAt).toBeNull(); }); }); // ═══════════════════════════════════════════════════════════════ // start() — Planning // ═══════════════════════════════════════════════════════════════ describe('start()', () => { it('transitions to planning state', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); const stateChanges: string[] = []; loop.on('stateChanged', (state) => stateChanges.push(state)); await loop.start('Build a REST API'); expect(stateChanges).toContain('planning'); expect(stateChanges).toContain('approval'); expect(loop.state).toBe('approval'); }); it('stores the generated plan', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Build a REST API'); expect(loop.getPlan()).toBeTruthy(); expect(loop.getPlan()!.goal).toBe('Build a REST API'); expect(loop.getPlan()!.phases).toHaveLength(2); }); it('emits planReady event', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); const planReadyPromise = waitForEvent(loop, 'planReady'); await loop.start('Build a REST API'); const [emittedPlan] = await planReadyPromise; expect(emittedPlan).toBeTruthy(); }); it('persists state during planning', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Build a REST API'); expect(mockStore.setOrchestratorState).toHaveBeenCalled(); }); it('auto-approves when configured', async () => { loop.destroy(); loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true }); const plan = createTestPlan({ phases: [] }); // Empty phases = immediate completion mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Quick task'); // Should skip approval and go straight to executing then completed expect(loop.state).toBe('completed'); }); it('transitions to failed on planner error', async () => { mockPlannerInstance.generatePlan.mockRejectedValue(new Error('Planner crashed')); // Must listen for 'error' events — Node throws unhandled 'error' events on EventEmitters const errors: Error[] = []; loop.on('error', (err) => errors.push(err)); await loop.start('Bad goal'); expect(loop.state).toBe('failed'); expect(errors).toHaveLength(1); expect(errors[0].message).toBe('Planner crashed'); }); it('rejects start from invalid states', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); // Now in 'approval' state await expect(loop.start('Another goal')).rejects.toThrow('Cannot start from state'); }); it('allows restart from failed state', async () => { mockPlannerInstance.generatePlan.mockRejectedValue(new Error('Failed')); loop.on('error', () => {}); // Suppress unhandled error throw await loop.start('Fail'); expect(loop.state).toBe('failed'); const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Retry'); expect(loop.state).toBe('approval'); }); }); // ═══════════════════════════════════════════════════════════════ // approve() / reject() // ═══════════════════════════════════════════════════════════════ describe('approve()', () => { it('transitions from approval to executing', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); expect(loop.state).toBe('approval'); // approve starts execution asynchronously const approvePromise = loop.approve(); // Need to wait for the first phase to start executing await approvePromise; expect(loop.state).toBe('executing'); expect(loop.isRunning()).toBe(true); }); it('rejects approve from non-approval state', async () => { await expect(loop.approve()).rejects.toThrow('Expected state "approval"'); }); }); describe('reject()', () => { it('transitions back to planning with feedback', async () => { const plan1 = createTestPlan({ id: 'plan-1' }); const plan2 = createTestPlan({ id: 'plan-2' }); mockPlannerInstance.generatePlan.mockResolvedValueOnce(plan1).mockResolvedValueOnce(plan2); await loop.start('Goal'); expect(loop.state).toBe('approval'); await loop.reject('Add more tests'); expect(loop.state).toBe('approval'); // Planner should be called with goal + feedback expect(mockPlannerInstance.generatePlan).toHaveBeenCalledTimes(2); const secondCallGoal = mockPlannerInstance.generatePlan.mock.calls[1][0]; expect(secondCallGoal).toContain('Add more tests'); }); it('rejects reject from non-approval state', async () => { await expect(loop.reject('feedback')).rejects.toThrow('Expected state "approval"'); }); }); // ═══════════════════════════════════════════════════════════════ // Phase Execution // ═══════════════════════════════════════════════════════════════ describe('phase execution', () => { it('creates task queue entries for phase tasks', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); // addTask should have been called for the first phase's task expect(mockTaskQueue.addTask).toHaveBeenCalled(); const addTaskCall = mockTaskQueue.addTask.mock.calls[0][0]; expect(addTaskCall.completionPhrase).toBe('ORCH_P1_T1'); }); it('emits phaseStarted event', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); const phaseStartedPromise = waitForEvent(loop, 'phaseStarted'); await loop.approve(); const [phase] = await phaseStartedPromise; expect((phase as { id: string }).id).toBe('phase-1'); }); it('increments phase attempts', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); const currentPhase = loop.getCurrentPhase(); expect(currentPhase).toBeTruthy(); expect(currentPhase!.attempts).toBe(1); expect(currentPhase!.status).toBe('executing'); }); }); // ═══════════════════════════════════════════════════════════════ // pause() / resume() // ═══════════════════════════════════════════════════════════════ describe('pause()', () => { it('transitions to paused state', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); expect(loop.state).toBe('executing'); loop.pause(); expect(loop.state).toBe('paused'); expect(loop.isRunning()).toBe(true); // Still "running" in broader sense }); it('is a no-op from idle/completed/failed', () => { loop.pause(); // Should not throw expect(loop.state).toBe('idle'); }); it('cleans up listeners on pause', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); // Listeners should be active const listenerCount = mockSessionManager.listenerCount('sessionCompletion'); loop.pause(); // After pause, listener should be removed expect(mockSessionManager.listenerCount('sessionCompletion')).toBeLessThan(listenerCount); }); }); describe('resume()', () => { it('resumes executing state', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); loop.pause(); expect(loop.state).toBe('paused'); await loop.resume(); expect(loop.state).toBe('executing'); }); it('rejects resume when not paused', async () => { await expect(loop.resume()).rejects.toThrow('Not paused'); }); }); // ═══════════════════════════════════════════════════════════════ // stop() // ═══════════════════════════════════════════════════════════════ describe('stop()', () => { it('returns to idle state', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.stop(); expect(loop.state).toBe('idle'); expect(loop.isRunning()).toBe(false); }); it('cancels planner', async () => { await loop.stop(); expect(mockPlannerInstance.cancel).toHaveBeenCalled(); }); it('clears persisted state', async () => { await loop.stop(); expect(mockStore.clearOrchestratorState).toHaveBeenCalled(); }); }); // ═══════════════════════════════════════════════════════════════ // skipPhase() / retryPhase() // ═══════════════════════════════════════════════════════════════ describe('skipPhase()', () => { it('marks phase as skipped', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); await loop.skipPhase('phase-1'); const phase = loop.getPlan()!.phases[0]; expect(phase.status).toBe('skipped'); expect(phase.completedAt).toBeTruthy(); }); it('throws for unknown phase', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await expect(loop.skipPhase('nonexistent')).rejects.toThrow('not found'); }); }); describe('retryPhase()', () => { it('resets phase tasks and re-executes', async () => { const plan = createTestPlan(); plan.phases[0].status = 'failed'; plan.phases[0].tasks[0].status = 'failed'; plan.phases[0].tasks[0].error = 'Something broke'; mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); // The phase was pre-failed, so retrying should work await loop.retryPhase('phase-1'); const phase = loop.getPlan()!.phases[0]; expect(phase.tasks[0].status).not.toBe('failed'); expect(phase.tasks[0].error).toBeNull(); }); it('rejects from invalid states', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); // In approval state — can't retry await expect(loop.retryPhase('phase-1')).rejects.toThrow('Cannot retry from state'); }); }); // ═══════════════════════════════════════════════════════════════ // Verification Flow // ═══════════════════════════════════════════════════════════════ describe('verification', () => { it('skips verification for phases with no criteria', async () => { // Phase 1 has no verificationCriteria, so it should pass automatically const plan = createTestPlan(); plan.phases = [plan.phases[0]]; // Only phase 1 (no criteria) // Mock immediate task completion via pollPhaseStatus mockTaskQueue.addTask.mockImplementation((options) => { const task = createMockTask(options); task.complete(); // Immediately complete mockTaskQueue._tasks.set(task.id, task); return task; }); mockPlannerInstance.generatePlan.mockResolvedValue(plan); // Create a fresh auto-approve loop loop.destroy(); loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true }); await loop.start('Goal'); // Wait for poll (2s) + post-phase delay (1s) + buffer await new Promise((resolve) => setTimeout(resolve, 5000)); // Phase 1 has no criteria → should pass without verification expect(loop.state).toBe('completed'); }); }); // ═══════════════════════════════════════════════════════════════ // State Persistence & Recovery // ═══════════════════════════════════════════════════════════════ describe('persistence', () => { it('restores failed state on crash recovery', () => { mockStore.getOrchestratorState.mockReturnValue({ state: 'executing', plan: createTestPlan(), currentPhaseIndex: 0, startedAt: Date.now() - 10000, completedAt: null, config: { plannerModel: 'opus', autoApprove: false, maxPhaseRetries: 3, phaseTimeoutMs: 1800000, enableTeamAgents: true, maxParallelSessions: 3, verificationMode: 'moderate', compactBetweenPhases: true, researchEnabled: true, }, stats: { phasesCompleted: 0, phasesFailed: 0, totalTasksCompleted: 0, totalTasksFailed: 0, totalDurationMs: 0, replanCount: 0, }, }); const recovered = new OrchestratorLoop(createMockMux(), '/test/dir'); expect(recovered.state).toBe('failed'); // Executing at crash → failed expect(recovered.getPlan()).toBeTruthy(); recovered.destroy(); }); it('clears planning state on crash recovery', () => { mockStore.getOrchestratorState.mockReturnValue({ state: 'planning', plan: null, currentPhaseIndex: 0, startedAt: Date.now(), completedAt: null, config: { plannerModel: 'opus', autoApprove: false, maxPhaseRetries: 3, phaseTimeoutMs: 1800000, enableTeamAgents: true, maxParallelSessions: 3, verificationMode: 'moderate', compactBetweenPhases: true, researchEnabled: true, }, stats: { phasesCompleted: 0, phasesFailed: 0, totalTasksCompleted: 0, totalTasksFailed: 0, totalDurationMs: 0, replanCount: 0, }, }); const recovered = new OrchestratorLoop(createMockMux(), '/test/dir'); expect(recovered.state).toBe('idle'); // Planning at crash → idle expect(mockStore.clearOrchestratorState).toHaveBeenCalled(); recovered.destroy(); }); it('preserves completed state on recovery', () => { mockStore.getOrchestratorState.mockReturnValue({ state: 'completed', plan: createTestPlan(), currentPhaseIndex: 2, startedAt: Date.now() - 60000, completedAt: Date.now(), config: { plannerModel: 'opus', autoApprove: false, maxPhaseRetries: 3, phaseTimeoutMs: 1800000, enableTeamAgents: true, maxParallelSessions: 3, verificationMode: 'moderate', compactBetweenPhases: true, researchEnabled: true, }, stats: { phasesCompleted: 2, phasesFailed: 0, totalTasksCompleted: 2, totalTasksFailed: 0, totalDurationMs: 60000, replanCount: 0, }, }); const recovered = new OrchestratorLoop(createMockMux(), '/test/dir'); expect(recovered.state).toBe('completed'); expect(recovered.getPlan()).toBeTruthy(); expect(recovered.getStats().phasesCompleted).toBe(2); recovered.destroy(); }); }); // ═══════════════════════════════════════════════════════════════ // Event Emissions // ═══════════════════════════════════════════════════════════════ describe('event emissions', () => { it('emits stateChanged for every transition', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); const transitions: Array<{ state: string; prevState: string }> = []; loop.on('stateChanged', (state, prevState) => { transitions.push({ state, prevState }); }); await loop.start('Goal'); // idle → planning → approval expect(transitions).toEqual([ { state: 'planning', prevState: 'idle' }, { state: 'approval', prevState: 'planning' }, ]); }); it('emits error event on failure', async () => { mockPlannerInstance.generatePlan.mockRejectedValue(new Error('Boom')); const errorPromise = waitForEvent(loop, 'error'); await loop.start('Fail'); const [error] = await errorPromise; expect((error as Error).message).toBe('Boom'); }); }); // ═══════════════════════════════════════════════════════════════ // Team Phase Execution // ═══════════════════════════════════════════════════════════════ describe('team phase execution', () => { it('sends team lead prompt for team strategy phases', async () => { const plan = createTestPlan(); plan.phases[0].teamStrategy = { type: 'team', config: { leadPrompt: 'Lead prompt', suggestedTeammates: ['Specialist 1', 'Specialist 2'], maxTeammates: 2, }, }; plan.phases[0].tasks.push({ id: 'phase-1-task-2', phaseId: 'phase-1', prompt: 'Second task', status: 'pending', assignedSessionId: null, queueTaskId: null, parallel: true, completionPhrase: 'ORCH_P1_T2', timeoutMs: 600000, startedAt: null, completedAt: null, error: null, retries: 0, }); const mockSession = createMockSession(); mockSessionManager.getIdleSessions.mockReturnValue([mockSession]); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); // Should have sent a team lead prompt to the session expect(mockSession.sendInput).toHaveBeenCalled(); const sentPrompt = mockSession.sendInput.mock.calls[0][0]; expect(sentPrompt).toContain('team lead'); }); it('throws when no idle sessions for team phase', async () => { const plan = createTestPlan(); plan.phases[0].teamStrategy = { type: 'team', config: { leadPrompt: 'Lead prompt', suggestedTeammates: ['Specialist'], maxTeammates: 1, }, }; mockSessionManager.getIdleSessions.mockReturnValue([]); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); // Should fail because no sessions available // Wait for state to settle await new Promise((resolve) => setTimeout(resolve, 100)); expect(loop.state).toBe('failed'); }); }); // ═══════════════════════════════════════════════════════════════ // Complete Lifecycle // ═══════════════════════════════════════════════════════════════ describe('complete lifecycle (auto-approve, single phase, no verification)', () => { it('runs start → plan → execute → complete', async () => { const plan = createTestPlan(); // Single phase with no verification criteria plan.phases = [plan.phases[0]]; loop.destroy(); loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true }); // Make tasks complete immediately mockTaskQueue.addTask.mockImplementation((options) => { const task = createMockTask(options); task.complete(); mockTaskQueue._tasks.set(task.id, task); return task; }); mockPlannerInstance.generatePlan.mockResolvedValue(plan); const completedPromise = waitForEvent(loop, 'completed', 10000); await loop.start('Simple goal'); const [stats] = await completedPromise; expect(loop.state).toBe('completed'); expect((stats as { phasesCompleted: number }).phasesCompleted).toBe(1); }); }); // ═══════════════════════════════════════════════════════════════ // Edge Cases // ═══════════════════════════════════════════════════════════════ describe('edge cases', () => { it('handles empty plan (no phases)', async () => { const plan = createTestPlan({ phases: [] }); mockPlannerInstance.generatePlan.mockResolvedValue(plan); loop.destroy(); loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true }); await loop.start('Empty'); expect(loop.state).toBe('completed'); }); it('destroy cleans up timers', async () => { const plan = createTestPlan(); mockPlannerInstance.generatePlan.mockResolvedValue(plan); await loop.start('Goal'); await loop.approve(); // Should not throw loop.destroy(); }); it('multiple stop calls are safe', async () => { await loop.stop(); await loop.stop(); await loop.stop(); expect(loop.state).toBe('idle'); }); }); });