/** * PieceEngine integration tests: error handling scenarios. * * Covers: * - No rule matched (abort) * - runAgent throws (abort) * - Loop detection (abort) * - Iteration limit (abort and extend) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync, rmSync } from 'node:fs'; // --- Mock setup (must be before imports that use these modules) --- vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); vi.mock('../core/piece/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); vi.mock('../core/piece/phase-runner.js', () => ({ needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), runReportPhase: vi.fn().mockResolvedValue(undefined), runStatusJudgmentPhase: vi.fn().mockResolvedValue(''), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), generateReportDir: vi.fn().mockReturnValue('test-report-dir'), })); // --- Imports (after mocks) --- import { PieceEngine } from '../core/piece/index.js'; import { runAgent } from '../agents/runner.js'; import { detectMatchedRule } from '../core/piece/evaluation/index.js'; import { makeResponse, makeMovement, makeRule, buildDefaultPieceConfig, mockRunAgentSequence, mockDetectMatchedRuleSequence, createTestTmpDir, applyDefaultMocks, } from './engine-test-helpers.js'; describe('PieceEngine Integration: Error Handling', () => { let tmpDir: string; beforeEach(() => { vi.resetAllMocks(); applyDefaultMocks(); tmpDir = createTestTmpDir(); }); afterEach(() => { if (existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } }); // ===================================================== // 1. No rule matched // ===================================================== describe('No rule matched', () => { it('should abort when detectMatchedRule returns undefined', async () => { const config = buildDefaultPieceConfig(); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ persona: 'plan', content: 'Unclear output' }), ]); mockDetectMatchedRuleSequence([undefined]); const abortFn = vi.fn(); engine.on('piece:abort', abortFn); const state = await engine.run(); expect(state.status).toBe('aborted'); expect(abortFn).toHaveBeenCalledOnce(); const reason = abortFn.mock.calls[0]![1] as string; expect(reason).toContain('plan'); }); }); // ===================================================== // 2. runAgent throws // ===================================================== describe('runAgent throws', () => { it('should abort when runAgent throws an error', async () => { const config = buildDefaultPieceConfig(); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); vi.mocked(runAgent).mockRejectedValueOnce(new Error('API connection failed')); const abortFn = vi.fn(); engine.on('piece:abort', abortFn); const state = await engine.run(); expect(state.status).toBe('aborted'); expect(abortFn).toHaveBeenCalledOnce(); const reason = abortFn.mock.calls[0]![1] as string; expect(reason).toContain('API connection failed'); }); }); // ===================================================== // 3. Loop detection // ===================================================== describe('Loop detection', () => { it('should abort when loop detected with action: abort', async () => { const config = buildDefaultPieceConfig({ maxMovements: 100, loopDetection: { maxConsecutiveSameStep: 3, action: 'abort' }, initialMovement: 'loop-step', movements: [ makeMovement('loop-step', { rules: [makeRule('continue', 'loop-step')], }), ], }); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); for (let i = 0; i < 5; i++) { vi.mocked(runAgent).mockResolvedValueOnce( makeResponse({ content: `iteration ${i}` }) ); vi.mocked(detectMatchedRule).mockResolvedValueOnce( { index: 0, method: 'phase1_tag' } ); } const abortFn = vi.fn(); engine.on('piece:abort', abortFn); const state = await engine.run(); expect(state.status).toBe('aborted'); expect(abortFn).toHaveBeenCalledOnce(); const reason = abortFn.mock.calls[0]![1] as string; expect(reason).toContain('Loop detected'); expect(reason).toContain('loop-step'); }); }); // ===================================================== // 4. Iteration limit // ===================================================== describe('Iteration limit', () => { it('should abort when max iterations reached without onIterationLimit callback', async () => { const config = buildDefaultPieceConfig({ maxMovements: 2 }); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ persona: 'plan', content: 'Plan done' }), makeResponse({ persona: 'implement', content: 'Impl done' }), makeResponse({ persona: 'ai_review', content: 'OK' }), ]); mockDetectMatchedRuleSequence([ { index: 0, method: 'phase1_tag' }, // plan → implement { index: 0, method: 'phase1_tag' }, // implement → ai_review { index: 0, method: 'phase1_tag' }, // ai_review → reviewers (won't be reached) ]); const limitFn = vi.fn(); const abortFn = vi.fn(); engine.on('iteration:limit', limitFn); engine.on('piece:abort', abortFn); const state = await engine.run(); expect(state.status).toBe('aborted'); expect(limitFn).toHaveBeenCalledWith(2, 2); expect(abortFn).toHaveBeenCalledOnce(); const reason = abortFn.mock.calls[0]![1] as string; expect(reason).toContain('Max movements'); }); it('should extend iterations when onIterationLimit provides additional iterations', async () => { const config = buildDefaultPieceConfig({ maxMovements: 2 }); const onIterationLimit = vi.fn().mockResolvedValueOnce(10); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onIterationLimit, }); mockRunAgentSequence([ makeResponse({ persona: 'plan', content: 'Plan done' }), makeResponse({ persona: 'implement', content: 'Impl done' }), // After hitting limit at iteration 2, onIterationLimit extends to 12 makeResponse({ persona: 'ai_review', content: 'OK' }), makeResponse({ persona: 'arch-review', content: 'OK' }), makeResponse({ persona: 'security-review', content: 'OK' }), makeResponse({ persona: 'supervise', content: 'All passed' }), ]); mockDetectMatchedRuleSequence([ { index: 0, method: 'phase1_tag' }, // plan → implement { index: 0, method: 'phase1_tag' }, // implement → ai_review { index: 0, method: 'phase1_tag' }, // ai_review → reviewers { index: 0, method: 'phase1_tag' }, // arch-review → approved { index: 0, method: 'phase1_tag' }, // security-review → approved { index: 0, method: 'aggregate' }, // reviewers → supervise { index: 0, method: 'phase1_tag' }, // supervise → COMPLETE ]); const state = await engine.run(); expect(state.status).toBe('completed'); expect(onIterationLimit).toHaveBeenCalledOnce(); }); }); });