takt/src/__tests__/report-phase-retry.test.ts
nrs 39c587d67b
github-issue-245-report (#251)
* dist-tag 検証をリトライ付きに変更(npm レジストリの結果整合性対策)

* takt run 実行時に蓋閉じスリープを抑制

* takt: github-issue-245-report
2026-02-12 11:51:55 +09:00

212 lines
6.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runReportPhase, type PhaseRunnerContext } from '../core/piece/phase-runner.js';
import type { PieceMovement } from '../core/models/types.js';
vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
import { runAgent } from '../agents/runner.js';
function createStep(fileName: string): PieceMovement {
return {
name: 'implement',
persona: 'coder',
personaDisplayName: 'Coder',
instructionTemplate: 'Implement task',
passPreviousResponse: false,
outputContracts: [{ name: fileName }],
};
}
function createContext(reportDir: string, lastResponse = 'Phase 1 result'): PhaseRunnerContext {
let currentSessionId = 'session-resume-1';
return {
cwd: reportDir,
reportDir,
language: 'en',
lastResponse,
getSessionId: (_persona: string) => currentSessionId,
buildResumeOptions: (_step, sessionId, overrides) => ({
cwd: reportDir,
sessionId,
allowedTools: overrides.allowedTools,
maxTurns: overrides.maxTurns,
}),
buildNewSessionReportOptions: (_step, overrides) => ({
cwd: reportDir,
allowedTools: overrides.allowedTools,
maxTurns: overrides.maxTurns,
}),
updatePersonaSession: (_persona, sessionId) => {
if (sessionId) {
currentSessionId = sessionId;
}
},
};
}
describe('runReportPhase retry with new session', () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'takt-report-retry-'));
vi.resetAllMocks();
});
afterEach(() => {
if (existsSync(tmpRoot)) {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it('should retry with new session when first attempt returns empty content', async () => {
// Given
const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports');
const step = createStep('02-coder.md');
const ctx = createContext(reportDir, 'Implemented feature X');
const runAgentMock = vi.mocked(runAgent);
runAgentMock
.mockResolvedValueOnce({
persona: 'coder',
status: 'done',
content: ' ',
timestamp: new Date('2026-02-11T00:00:00Z'),
sessionId: 'session-resume-2',
})
.mockResolvedValueOnce({
persona: 'coder',
status: 'done',
content: '# Report\nRecovered output',
timestamp: new Date('2026-02-11T00:00:01Z'),
sessionId: 'session-fresh-1',
});
// When
await runReportPhase(step, 1, ctx);
// Then
const reportPath = join(reportDir, '02-coder.md');
expect(readFileSync(reportPath, 'utf-8')).toBe('# Report\nRecovered output');
expect(runAgentMock).toHaveBeenCalledTimes(2);
const secondCallOptions = runAgentMock.mock.calls[1]?.[2] as { sessionId?: string };
expect(secondCallOptions.sessionId).toBeUndefined();
const secondInstruction = runAgentMock.mock.calls[1]?.[1] as string;
expect(secondInstruction).toContain('## Previous Work Context');
expect(secondInstruction).toContain('Implemented feature X');
});
it('should retry with new session when first attempt status is error', async () => {
// Given
const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports');
const step = createStep('03-review.md');
const ctx = createContext(reportDir);
const runAgentMock = vi.mocked(runAgent);
runAgentMock
.mockResolvedValueOnce({
persona: 'coder',
status: 'error',
content: 'Tool use is not allowed in this phase',
timestamp: new Date('2026-02-11T00:01:00Z'),
error: 'Tool use is not allowed in this phase',
})
.mockResolvedValueOnce({
persona: 'coder',
status: 'done',
content: 'Recovered report',
timestamp: new Date('2026-02-11T00:01:01Z'),
});
// When
await runReportPhase(step, 1, ctx);
// Then
const reportPath = join(reportDir, '03-review.md');
expect(readFileSync(reportPath, 'utf-8')).toBe('Recovered report');
expect(runAgentMock).toHaveBeenCalledTimes(2);
});
it('should throw when both attempts return empty output', async () => {
// Given
const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports');
const step = createStep('04-qa.md');
const ctx = createContext(reportDir);
const runAgentMock = vi.mocked(runAgent);
runAgentMock
.mockResolvedValueOnce({
persona: 'coder',
status: 'done',
content: ' ',
timestamp: new Date('2026-02-11T00:02:00Z'),
})
.mockResolvedValueOnce({
persona: 'coder',
status: 'done',
content: '\n\n',
timestamp: new Date('2026-02-11T00:02:01Z'),
});
// When / Then
await expect(runReportPhase(step, 1, ctx)).rejects.toThrow('Report phase failed for 04-qa.md: Report output is empty');
expect(runAgentMock).toHaveBeenCalledTimes(2);
});
it('should not retry when first attempt succeeds', async () => {
// Given
const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports');
const step = createStep('05-ok.md');
const ctx = createContext(reportDir);
const runAgentMock = vi.mocked(runAgent);
runAgentMock.mockResolvedValueOnce({
persona: 'coder',
status: 'done',
content: 'Single-pass success',
timestamp: new Date('2026-02-11T00:03:00Z'),
sessionId: 'session-resume-2',
});
// When
await runReportPhase(step, 1, ctx);
// Then
expect(runAgentMock).toHaveBeenCalledTimes(1);
const reportPath = join(reportDir, '05-ok.md');
expect(readFileSync(reportPath, 'utf-8')).toBe('Single-pass success');
});
it('should return blocked result without retry', async () => {
// Given
const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports');
const step = createStep('06-blocked.md');
const ctx = createContext(reportDir);
const runAgentMock = vi.mocked(runAgent);
runAgentMock.mockResolvedValueOnce({
persona: 'coder',
status: 'blocked',
content: 'Need permission',
timestamp: new Date('2026-02-11T00:04:00Z'),
});
// When
const result = await runReportPhase(step, 1, ctx);
// Then
expect(result).toEqual({
blocked: true,
response: {
persona: 'coder',
status: 'blocked',
content: 'Need permission',
timestamp: new Date('2026-02-11T00:04:00Z'),
},
});
expect(runAgentMock).toHaveBeenCalledTimes(1);
});
});