失敗タスク専用のリトライモード(retryMode.ts)を追加し、失敗情報・実行ログ・ レポートをシステムプロンプトに注入する方式に変更。instruct モードもタスク情報を プロンプトに含める専用テンプレートへ移行。requeue のみだった再実行を startReExecution による即時実行に対応し、既存ワークツリーの再利用も実装。 不要になった DebugConfig を削除。
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
/**
|
|
* Tests for runSessionReader
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
vi.mock('../infra/fs/session.js', () => ({
|
|
loadNdjsonLog: vi.fn(),
|
|
}));
|
|
|
|
import { loadNdjsonLog } from '../infra/fs/session.js';
|
|
import {
|
|
listRecentRuns,
|
|
findRunForTask,
|
|
loadRunSessionContext,
|
|
formatRunSessionForPrompt,
|
|
type RunSessionContext,
|
|
} from '../features/interactive/runSessionReader.js';
|
|
|
|
const mockLoadNdjsonLog = vi.mocked(loadNdjsonLog);
|
|
|
|
function createTmpDir(): string {
|
|
const dir = join(tmpdir(), `takt-test-runSessionReader-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function createRunDir(
|
|
cwd: string,
|
|
slug: string,
|
|
meta: Record<string, unknown>,
|
|
): string {
|
|
const runDir = join(cwd, '.takt', 'runs', slug);
|
|
mkdirSync(join(runDir, 'logs'), { recursive: true });
|
|
mkdirSync(join(runDir, 'reports'), { recursive: true });
|
|
writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8');
|
|
return runDir;
|
|
}
|
|
|
|
describe('listRecentRuns', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTmpDir();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should return empty array when .takt/runs does not exist', () => {
|
|
const result = listRecentRuns(tmpDir);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array when no runs have meta.json', () => {
|
|
mkdirSync(join(tmpDir, '.takt', 'runs', 'empty-run'), { recursive: true });
|
|
const result = listRecentRuns(tmpDir);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should return runs sorted by startTime descending', () => {
|
|
createRunDir(tmpDir, 'run-old', {
|
|
task: 'Old task',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: '2026-01-01T00:00:00.000Z',
|
|
logsDirectory: '.takt/runs/run-old/logs',
|
|
reportDirectory: '.takt/runs/run-old/reports',
|
|
runSlug: 'run-old',
|
|
});
|
|
createRunDir(tmpDir, 'run-new', {
|
|
task: 'New task',
|
|
piece: 'custom',
|
|
status: 'running',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: '.takt/runs/run-new/logs',
|
|
reportDirectory: '.takt/runs/run-new/reports',
|
|
runSlug: 'run-new',
|
|
});
|
|
|
|
const result = listRecentRuns(tmpDir);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].slug).toBe('run-new');
|
|
expect(result[1].slug).toBe('run-old');
|
|
});
|
|
|
|
it('should limit results to 10', () => {
|
|
for (let i = 0; i < 12; i++) {
|
|
const slug = `run-${String(i).padStart(2, '0')}`;
|
|
createRunDir(tmpDir, slug, {
|
|
task: `Task ${i}`,
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00.000Z`,
|
|
logsDirectory: `.takt/runs/${slug}/logs`,
|
|
reportDirectory: `.takt/runs/${slug}/reports`,
|
|
runSlug: slug,
|
|
});
|
|
}
|
|
|
|
const result = listRecentRuns(tmpDir);
|
|
expect(result).toHaveLength(10);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('findRunForTask', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTmpDir();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should return null when no runs exist', () => {
|
|
const result = findRunForTask(tmpDir, 'Some task');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null when no runs match the task content', () => {
|
|
createRunDir(tmpDir, 'run-other', {
|
|
task: 'Different task',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: '.takt/runs/run-other/logs',
|
|
reportDirectory: '.takt/runs/run-other/reports',
|
|
runSlug: 'run-other',
|
|
});
|
|
|
|
const result = findRunForTask(tmpDir, 'My specific task');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return the matching run slug', () => {
|
|
createRunDir(tmpDir, 'run-match', {
|
|
task: 'Build login page',
|
|
piece: 'default',
|
|
status: 'failed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: '.takt/runs/run-match/logs',
|
|
reportDirectory: '.takt/runs/run-match/reports',
|
|
runSlug: 'run-match',
|
|
});
|
|
|
|
const result = findRunForTask(tmpDir, 'Build login page');
|
|
expect(result).toBe('run-match');
|
|
});
|
|
|
|
it('should return the most recent matching run when multiple exist', () => {
|
|
createRunDir(tmpDir, 'run-old', {
|
|
task: 'Build login page',
|
|
piece: 'default',
|
|
status: 'failed',
|
|
startTime: '2026-01-01T00:00:00.000Z',
|
|
logsDirectory: '.takt/runs/run-old/logs',
|
|
reportDirectory: '.takt/runs/run-old/reports',
|
|
runSlug: 'run-old',
|
|
});
|
|
createRunDir(tmpDir, 'run-new', {
|
|
task: 'Build login page',
|
|
piece: 'default',
|
|
status: 'failed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: '.takt/runs/run-new/logs',
|
|
reportDirectory: '.takt/runs/run-new/reports',
|
|
runSlug: 'run-new',
|
|
});
|
|
|
|
const result = findRunForTask(tmpDir, 'Build login page');
|
|
expect(result).toBe('run-new');
|
|
});
|
|
});
|
|
|
|
describe('loadRunSessionContext', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTmpDir();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should throw when run does not exist', () => {
|
|
expect(() => loadRunSessionContext(tmpDir, 'nonexistent')).toThrow('Run not found: nonexistent');
|
|
});
|
|
|
|
it('should load context with movement logs and reports', () => {
|
|
const slug = 'test-run';
|
|
const runDir = createRunDir(tmpDir, slug, {
|
|
task: 'Test task',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: `.takt/runs/${slug}/logs`,
|
|
reportDirectory: `.takt/runs/${slug}/reports`,
|
|
runSlug: slug,
|
|
});
|
|
|
|
// Create a log file
|
|
writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8');
|
|
|
|
// Create a report file
|
|
writeFileSync(join(runDir, 'reports', '00-plan.md'), '# Plan\nDetails here', 'utf-8');
|
|
|
|
mockLoadNdjsonLog.mockReturnValue({
|
|
task: 'Test task',
|
|
projectDir: '',
|
|
pieceName: 'default',
|
|
iterations: 1,
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
status: 'completed',
|
|
history: [
|
|
{
|
|
step: 'implement',
|
|
persona: 'coder',
|
|
instruction: 'Implement feature',
|
|
status: 'completed',
|
|
timestamp: '2026-02-01T00:01:00.000Z',
|
|
content: 'Implementation done',
|
|
},
|
|
],
|
|
});
|
|
|
|
const context = loadRunSessionContext(tmpDir, slug);
|
|
|
|
expect(context.task).toBe('Test task');
|
|
expect(context.piece).toBe('default');
|
|
expect(context.status).toBe('completed');
|
|
expect(context.movementLogs).toHaveLength(1);
|
|
expect(context.movementLogs[0].step).toBe('implement');
|
|
expect(context.movementLogs[0].content).toBe('Implementation done');
|
|
expect(context.reports).toHaveLength(1);
|
|
expect(context.reports[0].filename).toBe('00-plan.md');
|
|
});
|
|
|
|
it('should truncate movement content to 500 characters', () => {
|
|
const slug = 'truncate-run';
|
|
const runDir = createRunDir(tmpDir, slug, {
|
|
task: 'Truncate test',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: `.takt/runs/${slug}/logs`,
|
|
reportDirectory: `.takt/runs/${slug}/reports`,
|
|
runSlug: slug,
|
|
});
|
|
|
|
writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8');
|
|
|
|
const longContent = 'A'.repeat(600);
|
|
mockLoadNdjsonLog.mockReturnValue({
|
|
task: 'Truncate test',
|
|
projectDir: '',
|
|
pieceName: 'default',
|
|
iterations: 1,
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
status: 'completed',
|
|
history: [
|
|
{
|
|
step: 'implement',
|
|
persona: 'coder',
|
|
instruction: 'Do it',
|
|
status: 'completed',
|
|
timestamp: '2026-02-01T00:01:00.000Z',
|
|
content: longContent,
|
|
},
|
|
],
|
|
});
|
|
|
|
const context = loadRunSessionContext(tmpDir, slug);
|
|
|
|
expect(context.movementLogs[0].content.length).toBe(501); // 500 + '…'
|
|
expect(context.movementLogs[0].content.endsWith('…')).toBe(true);
|
|
});
|
|
|
|
it('should handle missing log files gracefully', () => {
|
|
const slug = 'no-logs-run';
|
|
createRunDir(tmpDir, slug, {
|
|
task: 'No logs',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: `.takt/runs/${slug}/logs`,
|
|
reportDirectory: `.takt/runs/${slug}/reports`,
|
|
runSlug: slug,
|
|
});
|
|
|
|
const context = loadRunSessionContext(tmpDir, slug);
|
|
expect(context.movementLogs).toEqual([]);
|
|
expect(context.reports).toEqual([]);
|
|
});
|
|
|
|
it('should exclude provider-events log files', () => {
|
|
const slug = 'provider-events-run';
|
|
const runDir = createRunDir(tmpDir, slug, {
|
|
task: 'Provider events test',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
startTime: '2026-02-01T00:00:00.000Z',
|
|
logsDirectory: `.takt/runs/${slug}/logs`,
|
|
reportDirectory: `.takt/runs/${slug}/reports`,
|
|
runSlug: slug,
|
|
});
|
|
|
|
// Only provider-events log file
|
|
writeFileSync(join(runDir, 'logs', 'session-001-provider-events.jsonl'), '{}', 'utf-8');
|
|
|
|
const context = loadRunSessionContext(tmpDir, slug);
|
|
expect(mockLoadNdjsonLog).not.toHaveBeenCalled();
|
|
expect(context.movementLogs).toEqual([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('formatRunSessionForPrompt', () => {
|
|
it('should format context into prompt variables', () => {
|
|
const ctx: RunSessionContext = {
|
|
task: 'Implement feature X',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
movementLogs: [
|
|
{ step: 'plan', persona: 'architect', status: 'completed', content: 'Plan content' },
|
|
{ step: 'implement', persona: 'coder', status: 'completed', content: 'Code content' },
|
|
],
|
|
reports: [
|
|
{ filename: '00-plan.md', content: '# Plan\nDetails' },
|
|
],
|
|
};
|
|
|
|
const result = formatRunSessionForPrompt(ctx);
|
|
|
|
expect(result.runTask).toBe('Implement feature X');
|
|
expect(result.runPiece).toBe('default');
|
|
expect(result.runStatus).toBe('completed');
|
|
expect(result.runMovementLogs).toContain('plan');
|
|
expect(result.runMovementLogs).toContain('architect');
|
|
expect(result.runMovementLogs).toContain('Plan content');
|
|
expect(result.runMovementLogs).toContain('implement');
|
|
expect(result.runMovementLogs).toContain('Code content');
|
|
expect(result.runReports).toContain('00-plan.md');
|
|
expect(result.runReports).toContain('# Plan\nDetails');
|
|
});
|
|
|
|
it('should handle empty logs and reports', () => {
|
|
const ctx: RunSessionContext = {
|
|
task: 'Empty task',
|
|
piece: 'default',
|
|
status: 'aborted',
|
|
movementLogs: [],
|
|
reports: [],
|
|
};
|
|
|
|
const result = formatRunSessionForPrompt(ctx);
|
|
|
|
expect(result.runTask).toBe('Empty task');
|
|
expect(result.runMovementLogs).toBe('');
|
|
expect(result.runReports).toBe('');
|
|
});
|
|
});
|