- rules の condition に ai("...") 式を追加し、別AIが遷移先を判断する仕組みを導入
- ワークフローステップに parallel フィールドを追加し、サブステップの並列実行を実装
- all()/any() 集約条件の仕様書を追加
301 lines
9.5 KiB
TypeScript
301 lines
9.5 KiB
TypeScript
/**
|
|
* Tests for parallel step execution and ai() condition loader
|
|
*
|
|
* Covers:
|
|
* - Schema validation for parallel sub-steps
|
|
* - Workflow loader normalization of ai() conditions and parallel steps
|
|
* - Engine parallel step aggregation logic
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { WorkflowConfigRawSchema, ParallelSubStepRawSchema, WorkflowStepRawSchema } from '../models/schemas.js';
|
|
|
|
describe('ParallelSubStepRawSchema', () => {
|
|
it('should validate a valid parallel sub-step', () => {
|
|
const raw = {
|
|
name: 'arch-review',
|
|
agent: '~/.takt/agents/default/reviewer.md',
|
|
instruction_template: 'Review architecture',
|
|
};
|
|
|
|
const result = ParallelSubStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject a sub-step without agent', () => {
|
|
const raw = {
|
|
name: 'no-agent-step',
|
|
instruction_template: 'Do something',
|
|
};
|
|
|
|
const result = ParallelSubStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should accept optional fields', () => {
|
|
const raw = {
|
|
name: 'full-sub-step',
|
|
agent: '~/.takt/agents/default/coder.md',
|
|
agent_name: 'Coder',
|
|
allowed_tools: ['Read', 'Grep'],
|
|
model: 'haiku',
|
|
edit: false,
|
|
instruction_template: 'Do work',
|
|
report: '01-report.md',
|
|
pass_previous_response: false,
|
|
};
|
|
|
|
const result = ParallelSubStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.agent_name).toBe('Coder');
|
|
expect(result.data.allowed_tools).toEqual(['Read', 'Grep']);
|
|
expect(result.data.edit).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should accept rules on sub-steps', () => {
|
|
const raw = {
|
|
name: 'reviewed',
|
|
agent: '~/.takt/agents/default/reviewer.md',
|
|
instruction_template: 'Review',
|
|
rules: [
|
|
{ condition: 'No issues', next: 'COMPLETE' },
|
|
{ condition: 'Issues found', next: 'fix' },
|
|
],
|
|
};
|
|
|
|
const result = ParallelSubStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.rules).toHaveLength(2);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('WorkflowStepRawSchema with parallel', () => {
|
|
it('should accept a step with parallel sub-steps (no agent)', () => {
|
|
const raw = {
|
|
name: 'parallel-review',
|
|
parallel: [
|
|
{ name: 'arch-review', agent: 'reviewer.md', instruction_template: 'Review arch' },
|
|
{ name: 'sec-review', agent: 'security.md', instruction_template: 'Review security' },
|
|
],
|
|
rules: [
|
|
{ condition: 'All pass', next: 'COMPLETE' },
|
|
],
|
|
};
|
|
|
|
const result = WorkflowStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject a step with neither agent nor parallel', () => {
|
|
const raw = {
|
|
name: 'orphan-step',
|
|
instruction_template: 'Do something',
|
|
};
|
|
|
|
const result = WorkflowStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should accept a step with agent (no parallel)', () => {
|
|
const raw = {
|
|
name: 'normal-step',
|
|
agent: 'coder.md',
|
|
instruction_template: 'Code something',
|
|
};
|
|
|
|
const result = WorkflowStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject a step with empty parallel array', () => {
|
|
const raw = {
|
|
name: 'empty-parallel',
|
|
parallel: [],
|
|
};
|
|
|
|
const result = WorkflowStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('WorkflowConfigRawSchema with parallel steps', () => {
|
|
it('should validate a workflow with parallel step', () => {
|
|
const raw = {
|
|
name: 'test-parallel-workflow',
|
|
steps: [
|
|
{
|
|
name: 'plan',
|
|
agent: 'planner.md',
|
|
rules: [{ condition: 'Plan complete', next: 'review' }],
|
|
},
|
|
{
|
|
name: 'review',
|
|
parallel: [
|
|
{ name: 'arch-review', agent: 'arch-reviewer.md', instruction_template: 'Review architecture' },
|
|
{ name: 'sec-review', agent: 'sec-reviewer.md', instruction_template: 'Review security' },
|
|
],
|
|
rules: [
|
|
{ condition: 'All approved', next: 'COMPLETE' },
|
|
{ condition: 'Issues found', next: 'plan' },
|
|
],
|
|
},
|
|
],
|
|
initial_step: 'plan',
|
|
max_iterations: 10,
|
|
};
|
|
|
|
const result = WorkflowConfigRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.steps).toHaveLength(2);
|
|
expect(result.data.steps[1].parallel).toHaveLength(2);
|
|
}
|
|
});
|
|
|
|
it('should validate a workflow mixing normal and parallel steps', () => {
|
|
const raw = {
|
|
name: 'mixed-workflow',
|
|
steps: [
|
|
{ name: 'plan', agent: 'planner.md', rules: [{ condition: 'Done', next: 'implement' }] },
|
|
{ name: 'implement', agent: 'coder.md', rules: [{ condition: 'Done', next: 'review' }] },
|
|
{
|
|
name: 'review',
|
|
parallel: [
|
|
{ name: 'arch', agent: 'arch.md' },
|
|
{ name: 'sec', agent: 'sec.md' },
|
|
],
|
|
rules: [{ condition: 'All pass', next: 'COMPLETE' }],
|
|
},
|
|
],
|
|
initial_step: 'plan',
|
|
};
|
|
|
|
const result = WorkflowConfigRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.steps[0].agent).toBe('planner.md');
|
|
expect(result.data.steps[2].parallel).toHaveLength(2);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('ai() condition in WorkflowRuleSchema', () => {
|
|
it('should accept ai() condition as a string', () => {
|
|
const raw = {
|
|
name: 'test-step',
|
|
agent: 'agent.md',
|
|
rules: [
|
|
{ condition: 'ai("All reviews approved")', next: 'COMPLETE' },
|
|
{ condition: 'ai("Issues detected")', next: 'fix' },
|
|
],
|
|
};
|
|
|
|
const result = WorkflowStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.rules?.[0].condition).toBe('ai("All reviews approved")');
|
|
expect(result.data.rules?.[1].condition).toBe('ai("Issues detected")');
|
|
}
|
|
});
|
|
|
|
it('should accept mixed regular and ai() conditions', () => {
|
|
const raw = {
|
|
name: 'mixed-rules',
|
|
agent: 'agent.md',
|
|
rules: [
|
|
{ condition: 'Regular condition', next: 'step-a' },
|
|
{ condition: 'ai("AI evaluated condition")', next: 'step-b' },
|
|
],
|
|
};
|
|
|
|
const result = WorkflowStepRawSchema.safeParse(raw);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ai() condition regex parsing', () => {
|
|
// Test the regex pattern used in workflowLoader.ts
|
|
const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/;
|
|
|
|
it('should match simple ai() condition', () => {
|
|
const match = 'ai("No issues found")'.match(AI_CONDITION_REGEX);
|
|
expect(match).not.toBeNull();
|
|
expect(match![1]).toBe('No issues found');
|
|
});
|
|
|
|
it('should match ai() with Japanese text', () => {
|
|
const match = 'ai("全てのレビューが承認している場合")'.match(AI_CONDITION_REGEX);
|
|
expect(match).not.toBeNull();
|
|
expect(match![1]).toBe('全てのレビューが承認している場合');
|
|
});
|
|
|
|
it('should not match regular condition text', () => {
|
|
const match = 'No issues found'.match(AI_CONDITION_REGEX);
|
|
expect(match).toBeNull();
|
|
});
|
|
|
|
it('should not match partial ai() pattern', () => {
|
|
expect('ai(missing quotes)'.match(AI_CONDITION_REGEX)).toBeNull();
|
|
expect('ai("")'.match(AI_CONDITION_REGEX)).toBeNull(); // .+ requires at least 1 char
|
|
expect('not ai("text")'.match(AI_CONDITION_REGEX)).toBeNull(); // must start with ai(
|
|
expect('ai("text") extra'.match(AI_CONDITION_REGEX)).toBeNull(); // must end with )
|
|
});
|
|
|
|
it('should match ai() with special characters in text', () => {
|
|
const match = 'ai("Issues found (critical/high severity)")'.match(AI_CONDITION_REGEX);
|
|
expect(match).not.toBeNull();
|
|
expect(match![1]).toBe('Issues found (critical/high severity)');
|
|
});
|
|
});
|
|
|
|
describe('parallel step aggregation format', () => {
|
|
it('should aggregate sub-step outputs in the expected format', () => {
|
|
// Mirror the aggregation logic from engine.ts
|
|
const subResults = [
|
|
{ name: 'arch-review', content: 'Architecture looks good.\n## Result: APPROVE' },
|
|
{ name: 'sec-review', content: 'No security issues.\n## Result: APPROVE' },
|
|
];
|
|
|
|
const aggregatedContent = subResults
|
|
.map((r) => `## ${r.name}\n${r.content}`)
|
|
.join('\n\n---\n\n');
|
|
|
|
expect(aggregatedContent).toContain('## arch-review');
|
|
expect(aggregatedContent).toContain('Architecture looks good.');
|
|
expect(aggregatedContent).toContain('---');
|
|
expect(aggregatedContent).toContain('## sec-review');
|
|
expect(aggregatedContent).toContain('No security issues.');
|
|
});
|
|
|
|
it('should handle single sub-step', () => {
|
|
const subResults = [
|
|
{ name: 'only-step', content: 'Single result' },
|
|
];
|
|
|
|
const aggregatedContent = subResults
|
|
.map((r) => `## ${r.name}\n${r.content}`)
|
|
.join('\n\n---\n\n');
|
|
|
|
expect(aggregatedContent).toBe('## only-step\nSingle result');
|
|
expect(aggregatedContent).not.toContain('---');
|
|
});
|
|
|
|
it('should handle empty content from sub-steps', () => {
|
|
const subResults = [
|
|
{ name: 'step-a', content: '' },
|
|
{ name: 'step-b', content: 'Has content' },
|
|
];
|
|
|
|
const aggregatedContent = subResults
|
|
.map((r) => `## ${r.name}\n${r.content}`)
|
|
.join('\n\n---\n\n');
|
|
|
|
expect(aggregatedContent).toContain('## step-a\n');
|
|
expect(aggregatedContent).toContain('## step-b\nHas content');
|
|
});
|
|
});
|