takt/src/__tests__/agent-usecases.test.ts
nrslib 47612d9dcc refactor: agent-usecases / schema-loader の移動と pieceExecution の責務分割
- agent-usecases.ts を core/piece/ → agents/ へ移動
- schema-loader.ts を core/piece/ → infra/resources/ へ移動
- interactive-summary-types.ts を分離、shared/types/ ディレクトリを追加
- pieceExecution.ts を abortHandler / analyticsEmitter / iterationLimitHandler / outputFns / runMeta / sessionLogger に分割
- buildMergeFn を async → sync に変更(custom merge の file 戦略を削除)
- cleanupOrphanedClone にパストラバーサル保護を追加
- review-fix / frontend-review-fix ピースの IT テストを追加
2026-03-02 21:20:50 +09:00

281 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runAgent } from '../agents/runner.js';
import { parseParts } from '../core/piece/engine/task-decomposer.js';
import { detectJudgeIndex } from '../agents/judge-utils.js';
import {
executeAgent,
generateReport,
executePart,
evaluateCondition,
judgeStatus,
decomposeTask,
requestMoreParts,
} from '../agents/agent-usecases.js';
vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../infra/resources/schema-loader.js', () => ({
loadJudgmentSchema: vi.fn(() => ({ type: 'judgment' })),
loadEvaluationSchema: vi.fn(() => ({ type: 'evaluation' })),
loadDecompositionSchema: vi.fn((maxParts: number) => ({ type: 'decomposition', maxParts })),
loadMorePartsSchema: vi.fn((maxAdditionalParts: number) => ({ type: 'more-parts', maxAdditionalParts })),
}));
vi.mock('../core/piece/engine/task-decomposer.js', () => ({
parseParts: vi.fn(),
}));
vi.mock('../agents/judge-utils.js', () => ({
buildJudgePrompt: vi.fn(() => 'judge prompt'),
detectJudgeIndex: vi.fn(() => -1),
}));
function doneResponse(content: string, structuredOutput?: Record<string, unknown>) {
return {
persona: 'tester',
status: 'done' as const,
content,
timestamp: new Date('2026-02-12T00:00:00Z'),
structuredOutput,
};
}
const judgeOptions = { cwd: '/repo', movementName: 'review' };
describe('agent-usecases', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('executeAgent/generateReport/executePart は runAgent に委譲する', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('ok'));
await executeAgent('coder', 'do work', { cwd: '/tmp' });
await generateReport('coder', 'write report', { cwd: '/tmp' });
await executePart('coder', 'part work', { cwd: '/tmp' });
expect(runAgent).toHaveBeenCalledTimes(3);
expect(runAgent).toHaveBeenNthCalledWith(1, 'coder', 'do work', { cwd: '/tmp' });
expect(runAgent).toHaveBeenNthCalledWith(2, 'coder', 'write report', { cwd: '/tmp' });
expect(runAgent).toHaveBeenNthCalledWith(3, 'coder', 'part work', { cwd: '/tmp' });
});
it('evaluateCondition は構造化出力の matched_index を優先する', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('ignored', { matched_index: 2 }));
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
{ index: 1, text: 'second' },
], { cwd: '/repo' });
expect(result).toBe(1);
expect(runAgent).toHaveBeenCalledWith(undefined, 'judge prompt', expect.objectContaining({
cwd: '/repo',
outputSchema: { type: 'evaluation' },
}));
});
it('evaluateCondition は構造化出力が使えない場合にタグ検出へフォールバックする', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('[JUDGE:2]'));
vi.mocked(detectJudgeIndex).mockReturnValue(1);
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
{ index: 1, text: 'second' },
], { cwd: '/repo' });
expect(result).toBe(1);
expect(detectJudgeIndex).toHaveBeenCalledWith('[JUDGE:2]');
});
it('evaluateCondition は runAgent が done 以外なら -1 を返す', async () => {
vi.mocked(runAgent).mockResolvedValue({
persona: 'tester',
status: 'error',
content: 'failed',
timestamp: new Date('2026-02-12T00:00:00Z'),
});
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
], { cwd: '/repo' });
expect(result).toBe(-1);
expect(detectJudgeIndex).not.toHaveBeenCalled();
});
// --- judgeStatus: 3-stage fallback ---
it('judgeStatus は単一ルール時に auto_select を返す', async () => {
const result = await judgeStatus('structured', 'tag', [{ condition: 'always', next: 'done' }], judgeOptions);
expect(result).toEqual({ ruleIndex: 0, method: 'auto_select' });
expect(runAgent).not.toHaveBeenCalled();
});
it('judgeStatus はルールが空ならエラー', async () => {
await expect(judgeStatus('structured', 'tag', [], judgeOptions))
.rejects.toThrow('judgeStatus requires at least one rule');
});
it('judgeStatus は Stage 1 で構造化出力 step を採用する', async () => {
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('x', { step: 2 }));
const result = await judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions);
expect(result).toEqual({ ruleIndex: 1, method: 'structured_output' });
expect(runAgent).toHaveBeenCalledTimes(1);
expect(runAgent).toHaveBeenCalledWith('conductor', 'structured', expect.objectContaining({
outputSchema: { type: 'judgment' },
}));
});
it('judgeStatus は Stage 2 でタグ検出を使う', async () => {
// Stage 1: structured output fails (no structuredOutput)
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
// Stage 2: tag detection succeeds
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('[REVIEW:2]'));
const result = await judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions);
expect(result).toEqual({ ruleIndex: 1, method: 'phase3_tag' });
expect(runAgent).toHaveBeenCalledTimes(2);
expect(runAgent).toHaveBeenNthCalledWith(1, 'conductor', 'structured', expect.objectContaining({
outputSchema: { type: 'judgment' },
}));
expect(runAgent).toHaveBeenNthCalledWith(2, 'conductor', 'tag', expect.not.objectContaining({
outputSchema: expect.anything(),
}));
});
it('judgeStatus は Stage 3 で AI Judge を使う', async () => {
// Stage 1: structured output fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
// Stage 2: tag detection fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no tag'));
// Stage 3: evaluateCondition succeeds
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('ignored', { matched_index: 2 }));
const result = await judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions);
expect(result).toEqual({ ruleIndex: 1, method: 'ai_judge' });
expect(runAgent).toHaveBeenCalledTimes(3);
});
it('judgeStatus は全ての判定に失敗したらエラー', async () => {
// Stage 1: structured output fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
// Stage 2: tag detection fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no tag'));
// Stage 3: evaluateCondition fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('still no match'));
vi.mocked(detectJudgeIndex).mockReturnValue(-1);
await expect(judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions)).rejects.toThrow('Status not found for movement "review"');
});
// --- decomposeTask ---
it('decomposeTask は構造化出力 parts を返す', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('x', {
parts: [
{ id: 'p1', title: 'Part 1', instruction: 'Do 1', timeout_ms: 1000 },
],
}));
const result = await decomposeTask('instruction', 3, { cwd: '/repo', persona: 'team-leader' });
expect(result).toEqual([
{ id: 'p1', title: 'Part 1', instruction: 'Do 1', timeoutMs: 1000 },
]);
expect(parseParts).not.toHaveBeenCalled();
});
it('decomposeTask は構造化出力がない場合 parseParts にフォールバックする', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('```json [] ```'));
vi.mocked(parseParts).mockReturnValue([
{ id: 'p1', title: 'Part 1', instruction: 'fallback', timeoutMs: undefined },
]);
const result = await decomposeTask('instruction', 2, { cwd: '/repo' });
expect(parseParts).toHaveBeenCalledWith('```json [] ```', 2);
expect(result).toEqual([
{ id: 'p1', title: 'Part 1', instruction: 'fallback', timeoutMs: undefined },
]);
});
it('decomposeTask は done 以外をエラーにする', async () => {
vi.mocked(runAgent).mockResolvedValue({
persona: 'team-leader',
status: 'error',
content: 'failure',
error: 'bad output',
timestamp: new Date('2026-02-12T00:00:00Z'),
});
await expect(decomposeTask('instruction', 2, { cwd: '/repo' }))
.rejects.toThrow('Team leader failed: bad output');
});
it('requestMoreParts は構造化出力をパースして返す', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('x', {
done: false,
reasoning: 'Need one more part',
parts: [
{ id: 'p3', title: 'Part 3', instruction: 'Do 3', timeout_ms: null },
],
}));
const result = await requestMoreParts(
'original instruction',
[{ id: 'p1', title: 'Part 1', status: 'done', content: 'done' }],
['p1', 'p2'],
2,
{ cwd: '/repo', persona: 'team-leader' },
);
expect(result).toEqual({
done: false,
reasoning: 'Need one more part',
parts: [{ id: 'p3', title: 'Part 3', instruction: 'Do 3', timeoutMs: undefined }],
});
expect(runAgent).toHaveBeenCalledWith('team-leader', expect.stringContaining('original instruction'), expect.objectContaining({
outputSchema: { type: 'more-parts', maxAdditionalParts: 2 },
permissionMode: 'readonly',
}));
});
it('requestMoreParts は done 以外をエラーにする', async () => {
vi.mocked(runAgent).mockResolvedValue({
persona: 'team-leader',
status: 'error',
content: 'feedback failed',
error: 'timeout',
timestamp: new Date('2026-02-12T00:00:00Z'),
});
await expect(requestMoreParts(
'instruction',
[{ id: 'p1', title: 'Part 1', status: 'done', content: 'ok' }],
['p1'],
1,
{ cwd: '/repo', persona: 'team-leader' },
)).rejects.toThrow('Team leader feedback failed: timeout');
});
});