takt/src/__tests__/instructionBuilder.test.ts
nrslib 2460dbdf61 refactor(output-contracts): unify OutputContractEntry to item format with use_judge and move runtime dir under .takt
- Remove OutputContractLabelPath (label:path format), unify to OutputContractItem only
- Add required format field and use_judge flag to output contracts
- Add getJudgmentReportFiles() to filter reports eligible for Phase 3 status judgment
- Add supervisor-validation output contract, remove review-summary
- Enhance output contracts with finding_id tracking (new/persists/resolved sections)
- Move runtime environment directory from .runtime to .takt/.runtime
- Update all builtin pieces, e2e fixtures, and tests
2026-02-15 11:17:55 +09:00

1276 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for instruction-builder module
*/
import { describe, it, expect } from 'vitest';
import {
InstructionBuilder,
isOutputContractItem,
ReportInstructionBuilder,
StatusJudgmentBuilder,
generateStatusRulesComponents,
type ReportInstructionContext,
type StatusJudgmentContext,
type InstructionContext,
} from '../core/piece/index.js';
// Function wrappers for test readability
function buildInstruction(step: PieceMovement, ctx: InstructionContext): string {
return new InstructionBuilder(step, ctx).build();
}
function buildReportInstruction(step: PieceMovement, ctx: ReportInstructionContext): string {
return new ReportInstructionBuilder(step, ctx).build();
}
function buildStatusJudgmentInstruction(step: PieceMovement, ctx: StatusJudgmentContext): string {
return new StatusJudgmentBuilder(step, ctx).build();
}
import type { PieceMovement, PieceRule } from '../core/models/index.js';
function createMinimalStep(template: string): PieceMovement {
return {
name: 'test-step',
persona: 'test-agent',
personaDisplayName: 'Test Agent',
instructionTemplate: template,
passPreviousResponse: false,
};
}
function createMinimalContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
return {
task: 'Test task',
iteration: 1,
maxMovements: 10,
movementIteration: 1,
cwd: '/project',
projectCwd: '/project',
userInputs: [],
pieceName: 'test-piece',
pieceDescription: 'Test piece description',
...overrides,
};
}
describe('instruction-builder', () => {
describe('execution context metadata', () => {
it('should always include Working Directory', () => {
const step = createMinimalStep('Do some work');
const context = createMinimalContext({ cwd: '/project' });
const result = buildInstruction(step, context);
expect(result).toContain('## Execution Context');
expect(result).toContain('Working Directory: /project');
expect(result).toContain('Do some work');
});
it('should NOT include Project Root even when cwd !== projectCwd', () => {
const step = createMinimalStep('Do some work');
const context = createMinimalContext({
cwd: '/worktree-path',
projectCwd: '/project-path',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Execution Context');
expect(result).toContain('Working Directory: /worktree-path');
expect(result).not.toContain('Project Root');
expect(result).not.toContain('Mode:');
expect(result).toContain('Do some work');
});
it('should prepend metadata before the instruction body', () => {
const step = createMinimalStep('Do some work');
const context = createMinimalContext({ cwd: '/project' });
const result = buildInstruction(step, context);
const metadataIndex = result.indexOf('## Execution Context');
const bodyIndex = result.indexOf('Do some work');
expect(metadataIndex).toBeLessThan(bodyIndex);
});
it('should include edit enabled prompt when step.edit is true', () => {
const step = { ...createMinimalStep('Implement feature'), edit: true as const };
const context = createMinimalContext({ cwd: '/project' });
const result = buildInstruction(step, context);
expect(result).toContain('Editing is ENABLED');
});
it('should include edit disabled prompt when step.edit is false', () => {
const step = { ...createMinimalStep('Review code'), edit: false as const };
const context = createMinimalContext({ cwd: '/project' });
const result = buildInstruction(step, context);
expect(result).toContain('Editing is DISABLED');
});
it('should not include edit prompt when step.edit is undefined', () => {
const step = createMinimalStep('Do some work');
const context = createMinimalContext({ cwd: '/project' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Editing is ENABLED');
expect(result).not.toContain('Editing is DISABLED');
});
});
describe('report_dir replacement', () => {
it('should replace {report_dir} with absolute path', () => {
const step = createMinimalStep(
'- Report Directory: {report_dir}/'
);
const context = createMinimalContext({
cwd: '/project',
reportDir: '/project/.takt/runs/20260128-test-report/reports',
});
const result = buildInstruction(step, context);
expect(result).toContain(
'- Report Directory: /project/.takt/runs/20260128-test-report/reports/'
);
});
it('should use absolute reportDir path in worktree mode', () => {
const step = createMinimalStep(
'- Report: {report_dir}/00-plan.md'
);
const context = createMinimalContext({
cwd: '/clone/my-task',
projectCwd: '/project',
reportDir: '/project/.takt/runs/20260128-worktree-report/reports',
});
const result = buildInstruction(step, context);
// reportDir is now absolute, pointing to projectCwd
expect(result).toContain(
'- Report: /project/.takt/runs/20260128-worktree-report/reports/00-plan.md'
);
expect(result).toContain('Working Directory: /clone/my-task');
});
it('should replace multiple {report_dir} occurrences with absolute path', () => {
const step = createMinimalStep(
'- Scope: {report_dir}/01-scope.md\n- Decisions: {report_dir}/02-decisions.md'
);
const context = createMinimalContext({
projectCwd: '/project',
cwd: '/worktree',
reportDir: '/project/.takt/runs/20260128-multi/reports',
});
const result = buildInstruction(step, context);
expect(result).toContain('/project/.takt/runs/20260128-multi/reports/01-scope.md');
expect(result).toContain('/project/.takt/runs/20260128-multi/reports/02-decisions.md');
});
it('should replace standalone {report_dir} with absolute path', () => {
const step = createMinimalStep(
'Report dir name: {report_dir}'
);
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260128-standalone/reports',
});
const result = buildInstruction(step, context);
expect(result).toContain('Report dir name: /project/.takt/runs/20260128-standalone/reports');
});
});
describe('context length control and source path injection', () => {
it('should truncate previous response and inject source path with conflict notice', () => {
const step = createMinimalStep('Continue work');
step.passPreviousResponse = true;
const longResponse = 'x'.repeat(2100);
const context = createMinimalContext({
previousOutput: {
persona: 'coder',
status: 'done',
content: longResponse,
timestamp: new Date(),
},
previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md',
});
const result = buildInstruction(step, context);
expect(result).toContain('...TRUNCATED...');
expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md');
expect(result).toContain('If prompt content conflicts with source files, source files take precedence.');
});
it('should always inject source paths when content is not truncated', () => {
const step = createMinimalStep('Do work');
step.passPreviousResponse = true;
const context = createMinimalContext({
previousOutput: {
persona: 'reviewer',
status: 'done',
content: 'short previous response',
timestamp: new Date(),
},
previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md',
knowledgeContents: ['short knowledge'],
knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md',
policyContents: ['short policy'],
policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md',
});
const result = buildInstruction(step, context);
expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md');
expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md');
expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md');
expect(result).not.toContain('...TRUNCATED...');
expect(result).not.toContain('Knowledge is truncated.');
expect(result).not.toContain('Policy is authoritative. If truncated');
expect(result).not.toContain('Previous Response is truncated.');
});
it('should not truncate when content length is exactly 2000 chars', () => {
const step = createMinimalStep('Do work');
step.passPreviousResponse = true;
const exactBoundary = 'x'.repeat(2000);
const context = createMinimalContext({
previousOutput: {
persona: 'reviewer',
status: 'done',
content: exactBoundary,
timestamp: new Date(),
},
previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md',
knowledgeContents: [exactBoundary],
knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md',
policyContents: [exactBoundary],
policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md',
});
const result = buildInstruction(step, context);
expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md');
expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md');
expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md');
expect(result).not.toContain('...TRUNCATED...');
});
it('should inject required truncated warning and source path for knowledge/policy', () => {
const step = createMinimalStep('Do work');
const longKnowledge = 'k'.repeat(2200);
const longPolicy = 'p'.repeat(2200);
const context = createMinimalContext({
knowledgeContents: [longKnowledge],
knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md',
policyContents: [longPolicy],
policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md',
});
const result = buildInstruction(step, context);
expect(result).toContain('Knowledge is truncated. You MUST consult the source files before making decisions.');
expect(result).toContain('Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly.');
expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md');
expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md');
});
});
describe('generateStatusRulesComponents', () => {
const rules: PieceRule[] = [
{ condition: '要件が明確で実装可能', next: 'implement' },
{ condition: 'ユーザーが質問をしている', next: 'COMPLETE' },
{ condition: '要件が不明確、情報不足', next: 'ABORT', appendix: '確認事項:\n- {質問1}\n- {質問2}' },
];
it('should generate criteria table with numbered tags (ja)', () => {
const result = generateStatusRulesComponents('plan', rules, 'ja');
expect(result.criteriaTable).toContain('| 1 | 要件が明確で実装可能 | `[PLAN:1]` |');
expect(result.criteriaTable).toContain('| 2 | ユーザーが質問をしている | `[PLAN:2]` |');
expect(result.criteriaTable).toContain('| 3 | 要件が不明確、情報不足 | `[PLAN:3]` |');
});
it('should generate criteria table with numbered tags (en)', () => {
const enRules: PieceRule[] = [
{ condition: 'Requirements are clear', next: 'implement' },
{ condition: 'User is asking a question', next: 'COMPLETE' },
];
const result = generateStatusRulesComponents('plan', enRules, 'en');
expect(result.criteriaTable).toContain('| 1 | Requirements are clear | `[PLAN:1]` |');
expect(result.criteriaTable).toContain('| 2 | User is asking a question | `[PLAN:2]` |');
});
it('should generate output list with condition labels', () => {
const result = generateStatusRulesComponents('plan', rules, 'ja');
expect(result.outputList).toContain('`[PLAN:1]` — 要件が明確で実装可能');
expect(result.outputList).toContain('`[PLAN:2]` — ユーザーが質問をしている');
expect(result.outputList).toContain('`[PLAN:3]` — 要件が不明確、情報不足');
});
it('should generate appendix content when rules have appendix', () => {
const result = generateStatusRulesComponents('plan', rules, 'ja');
expect(result.hasAppendix).toBe(true);
expect(result.appendixContent).toContain('[[PLAN:3]]');
expect(result.appendixContent).toContain('確認事項:');
expect(result.appendixContent).toContain('- {質問1}');
});
it('should not generate appendix when no rules have appendix', () => {
const noAppendixRules: PieceRule[] = [
{ condition: 'Done', next: 'review' },
{ condition: 'Blocked', next: 'plan' },
];
const result = generateStatusRulesComponents('implement', noAppendixRules, 'en');
expect(result.hasAppendix).toBe(false);
expect(result.appendixContent).toBe('');
});
it('should uppercase step name in tags', () => {
const result = generateStatusRulesComponents('ai_review', [
{ condition: 'No issues', next: 'supervise' },
], 'en');
expect(result.criteriaTable).toContain('`[AI_REVIEW:1]`');
});
it('should omit interactive-only rules when interactive is false', () => {
const filteredRules: PieceRule[] = [
{ condition: 'Clear', next: 'implement' },
{ condition: 'User input required', next: 'implement', interactiveOnly: true },
{ condition: 'Blocked', next: 'plan' },
];
const result = generateStatusRulesComponents('implement', filteredRules, 'en', { interactive: false });
expect(result.criteriaTable).toContain('`[IMPLEMENT:1]`');
expect(result.criteriaTable).toContain('`[IMPLEMENT:3]`');
expect(result.criteriaTable).not.toContain('User input required');
expect(result.criteriaTable).not.toContain('`[IMPLEMENT:2]`');
});
});
describe('buildInstruction with rules (Phase 1 — status rules injection)', () => {
it('should include status rules when tag-based rules exist', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Clear requirements', next: 'implement' },
{ condition: 'Unclear', next: 'ABORT' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
it('should not add status rules when rules do not exist', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Decision Criteria');
});
it('should not auto-generate when rules array is empty', () => {
const step = createMinimalStep('Do work');
step.rules = [];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Decision Criteria');
});
});
describe('auto-injected Piece Context section', () => {
it('should include piece name when provided', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
pieceName: 'my-piece',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Piece Context');
expect(result).toContain('- Piece: my-piece');
});
it('should include piece description when provided', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
pieceName: 'my-piece',
pieceDescription: 'A test piece for validation',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Piece Context');
expect(result).toContain('- Piece: my-piece');
expect(result).toContain('- Description: A test piece for validation');
});
it('should not show description when not provided', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
pieceName: 'my-piece',
pieceDescription: undefined,
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('- Piece: my-piece');
expect(result).not.toContain('- Description:');
});
it('should render piece context in Japanese', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
pieceName: 'coding',
pieceDescription: 'コーディングピース',
language: 'ja',
});
const result = buildInstruction(step, context);
expect(result).toContain('- ピース: coding');
expect(result).toContain('- 説明: コーディングピース');
});
it('should include iteration, step iteration, and step name', () => {
const step = createMinimalStep('Do work');
step.name = 'implement';
const context = createMinimalContext({
iteration: 3,
maxMovements: 20,
movementIteration: 2,
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Piece Context');
expect(result).toContain('- Iteration: 3/20');
expect(result).toContain('- Movement Iteration: 2');
expect(result).toContain('- Movement: implement');
});
it('should include report info in Phase 1 when step has report', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Piece Context');
expect(result).toContain('Report Directory');
expect(result).toContain('Report File');
expect(result).toContain('Phase 1');
});
it('should include report info for OutputContractEntry[] in Phase 1', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [
{ name: '01-scope.md', format: '01-scope', useJudge: true },
{ name: '02-decisions.md', format: '02-decisions', useJudge: true },
];
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('Report Directory');
expect(result).toContain('Report Files');
expect(result).toContain('Phase 1');
});
it('should include report info for OutputContractItem in Phase 1', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
// Phase 1 now includes Report Directory info and phase note
expect(result).toContain('Report Directory');
expect(result).toContain('Report File');
expect(result).toContain('Phase 1');
});
it('should render Japanese step iteration suffix', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
movementIteration: 3,
language: 'ja',
});
const result = buildInstruction(step, context);
expect(result).toContain('- Movement Iteration: 3このムーブメントの実行回数');
});
it('should include piece structure when pieceSteps is provided', () => {
const step = createMinimalStep('Do work');
step.name = 'implement';
const context = createMinimalContext({
language: 'en',
pieceMovements: [
{ name: 'plan' },
{ name: 'implement' },
{ name: 'review' },
],
currentMovementIndex: 1,
});
const result = buildInstruction(step, context);
expect(result).toContain('This piece consists of 3 movements:');
expect(result).toContain('- Movement 1: plan');
expect(result).toContain('- Movement 2: implement');
expect(result).toContain('← current');
expect(result).toContain('- Movement 3: review');
});
it('should mark current step with marker', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
const context = createMinimalContext({
language: 'en',
pieceMovements: [
{ name: 'plan' },
{ name: 'implement' },
],
currentMovementIndex: 0,
});
const result = buildInstruction(step, context);
expect(result).toContain('- Movement 1: plan ← current');
expect(result).not.toContain('- Movement 2: implement ← current');
});
it('should include description in parentheses when provided', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
const context = createMinimalContext({
language: 'ja',
pieceMovements: [
{ name: 'plan', description: 'タスクを分析し実装計画を作成する' },
{ name: 'implement' },
],
currentMovementIndex: 0,
});
const result = buildInstruction(step, context);
expect(result).toContain('- Movement 1: planタスクを分析し実装計画を作成する ← 現在');
});
it('should skip piece structure when pieceSteps is not provided', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('This piece consists of');
});
it('should skip piece structure when pieceSteps is empty', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
language: 'en',
pieceMovements: [],
currentMovementIndex: -1,
});
const result = buildInstruction(step, context);
expect(result).not.toContain('This piece consists of');
});
it('should render piece structure in Japanese', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
const context = createMinimalContext({
language: 'ja',
pieceMovements: [
{ name: 'plan' },
{ name: 'implement' },
],
currentMovementIndex: 0,
});
const result = buildInstruction(step, context);
expect(result).toContain('このピースは2ムーブメントで構成されています:');
expect(result).toContain('← 現在');
});
it('should not show current marker when currentMovementIndex is -1', () => {
const step = createMinimalStep('Do work');
step.name = 'sub-step';
const context = createMinimalContext({
language: 'en',
pieceMovements: [
{ name: 'plan' },
{ name: 'implement' },
],
currentMovementIndex: -1,
});
const result = buildInstruction(step, context);
expect(result).toContain('This piece consists of 2 movements:');
expect(result).not.toContain('← current');
});
});
describe('buildInstruction report-free (phase separation)', () => {
it('should include Report Directory info but NOT report output instruction in Phase 1', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
// Phase 1 includes Report Directory info and phase note
expect(result).toContain('Report Directory');
expect(result).toContain('Report File');
expect(result).toContain('Phase 1');
expect(result).toContain('Phase 2 will automatically generate the report');
// But NOT the report output instruction (that's for Phase 2)
expect(result).not.toContain('**Report output:**');
});
it('should NOT include output contract in buildInstruction', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '**Format:**\n# Plan', useJudge: true }];
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).not.toContain('**Format:**');
});
it('should NOT include report order in buildInstruction', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{
name: '00-plan.md',
order: 'Custom order instruction',
}];
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).not.toContain('Custom order instruction');
});
it('should still replace {report:filename} in instruction_template', () => {
const step = createMinimalStep('Write to {report:00-plan.md}');
const context = createMinimalContext({
reportDir: '/project/.takt/runs/20260129-test/reports',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('Write to /project/.takt/runs/20260129-test/reports/00-plan.md');
expect(result).not.toContain('{report:00-plan.md}');
});
});
describe('buildReportInstruction (phase 2)', () => {
function createReportContext(overrides: Partial<ReportInstructionContext> = {}): ReportInstructionContext {
return {
cwd: '/project',
reportDir: '/project/.takt/runs/20260129-test/reports',
movementIteration: 1,
language: 'en',
...overrides,
};
}
it('should include execution context with working directory', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext({ cwd: '/my/project' });
const result = buildReportInstruction(step, ctx);
expect(result).toContain('Working Directory: /my/project');
});
it('should include no-source-edit rule in execution rules', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('Do NOT modify project source files');
});
it('should include no-commit and no-cd rules', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('Do NOT run git commit');
expect(result).toContain('Do NOT use `cd`');
});
it('should include report directory and file for string report', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext({ reportDir: '/project/.takt/runs/20260130-test/reports' });
const result = buildReportInstruction(step, ctx);
expect(result).toContain('- Report Directory: /project/.takt/runs/20260130-test/reports/');
expect(result).toContain('- Report File: /project/.takt/runs/20260130-test/reports/00-plan.md');
});
it('should include report files for OutputContractEntry[] report', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [
{ name: '01-scope.md', format: '01-scope', useJudge: true },
{ name: '02-decisions.md', format: '02-decisions', useJudge: true },
];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('- Report Directory: /project/.takt/runs/20260129-test/reports/');
expect(result).toContain('- Report Files:');
expect(result).toContain(' - 01-scope.md: /project/.takt/runs/20260129-test/reports/01-scope.md');
expect(result).toContain(' - 02-decisions.md: /project/.takt/runs/20260129-test/reports/02-decisions.md');
});
it('should include report file for OutputContractItem report', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('- Report File: /project/.takt/runs/20260129-test/reports/00-plan.md');
});
it('should include auto-generated report output instruction', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('**Report output:** Output to the `Report File` specified above.');
expect(result).toContain('- If file does not exist: Create new file');
expect(result).toContain('- If file exists: Move current content to `logs/reports-history/` and overwrite with latest report');
});
it('should include explicit order instead of auto-generated', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{
name: '00-plan.md',
order: 'Output to {report:00-plan.md} file.',
format: '00-plan',
useJudge: true,
}];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('Output to /project/.takt/runs/20260129-test/reports/00-plan.md file.');
expect(result).not.toContain('**Report output:**');
});
it('should include format from OutputContractItem', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{
name: '00-plan.md',
format: '**Format:**\n```markdown\n# Plan\n```',
useJudge: true,
}];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('**Format:**');
expect(result).toContain('# Plan');
});
it('should include overwrite-and-archive rule in report output instruction', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext({ movementIteration: 5 });
const result = buildReportInstruction(step, ctx);
expect(result).toContain('Move current content to `logs/reports-history/` and overwrite with latest report');
});
it('should include instruction body text', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('## Instructions');
expect(result).toContain('Respond with the results of the work you just completed as a report');
});
it('should NOT include user request, previous response, or status rules', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
step.rules = [
{ condition: 'Done', next: 'COMPLETE' },
];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).not.toContain('User Request');
expect(result).not.toContain('Previous Response');
expect(result).not.toContain('Additional User Inputs');
expect(result).not.toContain('Status Output Rules');
});
it('should render Japanese report instruction', () => {
const step = createMinimalStep('作業する');
step.outputContracts = [{ name: '00-plan.md', format: '00-plan', useJudge: true }];
const ctx = createReportContext({ language: 'ja' });
const result = buildReportInstruction(step, ctx);
expect(result).toContain('あなたが今行った作業の結果をレポートとして回答してください');
expect(result).toContain('プロジェクトのソースファイルを変更しないでください');
expect(result).toContain('**レポート出力:** `Report File` に出力してください。');
});
it('should throw error when step has no output contracts', () => {
const step = createMinimalStep('Do work');
const ctx = createReportContext();
expect(() => buildReportInstruction(step, ctx)).toThrow('no output contracts');
});
it('should include multi-file report output instruction for OutputContractEntry[]', () => {
const step = createMinimalStep('Do work');
step.outputContracts = [
{ name: '01-scope.md', format: '01-scope', useJudge: true },
{ name: '02-decisions.md', format: '02-decisions', useJudge: true },
];
const ctx = createReportContext();
const result = buildReportInstruction(step, ctx);
expect(result).toContain('**Report output:** Output to the `Report Files` specified above.');
});
});
describe('auto-injected User Request and Additional User Inputs sections', () => {
it('should include User Request section with task', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({ task: 'Build the feature', language: 'en' });
const result = buildInstruction(step, context);
expect(result).toContain('## User Request\n');
});
it('should include Additional User Inputs section', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
userInputs: ['input1', 'input2'],
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Additional User Inputs\n');
});
it('should include Previous Response when passPreviousResponse is true and output exists', () => {
const step = createMinimalStep('Do work');
step.passPreviousResponse = true;
const context = createMinimalContext({
previousOutput: { content: 'Previous result', tag: '[TEST:1]' },
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).toContain('## Previous Response\n');
});
it('should NOT include Previous Response when passPreviousResponse is false', () => {
const step = createMinimalStep('Do work');
step.passPreviousResponse = false;
const context = createMinimalContext({
previousOutput: { content: 'Previous result', tag: '[TEST:1]' },
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).not.toContain('## Previous Response');
});
it('should include Instructions header before template content', () => {
const step = createMinimalStep('My specific instructions here');
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
const instructionsIdx = result.indexOf('## Instructions');
const contentIdx = result.indexOf('My specific instructions here');
expect(instructionsIdx).toBeGreaterThan(-1);
expect(contentIdx).toBeGreaterThan(instructionsIdx);
});
it('should skip auto-injected User Request when template contains {task}', () => {
const step = createMinimalStep('Process this: {task}');
const context = createMinimalContext({ task: 'My task', language: 'en' });
const result = buildInstruction(step, context);
// Auto-injected section should NOT appear
expect(result).not.toContain('## User Request');
// But template placeholder should be replaced
expect(result).toContain('Process this: My task');
});
it('should skip auto-injected Previous Response when template contains {previous_response}', () => {
const step = createMinimalStep('## Feedback\n{previous_response}\n\nFix the issues.');
step.passPreviousResponse = true;
const context = createMinimalContext({
previousOutput: { content: 'Review feedback here', tag: '[TEST:1]' },
language: 'en',
});
const result = buildInstruction(step, context);
// Auto-injected section should NOT appear
expect(result).not.toContain('## Previous Response\n');
// But template placeholder should be replaced with content
expect(result).toContain('## Feedback\nReview feedback here');
});
it('should apply truncation and source path when {previous_response} placeholder is used', () => {
const step = createMinimalStep('## Feedback\n{previous_response}\n\nFix the issues.');
step.passPreviousResponse = true;
const context = createMinimalContext({
previousOutput: { content: 'x'.repeat(2100), tag: '[TEST:1]' },
previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md',
language: 'en',
});
const result = buildInstruction(step, context);
expect(result).not.toContain('## Previous Response\n');
expect(result).toContain('## Feedback');
expect(result).toContain('...TRUNCATED...');
expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md');
expect(result).toContain('If prompt content conflicts with source files, source files take precedence.');
});
it('should skip auto-injected Additional User Inputs when template contains {user_inputs}', () => {
const step = createMinimalStep('Inputs: {user_inputs}');
const context = createMinimalContext({
userInputs: ['extra info'],
language: 'en',
});
const result = buildInstruction(step, context);
// Auto-injected section should NOT appear
expect(result).not.toContain('## Additional User Inputs');
// But template placeholder should be replaced
expect(result).toContain('Inputs: extra info');
});
});
describe('basic placeholder replacement', () => {
it('should replace {task} placeholder', () => {
const step = createMinimalStep('Execute: {task}');
const context = createMinimalContext({ task: 'Build the app' });
const result = buildInstruction(step, context);
expect(result).toContain('Build the app');
});
it('should replace {iteration} and {max_movements}', () => {
const step = createMinimalStep('Step {iteration}/{max_movements}');
const context = createMinimalContext({ iteration: 3, maxMovements: 20 });
const result = buildInstruction(step, context);
expect(result).toContain('Step 3/20');
});
it('should replace {movement_iteration}', () => {
const step = createMinimalStep('Run #{movement_iteration}');
const context = createMinimalContext({ movementIteration: 2 });
const result = buildInstruction(step, context);
expect(result).toContain('Run #2');
});
});
describe('status rules injection — skip when all rules are ai()/aggregate', () => {
it('should NOT include status rules when all rules are ai() conditions', () => {
const step = createMinimalStep('Do work');
step.rules = [
{ condition: 'ai("No issues")', next: 'COMPLETE', isAiCondition: true, aiConditionText: 'No issues' },
{ condition: 'ai("Issues found")', next: 'fix', isAiCondition: true, aiConditionText: 'Issues found' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Decision Criteria');
expect(result).not.toContain('[TEST-STEP:');
});
it('should NOT include status rules with mixed regular and ai() conditions (Phase 1 no longer has status rules)', () => {
const step = createMinimalStep('Do work');
step.name = 'review';
step.rules = [
{ condition: 'Error occurred', next: 'ABORT' },
{ condition: 'ai("Issues found")', next: 'fix', isAiCondition: true, aiConditionText: 'Issues found' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
it('should NOT include status rules with regular conditions only (Phase 1 no longer has status rules)', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Done', next: 'COMPLETE' },
{ condition: 'Blocked', next: 'ABORT' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
it('should NOT include status rules when all rules are aggregate conditions', () => {
const step = createMinimalStep('Do work');
step.rules = [
{ condition: 'all("approved")', next: 'COMPLETE', isAggregateCondition: true, aggregateType: 'all' as const, aggregateConditionText: 'approved' },
{ condition: 'any("rejected")', next: 'fix', isAggregateCondition: true, aggregateType: 'any' as const, aggregateConditionText: 'rejected' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Decision Criteria');
});
it('should NOT include status rules when all rules are ai() + aggregate', () => {
const step = createMinimalStep('Do work');
step.rules = [
{ condition: 'all("approved")', next: 'COMPLETE', isAggregateCondition: true, aggregateType: 'all' as const, aggregateConditionText: 'approved' },
{ condition: 'any("rejected")', next: 'fix', isAggregateCondition: true, aggregateType: 'any' as const, aggregateConditionText: 'rejected' },
{ condition: 'ai("Judgment needed")', next: 'manual', isAiCondition: true, aiConditionText: 'Judgment needed' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
expect(result).not.toContain('Decision Criteria');
});
it('should NOT include status rules with mixed aggregate and regular conditions (Phase 1 no longer has status rules)', () => {
const step = createMinimalStep('Do work');
step.name = 'supervise';
step.rules = [
{ condition: 'all("approved")', next: 'COMPLETE', isAggregateCondition: true, aggregateType: 'all' as const, aggregateConditionText: 'approved' },
{ condition: 'Error occurred', next: 'ABORT' },
];
const context = createMinimalContext({ language: 'en' });
const result = buildInstruction(step, context);
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
});
describe('isOutputContractItem', () => {
it('should return true for OutputContractItem', () => {
expect(isOutputContractItem({ name: '00-plan.md', format: '00-plan', useJudge: true })).toBe(true);
});
it('should return true for OutputContractItem with order/format', () => {
expect(isOutputContractItem({ name: '00-plan.md', order: 'output to...', format: '# Plan' })).toBe(true);
});
it('should return false when name is missing', () => {
expect(isOutputContractItem({ format: '01-scope', useJudge: true })).toBe(false);
});
});
describe('buildStatusJudgmentInstruction (Phase 3)', () => {
function createJudgmentContext(overrides: Partial<StatusJudgmentContext> = {}): StatusJudgmentContext {
return {
language: 'en',
reportContent: '# Test Report\n\nReport content for testing.',
...overrides,
};
}
it('should include header instruction (en)', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Clear requirements', next: 'implement' },
{ condition: 'Unclear', next: 'ABORT' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('Review is already complete');
expect(result).toContain('Output exactly one tag corresponding to the judgment result');
});
it('should include header instruction (ja)', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: '要件が明確', next: 'implement' },
{ condition: '不明確', next: 'ABORT' },
];
const ctx = createJudgmentContext({ language: 'ja' });
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('既にレビューは完了しています');
expect(result).toContain('レポートで示された判定結果に対応するタグを1つだけ出力してください');
});
it('should include criteria table with tags', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Clear requirements', next: 'implement' },
{ condition: 'Unclear', next: 'ABORT' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('## Decision Criteria');
expect(result).toContain('`[PLAN:1]`');
expect(result).toContain('`[PLAN:2]`');
});
it('should include output format section', () => {
const step = createMinimalStep('Do work');
step.name = 'review';
step.rules = [
{ condition: 'Approved', next: 'COMPLETE' },
{ condition: 'Rejected', next: 'fix' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('## Output Format');
expect(result).toContain('`[REVIEW:1]` — Approved');
expect(result).toContain('`[REVIEW:2]` — Rejected');
});
it('should throw error when step has no rules', () => {
const step = createMinimalStep('Do work');
const ctx = createJudgmentContext();
expect(() => buildStatusJudgmentInstruction(step, ctx)).toThrow('no rules');
});
it('should throw error when step has empty rules', () => {
const step = createMinimalStep('Do work');
step.rules = [];
const ctx = createJudgmentContext();
expect(() => buildStatusJudgmentInstruction(step, ctx)).toThrow('no rules');
});
it('should default language to en', () => {
const step = createMinimalStep('Do work');
step.name = 'test';
step.rules = [{ condition: 'Done', next: 'COMPLETE' }];
const ctx: StatusJudgmentContext = { reportContent: 'Test report content' };
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('Review is already complete');
expect(result).toContain('## Decision Criteria');
});
it('should include appendix template when rules have appendix', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
{ condition: 'Done', next: 'COMPLETE' },
{ condition: 'Blocked', next: 'ABORT', appendix: '確認事項:\n- {質問1}' },
];
const ctx = createJudgmentContext();
const result = buildStatusJudgmentInstruction(step, ctx);
expect(result).toContain('Appendix Template');
expect(result).toContain('確認事項:');
});
});
});