takt/src/__tests__/engine-happy-path.test.ts
2026-02-13 06:11:06 +09:00

715 lines
28 KiB
TypeScript

/**
* PieceEngine integration tests: happy path and normal flow scenarios.
*
* Covers:
* - Full happy path (plan → implement → ai_review → reviewers → supervise → COMPLETE)
* - Review reject and fix loop
* - AI review reject and fix
* - ABORT transition
* - Event emissions
* - Movement output tracking
* - Config validation
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
import type { PieceConfig, PieceMovement } from '../core/models/index.js';
// --- 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({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
}));
// --- Imports (after mocks) ---
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import {
makeResponse,
makeMovement,
makeRule,
buildDefaultPieceConfig,
mockRunAgentSequence,
mockDetectMatchedRuleSequence,
createTestTmpDir,
applyDefaultMocks,
cleanupPieceEngine,
} from './engine-test-helpers.js';
describe('PieceEngine Integration: Happy Path', () => {
let tmpDir: string;
let engine: PieceEngine | null = null;
beforeEach(() => {
vi.resetAllMocks();
applyDefaultMocks();
tmpDir = createTestTmpDir();
});
afterEach(() => {
if (engine) {
cleanupPieceEngine(engine);
engine = null;
}
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
// =====================================================
// 1. Happy Path
// =====================================================
describe('Happy path', () => {
it('should complete: plan → implement → ai_review → reviewers(all approved) → supervise → COMPLETE', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan complete' }),
makeResponse({ persona: 'implement', content: 'Implementation done' }),
makeResponse({ persona: 'ai_review', content: 'No issues' }),
makeResponse({ persona: 'arch-review', content: 'Architecture OK' }),
makeResponse({ persona: 'security-review', content: 'Security 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(all approved) → supervise
{ index: 0, method: 'phase1_tag' }, // supervise → COMPLETE
]);
const completeFn = vi.fn();
engine.on('piece:complete', completeFn);
const state = await engine.run();
expect(state.status).toBe('completed');
expect(state.iteration).toBe(5); // plan, implement, ai_review, reviewers, supervise
expect(completeFn).toHaveBeenCalledOnce();
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(6); // 4 normal + 2 parallel sub-movements
});
});
// =====================================================
// 2. Review reject and fix loop
// =====================================================
describe('Review reject and fix loop', () => {
it('should handle: reviewers(needs_fix) → fix → reviewers(all approved) → supervise → COMPLETE', async () => {
const config = buildDefaultPieceConfig();
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: 'No issues' }),
// Round 1 reviewers: arch approved, security needs fix
makeResponse({ persona: 'arch-review', content: 'OK' }),
makeResponse({ persona: 'security-review', content: 'Vulnerability found' }),
// fix step
makeResponse({ persona: 'fix', content: 'Fixed security issue' }),
// Round 2 reviewers: both approved
makeResponse({ persona: 'arch-review', content: 'OK' }),
makeResponse({ persona: 'security-review', content: 'Security OK now' }),
// supervise
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: 1, method: 'phase1_tag' }, // security-review → needs_fix
{ index: 1, method: 'aggregate' }, // reviewers: any(needs_fix) → fix
{ index: 0, method: 'phase1_tag' }, // fix → reviewers
{ index: 0, method: 'phase1_tag' }, // arch-review → approved
{ index: 0, method: 'phase1_tag' }, // security-review → approved
{ index: 0, method: 'aggregate' }, // reviewers: all(approved) → supervise
{ index: 0, method: 'phase1_tag' }, // supervise → COMPLETE
]);
const state = await engine.run();
expect(state.status).toBe('completed');
// plan, implement, ai_review, reviewers(1st), fix, reviewers(2nd), supervise = 7
expect(state.iteration).toBe(7);
});
it('should inject latest reviewers output as Previous Response for repeated fix steps', async () => {
const config = buildDefaultPieceConfig();
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: 'No issues' }),
// Round 1 reviewers
makeResponse({ persona: 'arch-review', content: 'Arch R1 OK' }),
makeResponse({ persona: 'security-review', content: 'Sec R1 needs fix' }),
// fix round 1
makeResponse({ persona: 'fix', content: 'Fix R1' }),
// Round 2 reviewers
makeResponse({ persona: 'arch-review', content: 'Arch R2 OK' }),
makeResponse({ persona: 'security-review', content: 'Sec R2 still failing' }),
// fix round 2
makeResponse({ persona: 'fix', content: 'Fix R2' }),
// Round 3 reviewers (approved)
makeResponse({ persona: 'arch-review', content: 'Arch R3 OK' }),
makeResponse({ persona: 'security-review', content: 'Sec R3 OK' }),
// supervise
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: 1, method: 'phase1_tag' }, // security-review → needs_fix
{ index: 1, method: 'aggregate' }, // reviewers: any(needs_fix) → fix
{ index: 0, method: 'phase1_tag' }, // fix → reviewers
{ index: 0, method: 'phase1_tag' }, // arch-review → approved
{ index: 1, method: 'phase1_tag' }, // security-review → needs_fix
{ index: 1, method: 'aggregate' }, // reviewers: any(needs_fix) → fix
{ index: 0, method: 'phase1_tag' }, // fix → reviewers
{ index: 0, method: 'phase1_tag' }, // arch-review → approved
{ index: 0, method: 'phase1_tag' }, // security-review → approved
{ index: 0, method: 'aggregate' }, // reviewers: all(approved) → supervise
{ index: 0, method: 'phase1_tag' }, // supervise → COMPLETE
]);
const fixInstructions: string[] = [];
engine.on('movement:start', (step, _iteration, instruction) => {
if (step.name === 'fix') {
fixInstructions.push(instruction);
}
});
await engine.run();
expect(fixInstructions).toHaveLength(2);
const fix1 = fixInstructions[0]!;
expect(fix1).toContain('## Previous Response');
expect(fix1).toContain('Arch R1 OK');
expect(fix1).toContain('Sec R1 needs fix');
expect(fix1).not.toContain('Arch R2 OK');
expect(fix1).not.toContain('Sec R2 still failing');
const fix2 = fixInstructions[1]!;
expect(fix2).toContain('## Previous Response');
expect(fix2).toContain('Arch R2 OK');
expect(fix2).toContain('Sec R2 still failing');
expect(fix2).not.toContain('Arch R1 OK');
expect(fix2).not.toContain('Sec R1 needs fix');
});
it('should use the latest movement output across different steps for Previous Response', async () => {
const config = buildDefaultPieceConfig();
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: 'AI issues found' }),
// ai_fix (should see ai_review output)
makeResponse({ persona: 'ai_fix', content: 'AI issues fixed' }),
// reviewers (approved)
makeResponse({ persona: 'arch-review', content: 'Arch OK' }),
makeResponse({ persona: 'security-review', content: 'Sec OK' }),
// supervise (should see reviewers aggregate output)
makeResponse({ persona: 'supervise', content: 'All passed' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' }, // plan → implement
{ index: 0, method: 'phase1_tag' }, // implement → ai_review
{ index: 1, method: 'phase1_tag' }, // ai_review → ai_fix
{ index: 0, method: 'phase1_tag' }, // ai_fix → 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 aiFixInstructions: string[] = [];
const superviseInstructions: string[] = [];
engine.on('movement:start', (step, _iteration, instruction) => {
if (step.name === 'ai_fix') {
aiFixInstructions.push(instruction);
} else if (step.name === 'supervise') {
superviseInstructions.push(instruction);
}
});
await engine.run();
expect(aiFixInstructions).toHaveLength(1);
const aiFix = aiFixInstructions[0]!;
expect(aiFix).toContain('## Previous Response');
expect(aiFix).toContain('AI issues found');
expect(aiFix).not.toContain('AI issues fixed');
expect(superviseInstructions).toHaveLength(1);
const supervise = superviseInstructions[0]!;
expect(supervise).toContain('## Previous Response');
expect(supervise).toContain('Arch OK');
expect(supervise).toContain('Sec OK');
});
});
// =====================================================
// 3. AI review reject and fix
// =====================================================
describe('AI review reject and fix', () => {
it('should handle: ai_review(issues) → ai_fix → reviewers → supervise → COMPLETE', async () => {
const config = buildDefaultPieceConfig();
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: 'AI issues found' }),
makeResponse({ persona: 'ai_fix', content: 'Issues fixed' }),
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: 1, method: 'phase1_tag' }, // ai_review → ai_fix (issues found)
{ index: 0, method: 'phase1_tag' }, // ai_fix → 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');
// plan, implement, ai_review, ai_fix, reviewers, supervise = 6
expect(state.iteration).toBe(6);
});
});
// =====================================================
// 4. ABORT transition
// =====================================================
describe('ABORT transition', () => {
it('should abort when movement transitions to ABORT', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Requirements unclear' }),
]);
// plan rule index 1 → ABORT
mockDetectMatchedRuleSequence([
{ index: 1, 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();
});
});
// =====================================================
// 5. Event emissions
// =====================================================
describe('Event emissions', () => {
it('should emit movement:start and movement:complete for each movement', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan' }),
makeResponse({ persona: 'implement', content: 'Impl' }),
makeResponse({ persona: 'ai_review', content: 'OK' }),
makeResponse({ persona: 'arch-review', content: 'OK' }),
makeResponse({ persona: 'security-review', content: 'OK' }),
makeResponse({ persona: 'supervise', content: 'Pass' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const startFn = vi.fn();
const completeFn = vi.fn();
engine.on('movement:start', startFn);
engine.on('movement:complete', completeFn);
await engine.run();
// 5 movements: plan, implement, ai_review, reviewers, supervise
expect(startFn).toHaveBeenCalledTimes(5);
expect(completeFn).toHaveBeenCalledTimes(5);
const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name);
expect(startedMovements).toEqual(['plan', 'implement', 'ai_review', 'reviewers', 'supervise']);
});
it('should pass instruction to movement:start for normal movements', async () => {
const simpleConfig: PieceConfig = {
name: 'test',
maxMovements: 10,
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('done', 'COMPLETE')],
}),
],
};
engine = new PieceEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan done' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const startFn = vi.fn();
engine.on('movement:start', startFn);
await engine.run();
expect(startFn).toHaveBeenCalledTimes(1);
// movement:start should receive (movement, iteration, instruction)
const [_movement, _iteration, instruction] = startFn.mock.calls[0];
expect(typeof instruction).toBe('string');
expect(instruction.length).toBeGreaterThan(0);
});
it('should pass empty instruction to movement:start for parallel movements', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan' }),
makeResponse({ persona: 'implement', content: 'Impl' }),
makeResponse({ persona: 'ai_review', content: 'OK' }),
makeResponse({ persona: 'arch-review', content: 'OK' }),
makeResponse({ persona: 'security-review', content: 'OK' }),
makeResponse({ persona: 'supervise', content: 'Pass' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const startFn = vi.fn();
engine.on('movement:start', startFn);
await engine.run();
// Find the "reviewers" movement:start call (parallel movement)
const reviewersCall = startFn.mock.calls.find(
(call) => (call[0] as PieceMovement).name === 'reviewers'
);
expect(reviewersCall).toBeDefined();
// Parallel movements emit empty string for instruction
const [, , instruction] = reviewersCall!;
expect(instruction).toBe('');
});
it('should emit iteration:limit when max iterations reached', async () => {
const config = buildDefaultPieceConfig({ maxMovements: 1 });
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const limitFn = vi.fn();
engine.on('iteration:limit', limitFn);
await engine.run();
expect(limitFn).toHaveBeenCalledWith(1, 1);
});
});
// =====================================================
// 6. Movement output tracking
// =====================================================
describe('Movement output tracking', () => {
it('should store outputs for all executed movements', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan output' }),
makeResponse({ persona: 'implement', content: 'Implement output' }),
makeResponse({ persona: 'ai_review', content: 'AI review output' }),
makeResponse({ persona: 'arch-review', content: 'Arch output' }),
makeResponse({ persona: 'security-review', content: 'Sec output' }),
makeResponse({ persona: 'supervise', content: 'Supervise output' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const state = await engine.run();
expect(state.movementOutputs.get('plan')!.content).toBe('Plan output');
expect(state.movementOutputs.get('implement')!.content).toBe('Implement output');
expect(state.movementOutputs.get('ai_review')!.content).toBe('AI review output');
expect(state.movementOutputs.get('supervise')!.content).toBe('Supervise output');
});
});
// =====================================================
// 7. Phase events
// =====================================================
describe('Phase events', () => {
it('should emit phase:start and phase:complete events for Phase 1', async () => {
const simpleConfig: PieceConfig = {
name: 'test',
maxMovements: 10,
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('done', 'COMPLETE')],
}),
],
};
engine = new PieceEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan done' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const phaseStartFn = vi.fn();
const phaseCompleteFn = vi.fn();
engine.on('phase:start', phaseStartFn);
engine.on('phase:complete', phaseCompleteFn);
await engine.run();
expect(phaseStartFn).toHaveBeenCalledWith(
expect.objectContaining({ name: 'plan' }),
1, 'execute', expect.any(String)
);
expect(phaseCompleteFn).toHaveBeenCalledWith(
expect.objectContaining({ name: 'plan' }),
1, 'execute', expect.any(String), 'done', undefined
);
});
it('should emit phase events for all movements in happy path', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan' }),
makeResponse({ persona: 'implement', content: 'Impl' }),
makeResponse({ persona: 'ai_review', content: 'OK' }),
makeResponse({ persona: 'arch-review', content: 'OK' }),
makeResponse({ persona: 'security-review', content: 'OK' }),
makeResponse({ persona: 'supervise', content: 'Pass' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const phaseStartFn = vi.fn();
const phaseCompleteFn = vi.fn();
engine.on('phase:start', phaseStartFn);
engine.on('phase:complete', phaseCompleteFn);
await engine.run();
// 4 normal movements + 2 parallel sub-movements = 6 Phase 1 invocations
expect(phaseStartFn).toHaveBeenCalledTimes(6);
expect(phaseCompleteFn).toHaveBeenCalledTimes(6);
// All calls should be Phase 1 (execute) since report/judgment are mocked off
for (const call of phaseStartFn.mock.calls) {
expect(call[1]).toBe(1);
expect(call[2]).toBe('execute');
}
});
});
// =====================================================
// 8. Config validation
// =====================================================
describe('Config validation', () => {
it('should throw when initial movement does not exist', () => {
const config = buildDefaultPieceConfig({ initialMovement: 'nonexistent' });
expect(() => {
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
}).toThrow('Unknown movement: nonexistent');
});
it('should throw when rule references nonexistent movement', () => {
const config: PieceConfig = {
name: 'test',
maxMovements: 10,
initialMovement: 'step1',
movements: [
makeMovement('step1', {
rules: [makeRule('done', 'nonexistent_step')],
}),
],
};
expect(() => {
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
}).toThrow('nonexistent_step');
});
it('should throw when startMovement option references nonexistent movement', () => {
const config = buildDefaultPieceConfig();
expect(() => {
new PieceEngine(config, tmpDir, 'test task', {
projectCwd: tmpDir,
startMovement: 'nonexistent',
});
}).toThrow('Unknown movement: nonexistent');
});
});
// =====================================================
// 9. startMovement option
// =====================================================
describe('startMovement option', () => {
it('should start from specified movement instead of initialMovement', async () => {
const config = buildDefaultPieceConfig();
// Start from ai_review, skipping plan and implement
engine = new PieceEngine(config, tmpDir, 'test task', {
projectCwd: tmpDir,
startMovement: 'ai_review',
});
mockRunAgentSequence([
makeResponse({ persona: 'ai_review', content: 'No issues' }),
makeResponse({ persona: 'arch-review', content: 'Architecture OK' }),
makeResponse({ persona: 'security-review', content: 'Security OK' }),
makeResponse({ persona: 'supervise', content: 'All passed' }),
]);
mockDetectMatchedRuleSequence([
{ 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(all approved) → supervise
{ index: 0, method: 'phase1_tag' }, // supervise → COMPLETE
]);
const startFn = vi.fn();
engine.on('movement:start', startFn);
const state = await engine.run();
expect(state.status).toBe('completed');
// Should only run 3 movements: ai_review, reviewers, supervise
expect(state.iteration).toBe(3);
// First movement should be ai_review, not plan
const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name);
expect(startedMovements[0]).toBe('ai_review');
expect(startedMovements).not.toContain('plan');
expect(startedMovements).not.toContain('implement');
});
it('should use initialMovement when startMovement is not specified', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan complete' }),
makeResponse({ persona: 'implement', content: 'Implementation done' }),
makeResponse({ persona: 'ai_review', content: 'No issues' }),
makeResponse({ persona: 'arch-review', content: 'Architecture OK' }),
makeResponse({ persona: 'security-review', content: 'Security OK' }),
makeResponse({ persona: 'supervise', content: 'All passed' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const startFn = vi.fn();
engine.on('movement:start', startFn);
await engine.run();
// First movement should be plan (the initialMovement)
const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name);
expect(startedMovements[0]).toBe('plan');
});
});
});