takt/src/__tests__/it-three-phase-execution.test.ts
nrslib 1466a7176d takt: # タスク指示書: Output Contracts と Quality Gates の実装
## 概要
仕様ドキュメント `/Users/m_naruse/work/git/takt/task_planning/output-contracts-and-quality-gates.md` に基づき、YAML構造の変更を実装する。

---

## タスク一覧

### 【高】トップレベル構造の変更
- 現在の `output_contracts` を `report_formats` にリネーム
- レポートテンプレート定義として機能させる

### 【高】Movement内の output_contracts 構造変更
- 各 movement の `output_contracts` が直接レポート配列を持つ構造に変更
- `output_contracts.report` の `report` キーを廃止

**変更後の構造:**
```yaml
report_formats:           # トップレベル(テンプレート定義)
  plan: ...

movements:
  - name: plan
    output_contracts:     # 直接配列(reportキー不要)
      - name: 00-plan.md
        format: plan
```

### 【中】quality_gates の実装
- エージェントへの通達として機能させる(自動検証は将来実装)
- Movement完了時にエージェントが参照できる形式で定義

---

## 制約(ユーザー明示)
- 後方互換性は不要

---

## 確認方法
- 既存のピース定義YAMLが新構造でパースできること
- テストが通ること
2026-02-07 22:03:43 +09:00

404 lines
12 KiB
TypeScript

/**
* 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 { callAiJudge, detectRuleIndex } from '../infra/claude/index.js';
// --- Mocks ---
vi.mock('../infra/claude/client.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../infra/claude/client.js')>();
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<Record<string, unknown>>()),
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({}),
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 makeRule(condition: string, next: string): PieceRule {
return { condition, next };
}
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('');
});
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',
maxIterations: 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('');
});
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',
maxIterations: 5,
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
makeRule('Not done', 'ABORT'),
], { outputContracts: [{ label: 'test', path: 'test-report.md' }] }),
],
};
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',
maxIterations: 5,
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
], { outputContracts: [{ label: 'Scope', path: 'scope.md' }, { label: 'Decisions', path: 'decisions.md' }] }),
],
};
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('[STEP:1]');
});
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',
maxIterations: 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('[STEP:1]');
});
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',
maxIterations: 5,
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
makeRule('Not done', 'ABORT'),
], { outputContracts: [{ label: 'test', path: 'test-report.md' }] }),
],
};
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('[STEP1:2]');
const config: PieceConfig = {
name: 'it-phase3-tag',
description: 'Test',
maxIterations: 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);
});
});