/** * Three-phase execution integration tests. * * Tests Phase 1 (main) → Phase 2 (report) → Phase 3 (status judgment) lifecycle. * Verifies that the correct combination of phases fires based on movement config. * * Mocked: UI, session, config, callAiJudge * Selectively mocked: phase-runner (to inspect call patterns) * Not mocked: PieceEngine, runAgent, detectMatchedRule, rule-evaluator */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { setMockScenario, resetScenario } from '../infra/mock/index.js'; import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js'; import { detectRuleIndex } from '../shared/utils/ruleIndex.js'; import { makeRule } from './test-helpers.js'; import { callAiJudge } from '../agents/ai-judge.js'; // --- Mocks --- vi.mock('../agents/ai-judge.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockResolvedValue(-1), }; }); const mockNeedsStatusJudgmentPhase = vi.fn(); const mockRunReportPhase = vi.fn(); const mockRunStatusJudgmentPhase = vi.fn(); vi.mock('../core/piece/phase-runner.js', () => ({ needsStatusJudgmentPhase: (...args: unknown[]) => mockNeedsStatusJudgmentPhase(...args), runReportPhase: (...args: unknown[]) => mockRunReportPhase(...args), runStatusJudgmentPhase: (...args: unknown[]) => mockRunStatusJudgmentPhase(...args), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), generateReportDir: vi.fn().mockReturnValue('test-report-dir'), generateSessionId: vi.fn().mockReturnValue('test-session-id'), })); vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); // --- Imports (after mocks) --- import { PieceEngine } from '../core/piece/index.js'; // --- Test helpers --- function createTestEnv(): { dir: string; agentPath: string } { const dir = mkdtempSync(join(tmpdir(), 'takt-it-3ph-')); mkdirSync(join(dir, '.takt', 'reports', 'test-report-dir'), { recursive: true }); const agentsDir = join(dir, 'agents'); mkdirSync(agentsDir, { recursive: true }); const agentPath = join(agentsDir, 'agent.md'); writeFileSync(agentPath, 'You are an agent.'); return { dir, agentPath }; } function buildEngineOptions(projectCwd: string) { return { projectCwd, detectRuleIndex, callAiJudge, }; } function makeMovement( name: string, agentPath: string, rules: PieceRule[], options: { outputContracts?: { label: string; path: string }[]; edit?: boolean } = {}, ): PieceMovement { return { name, persona: './agents/agent.md', personaDisplayName: name, personaPath: agentPath, instructionTemplate: '{task}', passPreviousResponse: true, rules, outputContracts: options.outputContracts, edit: options.edit, }; } describe('Three-Phase Execution IT: phase1 only (no report, no tag rules)', () => { let testDir: string; let agentPath: string; beforeEach(() => { vi.clearAllMocks(); const env = createTestEnv(); testDir = env.dir; agentPath = env.agentPath; // No tag rules needed → Phase 3 not needed mockNeedsStatusJudgmentPhase.mockReturnValue(false); mockRunReportPhase.mockResolvedValue(undefined); mockRunStatusJudgmentPhase.mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }); }); afterEach(() => { resetScenario(); rmSync(testDir, { recursive: true, force: true }); }); it('should only run Phase 1 when movement has no report and no tag rules', async () => { setMockScenario([ { status: 'done', content: '[STEP:1]\n\nDone.' }, ]); const config: PieceConfig = { name: 'it-phase1-only', description: 'Test', maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ makeRule('Done', 'COMPLETE'), makeRule('Not done', 'ABORT'), ]), ], }; const engine = new PieceEngine(config, testDir, 'Test task', { ...buildEngineOptions(testDir), provider: 'mock', }); const state = await engine.run(); expect(state.status).toBe('completed'); expect(mockRunReportPhase).not.toHaveBeenCalled(); // needsStatusJudgmentPhase is called but returns false expect(mockRunStatusJudgmentPhase).not.toHaveBeenCalled(); }); }); describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => { let testDir: string; let agentPath: string; beforeEach(() => { vi.clearAllMocks(); const env = createTestEnv(); testDir = env.dir; agentPath = env.agentPath; mockNeedsStatusJudgmentPhase.mockReturnValue(false); mockRunReportPhase.mockResolvedValue(undefined); mockRunStatusJudgmentPhase.mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }); }); afterEach(() => { resetScenario(); rmSync(testDir, { recursive: true, force: true }); }); it('should run Phase 1 + Phase 2 when movement has report', async () => { setMockScenario([ { status: 'done', content: '[STEP:1]\n\nDone.' }, ]); const config: PieceConfig = { name: 'it-phase1-2', description: 'Test', maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ makeRule('Done', 'COMPLETE'), makeRule('Not done', 'ABORT'), ], { outputContracts: [{ name: 'test-report.md', format: 'test-report', useJudge: true }] }), ], }; const engine = new PieceEngine(config, testDir, 'Test task', { ...buildEngineOptions(testDir), provider: 'mock', }); const state = await engine.run(); expect(state.status).toBe('completed'); expect(mockRunReportPhase).toHaveBeenCalledTimes(1); expect(mockRunStatusJudgmentPhase).not.toHaveBeenCalled(); }); it('should run Phase 2 for multi-report movement', async () => { setMockScenario([ { status: 'done', content: '[STEP:1]\n\nDone.' }, ]); const config: PieceConfig = { name: 'it-phase1-2-multi', description: 'Test', maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ makeRule('Done', 'COMPLETE'), ], { outputContracts: [{ name: 'scope.md', format: 'scope', useJudge: true }, { name: 'decisions.md', format: 'decisions', useJudge: true }] }), ], }; const engine = new PieceEngine(config, testDir, 'Test task', { ...buildEngineOptions(testDir), provider: 'mock', }); const state = await engine.run(); expect(state.status).toBe('completed'); expect(mockRunReportPhase).toHaveBeenCalledTimes(1); }); }); describe('Three-Phase Execution IT: phase1 + phase3 (tag rules defined)', () => { let testDir: string; let agentPath: string; beforeEach(() => { vi.clearAllMocks(); const env = createTestEnv(); testDir = env.dir; agentPath = env.agentPath; mockNeedsStatusJudgmentPhase.mockReturnValue(true); mockRunReportPhase.mockResolvedValue(undefined); // Phase 3 returns content with a tag mockRunStatusJudgmentPhase.mockResolvedValue({ tag: '[STEP:1]', ruleIndex: 0, method: 'structured_output' }); }); afterEach(() => { resetScenario(); rmSync(testDir, { recursive: true, force: true }); }); it('should run Phase 1 + Phase 3 when movement has tag-based rules but no report', async () => { setMockScenario([ // Phase 1: main content (no tag — Phase 3 will provide it) { status: 'done', content: 'Agent completed the work.' }, ]); const config: PieceConfig = { name: 'it-phase1-3', description: 'Test', maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ makeRule('Done', 'COMPLETE'), makeRule('Not done', 'ABORT'), ]), ], }; const engine = new PieceEngine(config, testDir, 'Test task', { ...buildEngineOptions(testDir), provider: 'mock', }); const state = await engine.run(); expect(state.status).toBe('completed'); expect(mockRunReportPhase).not.toHaveBeenCalled(); expect(mockRunStatusJudgmentPhase).toHaveBeenCalledTimes(1); }); }); describe('Three-Phase Execution IT: all three phases', () => { let testDir: string; let agentPath: string; beforeEach(() => { vi.clearAllMocks(); const env = createTestEnv(); testDir = env.dir; agentPath = env.agentPath; mockNeedsStatusJudgmentPhase.mockReturnValue(true); mockRunReportPhase.mockResolvedValue(undefined); mockRunStatusJudgmentPhase.mockResolvedValue({ tag: '[STEP:1]', ruleIndex: 0, method: 'structured_output' }); }); afterEach(() => { resetScenario(); rmSync(testDir, { recursive: true, force: true }); }); it('should run Phase 1 → Phase 2 → Phase 3 when movement has report and tag rules', async () => { setMockScenario([ { status: 'done', content: 'Agent completed the work.' }, ]); const config: PieceConfig = { name: 'it-all-phases', description: 'Test', maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ makeRule('Done', 'COMPLETE'), makeRule('Not done', 'ABORT'), ], { outputContracts: [{ name: 'test-report.md', format: 'test-report', useJudge: true }] }), ], }; const engine = new PieceEngine(config, testDir, 'Test task', { ...buildEngineOptions(testDir), provider: 'mock', }); const state = await engine.run(); expect(state.status).toBe('completed'); expect(mockRunReportPhase).toHaveBeenCalledTimes(1); expect(mockRunStatusJudgmentPhase).toHaveBeenCalledTimes(1); // Verify ordering: report phase is called before status judgment const reportCallOrder = mockRunReportPhase.mock.invocationCallOrder[0]; const judgmentCallOrder = mockRunStatusJudgmentPhase.mock.invocationCallOrder[0]; expect(reportCallOrder).toBeLessThan(judgmentCallOrder); }); }); describe('Three-Phase Execution IT: phase3 tag → rule match', () => { let testDir: string; let agentPath: string; beforeEach(() => { vi.clearAllMocks(); const env = createTestEnv(); testDir = env.dir; agentPath = env.agentPath; mockNeedsStatusJudgmentPhase.mockReturnValue(true); mockRunReportPhase.mockResolvedValue(undefined); }); afterEach(() => { resetScenario(); rmSync(testDir, { recursive: true, force: true }); }); it('should use Phase 3 tag for rule matching over Phase 1 content', async () => { // Phase 1 content has no tag → fallback setMockScenario([ { status: 'done', content: 'Work done.' }, { status: 'done', content: '[STEP2:1]\n\nChecked.' }, ]); // Phase 3 returns rule 2 (ABORT) mockRunStatusJudgmentPhase.mockResolvedValue({ tag: '[STEP1:2]', ruleIndex: 1, method: 'structured_output' }); const config: PieceConfig = { name: 'it-phase3-tag', description: 'Test', maxMovements: 5, initialMovement: 'step1', movements: [ makeMovement('step1', agentPath, [ makeRule('Done', 'step2'), makeRule('Not done', 'ABORT'), ]), makeMovement('step2', agentPath, [ makeRule('Checked', 'COMPLETE'), ]), ], }; const engine = new PieceEngine(config, testDir, 'Test task', { ...buildEngineOptions(testDir), provider: 'mock', }); const state = await engine.run(); // Phase 3 returned [STEP1:2] → rule index 1 → "Not done" → ABORT expect(state.status).toBe('aborted'); expect(state.iteration).toBe(1); }); });