interactiveモジュールの分割とタスク再キュー方式への移行
interactive.tsからsummary/runSelector/runSessionReader/selectorUtilsを分離し、 run session参照をrouting層からinstructMode層に移動。instructBranchで新タスク 作成の代わりに既存タスクのrequeueを使用する方式に変更。worktree確認プロンプトを 廃止し常時有効化。
This commit is contained in:
parent
5f108b8cfd
commit
620e384251
@ -94,6 +94,8 @@ takt hello
|
||||
|
||||
**注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6`、`--issue`)は対話モードの初期入力として使用されます。
|
||||
|
||||
対話開始時には `takt list` の履歴を自動取得し、`failed` / `interrupted` / `completed` の実行結果を `pieceContext` に注入して会話要約へ反映します。要約では `Worktree ID`、`開始/終了時刻`、`最終結果`、`失敗要約`、`ログ参照キー` を参照できます。`takt list` の取得に失敗しても対話は継続されます。
|
||||
|
||||
**フロー:**
|
||||
1. ピース選択
|
||||
2. 対話モード選択(assistant / persona / quiet / passthrough)
|
||||
@ -225,6 +227,8 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes
|
||||
takt list --non-interactive --format json
|
||||
```
|
||||
|
||||
対話モードでは、上記の実行履歴(`failed` / `interrupted` / `completed`)を起動時に再利用し、失敗事例や中断済み実行を再作業対象として特定しやすくします。
|
||||
|
||||
#### タスクディレクトリ運用(作成・実行・確認)
|
||||
|
||||
1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。
|
||||
|
||||
@ -114,6 +114,7 @@ describe('addTask', () => {
|
||||
expect(task.task_dir).toBeTypeOf('string');
|
||||
expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する');
|
||||
expect(task.piece).toBe('default');
|
||||
expect(task.worktree).toBe(true);
|
||||
});
|
||||
|
||||
it('should include worktree settings when enabled', async () => {
|
||||
@ -125,6 +126,7 @@ describe('addTask', () => {
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.worktree).toBe('/custom/path');
|
||||
expect(task.branch).toBe('feat/branch');
|
||||
expect(task.auto_pr).toBe(true);
|
||||
});
|
||||
|
||||
it('should create task from issue reference without interactive mode', async () => {
|
||||
|
||||
@ -56,6 +56,22 @@ vi.mock('../features/interactive/index.js', () => ({
|
||||
quietMode: vi.fn(),
|
||||
personaMode: vi.fn(),
|
||||
resolveLanguage: vi.fn(() => 'en'),
|
||||
selectRun: vi.fn(() => null),
|
||||
loadRunSessionContext: vi.fn(),
|
||||
listRecentRuns: vi.fn(() => []),
|
||||
normalizeTaskHistorySummary: vi.fn((items: unknown[]) => items),
|
||||
dispatchConversationAction: vi.fn(async (result: { action: string }, handlers: Record<string, (r: unknown) => unknown>) => {
|
||||
return handlers[result.action](result);
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockListAllTaskItems = vi.fn();
|
||||
const mockIsStaleRunningTask = vi.fn();
|
||||
vi.mock('../infra/task/index.js', () => ({
|
||||
TaskRunner: vi.fn(() => ({
|
||||
listAllTaskItems: mockListAllTaskItems,
|
||||
})),
|
||||
isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
@ -110,6 +126,7 @@ const mockSelectRecentSession = vi.mocked(selectRecentSession);
|
||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockIsDirectTask = vi.mocked(isDirectTask);
|
||||
const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems);
|
||||
|
||||
function createMockIssue(number: number): GitHubIssue {
|
||||
return {
|
||||
@ -133,6 +150,8 @@ beforeEach(() => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockIsDirectTask.mockReturnValue(false);
|
||||
mockParseIssueNumbers.mockReturnValue([]);
|
||||
mockTaskRunnerListAllTaskItems.mockReturnValue([]);
|
||||
mockIsStaleRunningTask.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('Issue resolution in routing', () => {
|
||||
@ -262,6 +281,142 @@ describe('Issue resolution in routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('task history injection', () => {
|
||||
it('should include failed/completed/interrupted tasks in pieceContext for interactive mode', async () => {
|
||||
const failedTask = {
|
||||
kind: 'failed' as const,
|
||||
name: 'failed-task',
|
||||
createdAt: '2026-02-17T00:00:00.000Z',
|
||||
filePath: '/project/.takt/tasks.yaml',
|
||||
content: 'failed',
|
||||
worktreePath: '/tmp/task/failed',
|
||||
branch: 'takt/failed',
|
||||
startedAt: '2026-02-17T00:00:00.000Z',
|
||||
completedAt: '2026-02-17T00:10:00.000Z',
|
||||
failure: { error: 'syntax error' },
|
||||
};
|
||||
const completedTask = {
|
||||
kind: 'completed' as const,
|
||||
name: 'completed-task',
|
||||
createdAt: '2026-02-16T00:00:00.000Z',
|
||||
filePath: '/project/.takt/tasks.yaml',
|
||||
content: 'done',
|
||||
worktreePath: '/tmp/task/completed',
|
||||
branch: 'takt/completed',
|
||||
startedAt: '2026-02-16T00:00:00.000Z',
|
||||
completedAt: '2026-02-16T00:07:00.000Z',
|
||||
};
|
||||
const runningTask = {
|
||||
kind: 'running' as const,
|
||||
name: 'running-task',
|
||||
createdAt: '2026-02-15T00:00:00.000Z',
|
||||
filePath: '/project/.takt/tasks.yaml',
|
||||
content: 'running',
|
||||
worktreePath: '/tmp/task/interrupted',
|
||||
ownerPid: 555,
|
||||
startedAt: '2026-02-15T00:00:00.000Z',
|
||||
};
|
||||
mockTaskRunnerListAllTaskItems.mockReturnValue([failedTask, completedTask, runningTask]);
|
||||
mockIsStaleRunningTask.mockReturnValue(true);
|
||||
|
||||
// When
|
||||
await executeDefaultAction('add feature');
|
||||
|
||||
// Then
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
'add feature',
|
||||
expect.objectContaining({
|
||||
taskHistory: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
worktreeId: '/tmp/task/failed',
|
||||
status: 'failed',
|
||||
finalResult: 'failed',
|
||||
logKey: 'takt/failed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
worktreeId: '/tmp/task/completed',
|
||||
status: 'completed',
|
||||
finalResult: 'completed',
|
||||
logKey: 'takt/completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
worktreeId: '/tmp/task/interrupted',
|
||||
status: 'interrupted',
|
||||
finalResult: 'interrupted',
|
||||
logKey: '/tmp/task/interrupted',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat running tasks with no ownerPid as interrupted', async () => {
|
||||
const runningTaskWithoutPid = {
|
||||
kind: 'running' as const,
|
||||
name: 'running-task-no-owner',
|
||||
createdAt: '2026-02-15T00:00:00.000Z',
|
||||
filePath: '/project/.takt/tasks.yaml',
|
||||
content: 'running',
|
||||
worktreePath: '/tmp/task/running-no-owner',
|
||||
branch: 'takt/running-no-owner',
|
||||
startedAt: '2026-02-15T00:00:00.000Z',
|
||||
};
|
||||
mockTaskRunnerListAllTaskItems.mockReturnValue([runningTaskWithoutPid]);
|
||||
mockIsStaleRunningTask.mockReturnValue(true);
|
||||
|
||||
await executeDefaultAction('recover interrupted');
|
||||
|
||||
expect(mockIsStaleRunningTask).toHaveBeenCalledWith(undefined);
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
'recover interrupted',
|
||||
expect.objectContaining({
|
||||
taskHistory: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
worktreeId: '/tmp/task/running-no-owner',
|
||||
status: 'interrupted',
|
||||
finalResult: 'interrupted',
|
||||
logKey: 'takt/running-no-owner',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should continue interactive mode when task list retrieval fails', async () => {
|
||||
mockTaskRunnerListAllTaskItems.mockImplementation(() => {
|
||||
throw new Error('list failed');
|
||||
});
|
||||
|
||||
// When
|
||||
await executeDefaultAction('fix issue');
|
||||
|
||||
// Then
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
'fix issue',
|
||||
expect.objectContaining({ taskHistory: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass empty taskHistory when task list is empty', async () => {
|
||||
mockTaskRunnerListAllTaskItems.mockReturnValue([]);
|
||||
|
||||
await executeDefaultAction('verify history');
|
||||
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
'verify history',
|
||||
expect.objectContaining({ taskHistory: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive mode cancel', () => {
|
||||
it('should not call selectAndExecuteTask when interactive mode is cancelled', async () => {
|
||||
// Given
|
||||
@ -387,4 +542,21 @@ describe('Issue resolution in routing', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run session reference', () => {
|
||||
it('should not prompt run session reference in default interactive flow', async () => {
|
||||
await executeDefaultAction();
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalledWith(
|
||||
"Reference a previous run's results?",
|
||||
false,
|
||||
);
|
||||
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
undefined,
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -76,10 +76,12 @@ import { getProvider } from '../infra/providers/index.js';
|
||||
import { runInstructMode } from '../features/tasks/list/instructMode.js';
|
||||
import { selectOption } from '../shared/prompt/index.js';
|
||||
import { info } from '../shared/ui/index.js';
|
||||
import { loadTemplate } from '../shared/prompts/index.js';
|
||||
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
const mockSelectOption = vi.mocked(selectOption);
|
||||
const mockInfo = vi.mocked(info);
|
||||
const mockLoadTemplate = vi.mocked(loadTemplate);
|
||||
|
||||
let savedIsTTY: boolean | undefined;
|
||||
let savedIsRaw: boolean | undefined;
|
||||
@ -279,4 +281,34 @@ describe('runInstructMode', () => {
|
||||
expect(values).toContain('continue');
|
||||
expect(values).not.toContain('create_issue');
|
||||
});
|
||||
|
||||
it('should inject selected run context into system prompt variables', async () => {
|
||||
setupRawStdin(toRawInputs(['/cancel']));
|
||||
setupMockProvider([]);
|
||||
|
||||
const runSessionContext = {
|
||||
task: 'Previous run task',
|
||||
piece: 'default',
|
||||
status: 'completed',
|
||||
movementLogs: [
|
||||
{ step: 'implement', persona: 'coder', status: 'completed', content: 'done' },
|
||||
],
|
||||
reports: [
|
||||
{ filename: '00-plan.md', content: '# Plan' },
|
||||
],
|
||||
};
|
||||
|
||||
await runInstructMode('/project', 'branch context', 'feature-branch', undefined, runSessionContext);
|
||||
|
||||
expect(mockLoadTemplate).toHaveBeenCalledWith(
|
||||
'score_interactive_system_prompt',
|
||||
'en',
|
||||
expect.objectContaining({
|
||||
hasRunSession: true,
|
||||
runTask: 'Previous run task',
|
||||
runPiece: 'default',
|
||||
runStatus: 'completed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
102
src/__tests__/interactive-summary.test.ts
Normal file
102
src/__tests__/interactive-summary.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Tests for task history context formatting in interactive summary.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildSummaryPrompt,
|
||||
formatTaskHistorySummary,
|
||||
type PieceContext,
|
||||
type TaskHistorySummaryItem,
|
||||
} from '../features/interactive/interactive.js';
|
||||
|
||||
describe('formatTaskHistorySummary', () => {
|
||||
it('returns empty string when history is empty', () => {
|
||||
expect(formatTaskHistorySummary([], 'en')).toBe('');
|
||||
});
|
||||
|
||||
it('formats task history with required fields', () => {
|
||||
const history: TaskHistorySummaryItem[] = [
|
||||
{
|
||||
worktreeId: 'wt-1',
|
||||
status: 'interrupted',
|
||||
startedAt: '2026-02-18T00:00:00.000Z',
|
||||
completedAt: 'N/A',
|
||||
finalResult: 'interrupted',
|
||||
failureSummary: undefined,
|
||||
logKey: 'log-1',
|
||||
},
|
||||
{
|
||||
worktreeId: 'wt-2',
|
||||
status: 'failed',
|
||||
startedAt: '2026-02-17T00:00:00.000Z',
|
||||
completedAt: '2026-02-17T00:01:00.000Z',
|
||||
finalResult: 'failed',
|
||||
failureSummary: 'Syntax error in test',
|
||||
logKey: 'log-2',
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatTaskHistorySummary(history, 'en');
|
||||
expect(result).toContain('## Task execution history');
|
||||
expect(result).toContain('Worktree ID: wt-1');
|
||||
expect(result).toContain('Status: interrupted');
|
||||
expect(result).toContain('Failure summary: Syntax error in test');
|
||||
expect(result).toContain('Log key: log-2');
|
||||
});
|
||||
|
||||
it('normalizes empty start/end timestamps to N/A', () => {
|
||||
const history: TaskHistorySummaryItem[] = [
|
||||
{
|
||||
worktreeId: 'wt-3',
|
||||
status: 'interrupted',
|
||||
startedAt: '',
|
||||
completedAt: '',
|
||||
finalResult: 'interrupted',
|
||||
failureSummary: undefined,
|
||||
logKey: 'log-3',
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatTaskHistorySummary(history, 'en');
|
||||
expect(result).toContain('Start/End: N/A / N/A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSummaryPrompt', () => {
|
||||
it('includes taskHistory context when provided', () => {
|
||||
const history: TaskHistorySummaryItem[] = [
|
||||
{
|
||||
worktreeId: 'wt-1',
|
||||
status: 'completed',
|
||||
startedAt: '2026-02-10T00:00:00.000Z',
|
||||
completedAt: '2026-02-10T00:00:30.000Z',
|
||||
finalResult: 'completed',
|
||||
failureSummary: undefined,
|
||||
logKey: 'log-1',
|
||||
},
|
||||
];
|
||||
const pieceContext: PieceContext = {
|
||||
name: 'my-piece',
|
||||
description: 'desc',
|
||||
pieceStructure: '',
|
||||
movementPreviews: [],
|
||||
taskHistory: history,
|
||||
};
|
||||
|
||||
const summary = buildSummaryPrompt(
|
||||
[{ role: 'user', content: 'Improve parser' }],
|
||||
false,
|
||||
'en',
|
||||
'No transcript',
|
||||
'Conversation:',
|
||||
pieceContext,
|
||||
);
|
||||
|
||||
expect(summary).toContain('## Task execution history');
|
||||
expect(summary).toContain('Worktree ID: wt-1');
|
||||
expect(summary).toContain('Conversation:');
|
||||
expect(summary).toContain('User: Improve parser');
|
||||
});
|
||||
});
|
||||
@ -387,6 +387,63 @@ describe('interactiveMode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include run session context in system prompt when provided', async () => {
|
||||
// Given
|
||||
setupRawStdin(toRawInputs(['hello', '/cancel']));
|
||||
const mockSetup = vi.fn();
|
||||
const mockCall = vi.fn(async () => ({
|
||||
persona: 'interactive',
|
||||
status: 'done' as const,
|
||||
content: 'AI response',
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
mockSetup.mockReturnValue({ call: mockCall });
|
||||
mockGetProvider.mockReturnValue({ setup: mockSetup, _call: mockCall } as unknown as ReturnType<typeof getProvider>);
|
||||
|
||||
const runSessionContext = {
|
||||
task: 'Previous run task',
|
||||
piece: 'default',
|
||||
status: 'completed',
|
||||
movementLogs: [{ step: 'implement', persona: 'coder', status: 'completed', content: 'Implementation done' }],
|
||||
reports: [],
|
||||
};
|
||||
|
||||
// When
|
||||
await interactiveMode('/project', undefined, undefined, undefined, runSessionContext);
|
||||
|
||||
// Then: system prompt should contain run session content
|
||||
expect(mockSetup).toHaveBeenCalled();
|
||||
const setupArgs = mockSetup.mock.calls[0]![0] as { systemPrompt: string };
|
||||
expect(setupArgs.systemPrompt).toContain('Previous run task');
|
||||
expect(setupArgs.systemPrompt).toContain('default');
|
||||
expect(setupArgs.systemPrompt).toContain('completed');
|
||||
expect(setupArgs.systemPrompt).toContain('implement');
|
||||
expect(setupArgs.systemPrompt).toContain('Implementation done');
|
||||
expect(setupArgs.systemPrompt).toContain('Previous Run Reference');
|
||||
});
|
||||
|
||||
it('should not include run session section in system prompt when not provided', async () => {
|
||||
// Given
|
||||
setupRawStdin(toRawInputs(['hello', '/cancel']));
|
||||
const mockSetup = vi.fn();
|
||||
const mockCall = vi.fn(async () => ({
|
||||
persona: 'interactive',
|
||||
status: 'done' as const,
|
||||
content: 'AI response',
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
mockSetup.mockReturnValue({ call: mockCall });
|
||||
mockGetProvider.mockReturnValue({ setup: mockSetup, _call: mockCall } as unknown as ReturnType<typeof getProvider>);
|
||||
|
||||
// When
|
||||
await interactiveMode('/project');
|
||||
|
||||
// Then: system prompt should NOT contain run session section
|
||||
expect(mockSetup).toHaveBeenCalled();
|
||||
const setupArgs = mockSetup.mock.calls[0]![0] as { systemPrompt: string };
|
||||
expect(setupArgs.systemPrompt).not.toContain('Previous Run Reference');
|
||||
});
|
||||
|
||||
it('should abort in-flight provider call on SIGINT during initial input', async () => {
|
||||
mockGetProvider.mockReturnValue({
|
||||
setup: () => ({
|
||||
|
||||
@ -58,6 +58,19 @@ describe('variable substitution', () => {
|
||||
expect(result).toContain('You are the agent');
|
||||
});
|
||||
|
||||
it('replaces taskHistory variable in score_summary_system_prompt', () => {
|
||||
const result = loadTemplate('score_summary_system_prompt', 'en', {
|
||||
pieceInfo: true,
|
||||
pieceName: 'piece',
|
||||
pieceDescription: 'desc',
|
||||
movementDetails: '',
|
||||
conversation: 'Conversation: User: test',
|
||||
taskHistory: '## Task execution history\n- Worktree ID: wt-1',
|
||||
});
|
||||
expect(result).toContain('## Task execution history');
|
||||
expect(result).toContain('Worktree ID: wt-1');
|
||||
});
|
||||
|
||||
it('replaces multiple different variables', () => {
|
||||
const result = loadTemplate('perform_judge_message', 'en', {
|
||||
agentOutput: 'test output',
|
||||
|
||||
91
src/__tests__/runSelector.test.ts
Normal file
91
src/__tests__/runSelector.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests for runSelector
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../features/interactive/runSessionReader.js', () => ({
|
||||
listRecentRuns: vi.fn(),
|
||||
}));
|
||||
|
||||
import { selectOption } from '../shared/prompt/index.js';
|
||||
import { info } from '../shared/ui/index.js';
|
||||
import { listRecentRuns } from '../features/interactive/runSessionReader.js';
|
||||
import { selectRun } from '../features/interactive/runSelector.js';
|
||||
|
||||
const mockListRecentRuns = vi.mocked(listRecentRuns);
|
||||
const mockSelectOption = vi.mocked(selectOption);
|
||||
const mockInfo = vi.mocked(info);
|
||||
|
||||
describe('selectRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return null and show message when no runs exist', async () => {
|
||||
mockListRecentRuns.mockReturnValue([]);
|
||||
|
||||
const result = await selectRun('/some/path', 'en');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockInfo).toHaveBeenCalledWith('interactive.runSelector.noRuns');
|
||||
});
|
||||
|
||||
it('should present run options and return selected slug', async () => {
|
||||
mockListRecentRuns.mockReturnValue([
|
||||
{ slug: 'run-1', task: 'First task', piece: 'default', status: 'completed', startTime: '2026-02-01T10:00:00Z' },
|
||||
{ slug: 'run-2', task: 'Second task', piece: 'custom', status: 'aborted', startTime: '2026-01-15T08:00:00Z' },
|
||||
]);
|
||||
mockSelectOption.mockResolvedValue('run-1');
|
||||
|
||||
const result = await selectRun('/some/path', 'en');
|
||||
|
||||
expect(result).toBe('run-1');
|
||||
expect(mockSelectOption).toHaveBeenCalledTimes(1);
|
||||
|
||||
const callArgs = mockSelectOption.mock.calls[0];
|
||||
expect(callArgs[0]).toBe('interactive.runSelector.prompt');
|
||||
const options = callArgs[1];
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].value).toBe('run-1');
|
||||
expect(options[0].label).toBe('First task');
|
||||
expect(options[1].value).toBe('run-2');
|
||||
expect(options[1].label).toBe('Second task');
|
||||
});
|
||||
|
||||
it('should return null when user cancels selection', async () => {
|
||||
mockListRecentRuns.mockReturnValue([
|
||||
{ slug: 'run-1', task: 'Task', piece: 'default', status: 'completed', startTime: '2026-02-01T00:00:00Z' },
|
||||
]);
|
||||
mockSelectOption.mockResolvedValue(null);
|
||||
|
||||
const result = await selectRun('/some/path', 'en');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should truncate long task labels', async () => {
|
||||
const longTask = 'A'.repeat(100);
|
||||
mockListRecentRuns.mockReturnValue([
|
||||
{ slug: 'run-1', task: longTask, piece: 'default', status: 'completed', startTime: '2026-02-01T00:00:00Z' },
|
||||
]);
|
||||
mockSelectOption.mockResolvedValue('run-1');
|
||||
|
||||
await selectRun('/some/path', 'en');
|
||||
|
||||
const options = mockSelectOption.mock.calls[0][1];
|
||||
expect(options[0].label.length).toBeLessThanOrEqual(61); // 60 + '…'
|
||||
});
|
||||
});
|
||||
297
src/__tests__/runSessionReader.test.ts
Normal file
297
src/__tests__/runSessionReader.test.ts
Normal file
@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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,
|
||||
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('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('');
|
||||
});
|
||||
});
|
||||
@ -105,8 +105,7 @@ describe('saveTaskFile', () => {
|
||||
});
|
||||
|
||||
describe('saveTaskFromInteractive', () => {
|
||||
it('should save task with worktree settings when user confirms', async () => {
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
it('should always save task with worktree settings', async () => {
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
@ -119,18 +118,22 @@ describe('saveTaskFromInteractive', () => {
|
||||
expect(task.auto_pr).toBe(true);
|
||||
});
|
||||
|
||||
it('should save task without worktree settings when declined', async () => {
|
||||
it('should keep worktree enabled even when auto-pr is declined', async () => {
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.worktree).toBeUndefined();
|
||||
expect(task.worktree).toBe(true);
|
||||
expect(task.branch).toBeUndefined();
|
||||
expect(task.auto_pr).toBeUndefined();
|
||||
expect(task.auto_pr).toBe(false);
|
||||
});
|
||||
|
||||
it('should display piece info when specified', async () => {
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await saveTaskFromInteractive(testDir, 'Task content', 'review');
|
||||
@ -139,6 +142,8 @@ describe('saveTaskFromInteractive', () => {
|
||||
});
|
||||
|
||||
it('should record issue number in tasks.yaml when issue option is provided', async () => {
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 });
|
||||
@ -163,7 +168,6 @@ describe('saveTaskFromInteractive', () => {
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await saveTaskFromInteractive(testDir, 'Task content', 'default', {
|
||||
@ -172,7 +176,7 @@ describe('saveTaskFromInteractive', () => {
|
||||
});
|
||||
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(1, 'Add this issue to tasks?', true);
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(2, 'Create worktree?', true);
|
||||
expect(mockConfirm).toHaveBeenNthCalledWith(2, 'Auto-create PR?', true);
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.issue).toBe(42);
|
||||
expect(task.worktree).toBe(true);
|
||||
|
||||
60
src/__tests__/selectorUtils.test.ts
Normal file
60
src/__tests__/selectorUtils.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tests for selector shared utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { truncateForLabel, formatDateForSelector } from '../features/interactive/selectorUtils.js';
|
||||
|
||||
describe('truncateForLabel', () => {
|
||||
it('should return text as-is when within max length', () => {
|
||||
const result = truncateForLabel('Short text', 20);
|
||||
|
||||
expect(result).toBe('Short text');
|
||||
});
|
||||
|
||||
it('should truncate text exceeding max length with ellipsis', () => {
|
||||
const longText = 'A'.repeat(100);
|
||||
|
||||
const result = truncateForLabel(longText, 60);
|
||||
|
||||
expect(result).toHaveLength(61); // 60 + '…'
|
||||
expect(result).toBe('A'.repeat(60) + '…');
|
||||
});
|
||||
|
||||
it('should replace newlines with spaces', () => {
|
||||
const result = truncateForLabel('Line one\nLine two\nLine three', 50);
|
||||
|
||||
expect(result).toBe('Line one Line two Line three');
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('should trim surrounding whitespace', () => {
|
||||
const result = truncateForLabel(' padded text ', 50);
|
||||
|
||||
expect(result).toBe('padded text');
|
||||
});
|
||||
|
||||
it('should handle text exactly at max length', () => {
|
||||
const exactText = 'A'.repeat(60);
|
||||
|
||||
const result = truncateForLabel(exactText, 60);
|
||||
|
||||
expect(result).toBe(exactText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateForSelector', () => {
|
||||
it('should format date for English locale', () => {
|
||||
const result = formatDateForSelector('2026-02-01T10:30:00Z', 'en');
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should format date for Japanese locale', () => {
|
||||
const result = formatDateForSelector('2026-02-01T10:30:00Z', 'ja');
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
58
src/__tests__/task-process-alive.test.ts
Normal file
58
src/__tests__/task-process-alive.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isProcessAlive, isStaleRunningTask } from '../infra/task/process.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('process alive utility', () => {
|
||||
it('returns true when process id exists', () => {
|
||||
const mockKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const result = isProcessAlive(process.pid);
|
||||
|
||||
expect(mockKill).toHaveBeenCalledWith(process.pid, 0);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when process does not exist', () => {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const error = new Error('No such process') as NodeJS.ErrnoException;
|
||||
error.code = 'ESRCH';
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(isProcessAlive(99999)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats permission errors as alive', () => {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const error = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||
error.code = 'EPERM';
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(isProcessAlive(99999)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws for unexpected process errors', () => {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const error = new Error('Unknown') as NodeJS.ErrnoException;
|
||||
error.code = 'EINVAL';
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() => isProcessAlive(99999)).toThrow('Unknown');
|
||||
});
|
||||
|
||||
it('returns true when stale check receives a live process id', () => {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
expect(isStaleRunningTask(process.pid)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when stale check has no process id', () => {
|
||||
expect(isStaleRunningTask(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,51 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
mockAddTask,
|
||||
mockCompleteTask,
|
||||
mockFailTask,
|
||||
mockExecuteTask,
|
||||
mockRequeueTask,
|
||||
mockRunInstructMode,
|
||||
mockDispatchConversationAction,
|
||||
mockSelectPiece,
|
||||
mockConfirm,
|
||||
mockGetLabel,
|
||||
mockResolveLanguage,
|
||||
mockListRecentRuns,
|
||||
mockSelectRun,
|
||||
mockLoadRunSessionContext,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAddTask: vi.fn(() => ({
|
||||
name: 'instruction-task',
|
||||
content: 'instruction',
|
||||
filePath: '/project/.takt/tasks.yaml',
|
||||
createdAt: '2026-02-14T00:00:00.000Z',
|
||||
status: 'pending',
|
||||
data: { task: 'instruction' },
|
||||
})),
|
||||
mockCompleteTask: vi.fn(),
|
||||
mockFailTask: vi.fn(),
|
||||
mockExecuteTask: vi.fn(),
|
||||
mockRequeueTask: vi.fn(),
|
||||
mockRunInstructMode: vi.fn(),
|
||||
mockDispatchConversationAction: vi.fn(),
|
||||
mockSelectPiece: vi.fn(),
|
||||
mockConfirm: vi.fn(),
|
||||
mockGetLabel: vi.fn(),
|
||||
mockResolveLanguage: vi.fn(() => 'en'),
|
||||
mockListRecentRuns: vi.fn(() => []),
|
||||
mockSelectRun: vi.fn(() => null),
|
||||
mockLoadRunSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/index.js', () => ({
|
||||
createTempCloneForBranch: vi.fn(() => ({ path: '/tmp/clone', branch: 'takt/sample' })),
|
||||
removeClone: vi.fn(),
|
||||
removeCloneMeta: vi.fn(),
|
||||
detectDefaultBranch: vi.fn(() => 'main'),
|
||||
autoCommitAndPush: vi.fn(() => ({ success: false, message: 'no changes' })),
|
||||
TaskRunner: class {
|
||||
addTask(...args: unknown[]) {
|
||||
return mockAddTask(...args);
|
||||
}
|
||||
completeTask(...args: unknown[]) {
|
||||
return mockCompleteTask(...args);
|
||||
}
|
||||
failTask(...args: unknown[]) {
|
||||
return mockFailTask(...args);
|
||||
requeueTask(...args: unknown[]) {
|
||||
return mockRequeueTask(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: false })),
|
||||
loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })),
|
||||
getPieceDescription: vi.fn(() => ({
|
||||
name: 'default',
|
||||
description: 'desc',
|
||||
@ -54,10 +43,6 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
||||
executeTask: (...args: unknown[]) => mockExecuteTask(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../features/tasks/list/instructMode.js', () => ({
|
||||
runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args),
|
||||
}));
|
||||
@ -74,6 +59,21 @@ vi.mock('../features/interactive/actionDispatcher.js', () => ({
|
||||
dispatchConversationAction: (...args: unknown[]) => mockDispatchConversationAction(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
confirm: (...args: unknown[]) => mockConfirm(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: (...args: unknown[]) => mockGetLabel(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../features/interactive/index.js', () => ({
|
||||
resolveLanguage: (...args: unknown[]) => mockResolveLanguage(...args),
|
||||
listRecentRuns: (...args: unknown[]) => mockListRecentRuns(...args),
|
||||
selectRun: (...args: unknown[]) => mockSelectRun(...args),
|
||||
loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
@ -91,17 +91,20 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
|
||||
import { instructBranch } from '../features/tasks/list/taskActions.js';
|
||||
|
||||
describe('instructBranch execute flow', () => {
|
||||
describe('instructBranch requeue flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectPiece.mockResolvedValue('default');
|
||||
mockRunInstructMode.mockResolvedValue({ type: 'execute', task: '追加して' });
|
||||
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加して' }));
|
||||
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
|
||||
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' }));
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockGetLabel.mockReturnValue("Reference a previous run's results?");
|
||||
mockResolveLanguage.mockReturnValue('en');
|
||||
mockListRecentRuns.mockReturnValue([]);
|
||||
mockSelectRun.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('should record addTask and completeTask on success', async () => {
|
||||
mockExecuteTask.mockResolvedValue(true);
|
||||
|
||||
it('should requeue the same completed task instead of creating another task', async () => {
|
||||
const result = await instructBranch('/project', {
|
||||
kind: 'completed',
|
||||
name: 'done-task',
|
||||
@ -110,18 +113,20 @@ describe('instructBranch execute flow', () => {
|
||||
content: 'done',
|
||||
branch: 'takt/done-task',
|
||||
worktreePath: '/project/.takt/worktrees/done-task',
|
||||
data: { task: 'done', retry_note: '既存ノート' },
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockAddTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockCompleteTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockFailTask).not.toHaveBeenCalled();
|
||||
expect(mockRequeueTask).toHaveBeenCalledWith(
|
||||
'done-task',
|
||||
['completed', 'failed'],
|
||||
undefined,
|
||||
'既存ノート\n\n追加指示A',
|
||||
);
|
||||
});
|
||||
|
||||
it('should record addTask and failTask on failure', async () => {
|
||||
mockExecuteTask.mockResolvedValue(false);
|
||||
|
||||
const result = await instructBranch('/project', {
|
||||
it('should set generated instruction as retry note when no existing note', async () => {
|
||||
await instructBranch('/project', {
|
||||
kind: 'completed',
|
||||
name: 'done-task',
|
||||
createdAt: '2026-02-14T00:00:00.000Z',
|
||||
@ -129,18 +134,26 @@ describe('instructBranch execute flow', () => {
|
||||
content: 'done',
|
||||
branch: 'takt/done-task',
|
||||
worktreePath: '/project/.takt/worktrees/done-task',
|
||||
data: { task: 'done' },
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockAddTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockFailTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockCompleteTask).not.toHaveBeenCalled();
|
||||
expect(mockRequeueTask).toHaveBeenCalledWith(
|
||||
'done-task',
|
||||
['completed', 'failed'],
|
||||
undefined,
|
||||
'追加指示A',
|
||||
);
|
||||
});
|
||||
|
||||
it('should record failTask when executeTask throws', async () => {
|
||||
mockExecuteTask.mockRejectedValue(new Error('crashed'));
|
||||
it('should load selected run context and pass it to instruct mode', async () => {
|
||||
mockListRecentRuns.mockReturnValue([
|
||||
{ slug: 'run-1', task: 'fix', piece: 'default', status: 'completed', startTime: '2026-02-18T00:00:00Z' },
|
||||
]);
|
||||
mockSelectRun.mockResolvedValue('run-1');
|
||||
const runContext = { task: 'fix', piece: 'default', status: 'completed', movementLogs: [], reports: [] };
|
||||
mockLoadRunSessionContext.mockReturnValue(runContext);
|
||||
|
||||
await expect(instructBranch('/project', {
|
||||
await instructBranch('/project', {
|
||||
kind: 'completed',
|
||||
name: 'done-task',
|
||||
createdAt: '2026-02-14T00:00:00.000Z',
|
||||
@ -148,10 +161,18 @@ describe('instructBranch execute flow', () => {
|
||||
content: 'done',
|
||||
branch: 'takt/done-task',
|
||||
worktreePath: '/project/.takt/worktrees/done-task',
|
||||
})).rejects.toThrow('crashed');
|
||||
data: { task: 'done' },
|
||||
});
|
||||
|
||||
expect(mockAddTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockFailTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockCompleteTask).not.toHaveBeenCalled();
|
||||
expect(mockConfirm).toHaveBeenCalledWith("Reference a previous run's results?", false);
|
||||
expect(mockSelectRun).toHaveBeenCalledWith('/project', 'en');
|
||||
expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project', 'run-1');
|
||||
expect(mockRunInstructMode).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
expect.any(String),
|
||||
'takt/done-task',
|
||||
expect.anything(),
|
||||
runContext,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: vi.fn(),
|
||||
promptInput: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
@ -29,21 +29,44 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
loadGlobalConfig: vi.fn(),
|
||||
loadPieceByIdentifier: vi.fn(),
|
||||
getPieceDescription: vi.fn(() => ({
|
||||
name: 'default',
|
||||
description: 'desc',
|
||||
pieceStructure: '',
|
||||
movementPreviews: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
import { selectOption, promptInput } from '../shared/prompt/index.js';
|
||||
vi.mock('../features/tasks/list/instructMode.js', () => ({
|
||||
runInstructMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../features/interactive/index.js', () => ({
|
||||
resolveLanguage: vi.fn(() => 'en'),
|
||||
listRecentRuns: vi.fn(() => []),
|
||||
selectRun: vi.fn(() => null),
|
||||
loadRunSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: vi.fn(() => "Reference a previous run's results?"),
|
||||
}));
|
||||
|
||||
import { selectOption, confirm } from '../shared/prompt/index.js';
|
||||
import { success, error as logError } from '../shared/ui/index.js';
|
||||
import { loadGlobalConfig, loadPieceByIdentifier } from '../infra/config/index.js';
|
||||
import { retryFailedTask } from '../features/tasks/list/taskRetryActions.js';
|
||||
import type { TaskListItem } from '../infra/task/types.js';
|
||||
import type { PieceConfig } from '../core/models/index.js';
|
||||
import { runInstructMode } from '../features/tasks/list/instructMode.js';
|
||||
|
||||
const mockSelectOption = vi.mocked(selectOption);
|
||||
const mockPromptInput = vi.mocked(promptInput);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockSuccess = vi.mocked(success);
|
||||
const mockLogError = vi.mocked(logError);
|
||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||
const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier);
|
||||
const mockRunInstructMode = vi.mocked(runInstructMode);
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
@ -107,7 +130,8 @@ describe('retryFailedTask', () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('implement');
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
|
||||
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
@ -117,6 +141,7 @@ describe('retryFailedTask', () => {
|
||||
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
expect(tasksYaml).toContain('status: pending');
|
||||
expect(tasksYaml).toContain('start_movement: implement');
|
||||
expect(tasksYaml).toContain('retry_note: 追加指示A');
|
||||
});
|
||||
|
||||
it('should not add start_movement when initial movement is selected', async () => {
|
||||
@ -125,13 +150,34 @@ describe('retryFailedTask', () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('plan');
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
|
||||
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
expect(tasksYaml).not.toContain('start_movement');
|
||||
expect(tasksYaml).toContain('retry_note: 追加指示A');
|
||||
});
|
||||
|
||||
it('should append generated instruction to existing retry note', async () => {
|
||||
const task = writeFailedTask(tmpDir, 'my-task');
|
||||
task.data = { task: 'Do something', piece: 'default', retry_note: '既存ノート' };
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('plan');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示B' });
|
||||
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
expect(tasksYaml).toContain('retry_note: |');
|
||||
expect(tasksYaml).toContain('既存ノート');
|
||||
expect(tasksYaml).toContain('追加指示B');
|
||||
});
|
||||
|
||||
it('should return false and show error when piece not found', async () => {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* pipeline mode, or interactive mode.
|
||||
*/
|
||||
|
||||
import { info, error, withProgress } from '../../shared/ui/index.js';
|
||||
import { info, error as logError, withProgress } from '../../shared/ui/index.js';
|
||||
import { confirm } from '../../shared/prompt/index.js';
|
||||
import { getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
@ -20,13 +20,14 @@ import {
|
||||
quietMode,
|
||||
personaMode,
|
||||
resolveLanguage,
|
||||
dispatchConversationAction,
|
||||
type InteractiveModeResult,
|
||||
} from '../../features/interactive/index.js';
|
||||
import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js';
|
||||
import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
|
||||
import { program, resolvedCwd, pipelineMode } from './program.js';
|
||||
import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js';
|
||||
import { loadTaskHistory } from './taskHistory.js';
|
||||
|
||||
/**
|
||||
* Resolve issue references from CLI input.
|
||||
@ -131,7 +132,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
initialInput = issueResult.initialInput;
|
||||
}
|
||||
} catch (e) {
|
||||
error(getErrorMessage(e));
|
||||
logError(getErrorMessage(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -160,6 +161,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
description: pieceDesc.description,
|
||||
pieceStructure: pieceDesc.pieceStructure,
|
||||
movementPreviews: pieceDesc.movementPreviews,
|
||||
taskHistory: loadTaskHistory(resolvedCwd, lang),
|
||||
};
|
||||
|
||||
let result: InteractiveModeResult;
|
||||
|
||||
55
src/app/cli/taskHistory.ts
Normal file
55
src/app/cli/taskHistory.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { isStaleRunningTask, TaskRunner } from '../../infra/task/index.js';
|
||||
import {
|
||||
type TaskHistorySummaryItem,
|
||||
normalizeTaskHistorySummary,
|
||||
} from '../../features/interactive/index.js';
|
||||
import { getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { error as logError } from '../../shared/ui/index.js';
|
||||
|
||||
/**
|
||||
* Load and normalize task history for interactive context.
|
||||
*/
|
||||
function toTaskHistoryItems(cwd: string): TaskHistorySummaryItem[] {
|
||||
const runner = new TaskRunner(cwd);
|
||||
const tasks = runner.listAllTaskItems();
|
||||
|
||||
const historyItems: TaskHistorySummaryItem[] = [];
|
||||
for (const task of tasks) {
|
||||
if (task.kind === 'failed' || task.kind === 'completed') {
|
||||
historyItems.push({
|
||||
worktreeId: task.worktreePath ?? task.name,
|
||||
status: task.kind,
|
||||
startedAt: task.startedAt ?? '',
|
||||
completedAt: task.completedAt ?? '',
|
||||
finalResult: task.kind,
|
||||
failureSummary: task.failure?.error,
|
||||
logKey: task.branch ?? task.worktreePath ?? task.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.kind === 'running' && isStaleRunningTask(task.ownerPid)) {
|
||||
historyItems.push({
|
||||
worktreeId: task.worktreePath ?? task.name,
|
||||
status: 'interrupted',
|
||||
startedAt: task.startedAt ?? '',
|
||||
completedAt: task.completedAt ?? '',
|
||||
finalResult: 'interrupted',
|
||||
failureSummary: undefined,
|
||||
logKey: task.branch ?? task.worktreePath ?? task.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return historyItems;
|
||||
}
|
||||
|
||||
export function loadTaskHistory(cwd: string, lang: 'en' | 'ja'): TaskHistorySummaryItem[] {
|
||||
try {
|
||||
return normalizeTaskHistorySummary(toTaskHistoryItems(cwd), lang);
|
||||
} catch (err) {
|
||||
logError(getErrorMessage(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,9 @@ export {
|
||||
selectPostSummaryAction,
|
||||
formatMovementPreviews,
|
||||
formatSessionStatus,
|
||||
normalizeTaskHistorySummary,
|
||||
type PieceContext,
|
||||
type TaskHistorySummaryItem,
|
||||
type InteractiveModeResult,
|
||||
type InteractiveModeAction,
|
||||
} from './interactive.js';
|
||||
@ -19,3 +21,6 @@ export { selectRecentSession } from './sessionSelector.js';
|
||||
export { passthroughMode } from './passthroughMode.js';
|
||||
export { quietMode } from './quietMode.js';
|
||||
export { personaMode } from './personaMode.js';
|
||||
export { selectRun } from './runSelector.js';
|
||||
export { listRecentRuns, loadRunSessionContext, type RunSessionContext } from './runSessionReader.js';
|
||||
export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js';
|
||||
|
||||
263
src/features/interactive/interactive-summary.ts
Normal file
263
src/features/interactive/interactive-summary.ts
Normal file
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Interactive summary helpers.
|
||||
*/
|
||||
|
||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||
import { type MovementPreview } from '../../infra/config/index.js';
|
||||
import { selectOption } from '../../shared/prompt/index.js';
|
||||
import { blankLine, info } from '../../shared/ui/index.js';
|
||||
|
||||
type TaskHistoryLocale = 'en' | 'ja';
|
||||
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TaskHistorySummaryItem {
|
||||
worktreeId: string;
|
||||
status: 'completed' | 'failed' | 'interrupted';
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
finalResult: string;
|
||||
failureSummary: string | undefined;
|
||||
logKey: string;
|
||||
}
|
||||
|
||||
export function formatMovementPreviews(previews: MovementPreview[], lang: TaskHistoryLocale): string {
|
||||
return previews.map((p, i) => {
|
||||
const toolsStr = p.allowedTools.length > 0
|
||||
? p.allowedTools.join(', ')
|
||||
: (lang === 'ja' ? 'なし' : 'None');
|
||||
const editStr = p.canEdit
|
||||
? (lang === 'ja' ? '可' : 'Yes')
|
||||
: (lang === 'ja' ? '不可' : 'No');
|
||||
const personaLabel = lang === 'ja' ? 'ペルソナ' : 'Persona';
|
||||
const instructionLabel = lang === 'ja' ? 'インストラクション' : 'Instruction';
|
||||
const toolsLabel = lang === 'ja' ? 'ツール' : 'Tools';
|
||||
const editLabel = lang === 'ja' ? '編集' : 'Edit';
|
||||
|
||||
const lines = [
|
||||
`### ${i + 1}. ${p.name} (${p.personaDisplayName})`,
|
||||
];
|
||||
if (p.personaContent) {
|
||||
lines.push(`**${personaLabel}:**`, p.personaContent);
|
||||
}
|
||||
if (p.instructionContent) {
|
||||
lines.push(`**${instructionLabel}:**`, p.instructionContent);
|
||||
}
|
||||
lines.push(`**${toolsLabel}:** ${toolsStr}`, `**${editLabel}:** ${editStr}`);
|
||||
return lines.join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
function normalizeDateTime(value: string): string {
|
||||
return value.trim() === '' ? 'N/A' : value;
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(status: TaskHistorySummaryItem['status'], lang: TaskHistoryLocale): string {
|
||||
return status === 'completed'
|
||||
? (lang === 'ja' ? '完了' : 'completed')
|
||||
: status === 'failed'
|
||||
? (lang === 'ja' ? '失敗' : 'failed')
|
||||
: (lang === 'ja' ? '中断' : 'interrupted');
|
||||
}
|
||||
|
||||
export function normalizeTaskHistorySummary(
|
||||
items: TaskHistorySummaryItem[],
|
||||
lang: TaskHistoryLocale,
|
||||
): TaskHistorySummaryItem[] {
|
||||
return items.map((task) => ({
|
||||
...task,
|
||||
startedAt: normalizeDateTime(task.startedAt),
|
||||
completedAt: normalizeDateTime(task.completedAt),
|
||||
finalResult: normalizeTaskStatus(task.status, lang),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatTaskHistoryItem(item: TaskHistorySummaryItem, lang: TaskHistoryLocale): string {
|
||||
const statusLabel = normalizeTaskStatus(item.status, lang);
|
||||
const failureSummaryLine = item.failureSummary
|
||||
? `${lang === 'ja' ? ' - 失敗要約' : ' - Failure summary'}: ${item.failureSummary}\n`
|
||||
: '';
|
||||
const lines = [
|
||||
`- ${lang === 'ja' ? '実行ID' : 'Worktree ID'}: ${item.worktreeId}`,
|
||||
` - ${lang === 'ja' ? 'ステータス' : 'Status'}: ${statusLabel}`,
|
||||
` - ${lang === 'ja' ? '開始/終了' : 'Start/End'}: ${item.startedAt} / ${item.completedAt}`,
|
||||
` - ${lang === 'ja' ? '最終結果' : 'Final result'}: ${item.finalResult}`,
|
||||
` - ${lang === 'ja' ? 'ログ参照' : 'Log key'}: ${item.logKey}`,
|
||||
failureSummaryLine,
|
||||
];
|
||||
return lines.join('\n').replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
export function formatTaskHistorySummary(taskHistory: TaskHistorySummaryItem[], lang: TaskHistoryLocale): string {
|
||||
if (taskHistory.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedTaskHistory = normalizeTaskHistorySummary(taskHistory, lang);
|
||||
const heading = lang === 'ja'
|
||||
? '## 実行履歴'
|
||||
: '## Task execution history';
|
||||
const details = normalizedTaskHistory.map((item) => formatTaskHistoryItem(item, lang)).join('\n\n');
|
||||
return `${heading}\n${details}`;
|
||||
}
|
||||
|
||||
function buildTaskFromHistory(history: ConversationMessage[]): string {
|
||||
return history
|
||||
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export interface PieceContext {
|
||||
/** Piece name (e.g. "minimal") */
|
||||
name: string;
|
||||
/** Piece description */
|
||||
description: string;
|
||||
/** Piece structure (numbered list of movements) */
|
||||
pieceStructure: string;
|
||||
/** Movement previews (persona + instruction content for first N movements) */
|
||||
movementPreviews?: MovementPreview[];
|
||||
/** Recent task history for conversation context */
|
||||
taskHistory?: TaskHistorySummaryItem[];
|
||||
}
|
||||
|
||||
export function buildSummaryPrompt(
|
||||
history: ConversationMessage[],
|
||||
hasSession: boolean,
|
||||
lang: 'en' | 'ja',
|
||||
noTranscriptNote: string,
|
||||
conversationLabel: string,
|
||||
pieceContext?: PieceContext,
|
||||
): string {
|
||||
let conversation = '';
|
||||
if (history.length > 0) {
|
||||
const historyText = buildTaskFromHistory(history);
|
||||
conversation = `${conversationLabel}\n${historyText}`;
|
||||
} else if (hasSession) {
|
||||
conversation = `${conversationLabel}\n${noTranscriptNote}`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hasPiece = !!pieceContext;
|
||||
const hasPreview = !!pieceContext?.movementPreviews?.length;
|
||||
const summaryMovementDetails = hasPreview
|
||||
? `\n### ${lang === 'ja' ? '処理するエージェント' : 'Processing Agents'}\n${formatMovementPreviews(pieceContext!.movementPreviews!, lang)}`
|
||||
: '';
|
||||
const summaryTaskHistory = pieceContext?.taskHistory?.length
|
||||
? formatTaskHistorySummary(pieceContext.taskHistory, lang)
|
||||
: '';
|
||||
|
||||
return loadTemplate('score_summary_system_prompt', lang, {
|
||||
pieceInfo: hasPiece,
|
||||
pieceName: pieceContext?.name ?? '',
|
||||
pieceDescription: pieceContext?.description ?? '',
|
||||
movementDetails: summaryMovementDetails,
|
||||
taskHistory: summaryTaskHistory,
|
||||
conversation,
|
||||
});
|
||||
}
|
||||
|
||||
export type PostSummaryAction = InteractiveModeAction | 'continue';
|
||||
|
||||
export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue';
|
||||
|
||||
export interface SummaryActionOption {
|
||||
label: string;
|
||||
value: SummaryActionValue;
|
||||
}
|
||||
|
||||
export type SummaryActionLabels = {
|
||||
execute: string;
|
||||
createIssue?: string;
|
||||
saveTask: string;
|
||||
continue: string;
|
||||
};
|
||||
|
||||
export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [
|
||||
'execute',
|
||||
'save_task',
|
||||
'continue',
|
||||
];
|
||||
|
||||
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
|
||||
|
||||
export interface InteractiveSummaryUIText {
|
||||
actionPrompt: string;
|
||||
actions: {
|
||||
execute: string;
|
||||
createIssue: string;
|
||||
saveTask: string;
|
||||
continue: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSummaryActionOptions(
|
||||
labels: SummaryActionLabels,
|
||||
append: readonly SummaryActionValue[] = [],
|
||||
): SummaryActionOption[] {
|
||||
const order = [...BASE_SUMMARY_ACTIONS, ...append];
|
||||
const seen = new Set<SummaryActionValue>();
|
||||
const options: SummaryActionOption[] = [];
|
||||
|
||||
for (const action of order) {
|
||||
if (seen.has(action)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(action);
|
||||
|
||||
if (action === 'execute') {
|
||||
options.push({ label: labels.execute, value: action });
|
||||
continue;
|
||||
}
|
||||
if (action === 'create_issue') {
|
||||
if (labels.createIssue) {
|
||||
options.push({ label: labels.createIssue, value: action });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (action === 'save_task') {
|
||||
options.push({ label: labels.saveTask, value: action });
|
||||
continue;
|
||||
}
|
||||
options.push({ label: labels.continue, value: action });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function selectSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
actionPrompt: string,
|
||||
options: SummaryActionOption[],
|
||||
): Promise<PostSummaryAction | null> {
|
||||
blankLine();
|
||||
info(proposedLabel);
|
||||
console.log(task);
|
||||
|
||||
return selectOption<PostSummaryAction>(actionPrompt, options);
|
||||
}
|
||||
|
||||
export function selectPostSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
ui: InteractiveSummaryUIText,
|
||||
): Promise<PostSummaryAction | null> {
|
||||
return selectSummaryAction(
|
||||
task,
|
||||
proposedLabel,
|
||||
ui.actionPrompt,
|
||||
buildSummaryActionOptions(
|
||||
{
|
||||
execute: ui.actions.execute,
|
||||
createIssue: ui.actions.createIssue,
|
||||
saveTask: ui.actions.saveTask,
|
||||
continue: ui.actions.continue,
|
||||
},
|
||||
['create_issue'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -13,17 +13,20 @@
|
||||
import type { Language } from '../../core/models/index.js';
|
||||
import {
|
||||
type SessionState,
|
||||
type MovementPreview,
|
||||
} from '../../infra/config/index.js';
|
||||
import { selectOption } from '../../shared/prompt/index.js';
|
||||
import { info, blankLine } from '../../shared/ui/index.js';
|
||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||
import {
|
||||
initializeSession,
|
||||
displayAndClearSessionState,
|
||||
runConversationLoop,
|
||||
} from './conversationLoop.js';
|
||||
import {
|
||||
type PieceContext,
|
||||
formatMovementPreviews,
|
||||
type InteractiveModeAction,
|
||||
} from './interactive-summary.js';
|
||||
import { type RunSessionContext, formatRunSessionForPrompt } from './runSessionReader.js';
|
||||
|
||||
/** Shape of interactive UI text */
|
||||
export interface InteractiveUIText {
|
||||
@ -57,7 +60,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str
|
||||
lines.push(
|
||||
getLabel('interactive.previousTask.error', lang, {
|
||||
error: state.errorMessage!,
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else if (state.status === 'user_stopped') {
|
||||
lines.push(getLabel('interactive.previousTask.userStopped', lang));
|
||||
@ -67,7 +70,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str
|
||||
lines.push(
|
||||
getLabel('interactive.previousTask.piece', lang, {
|
||||
pieceName: state.pieceName,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Timestamp
|
||||
@ -75,7 +78,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str
|
||||
lines.push(
|
||||
getLabel('interactive.previousTask.timestamp', lang, {
|
||||
timestamp,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
@ -85,197 +88,19 @@ export function resolveLanguage(lang?: Language): 'en' | 'ja' {
|
||||
return lang === 'ja' ? 'ja' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MovementPreview[] into a Markdown string for template injection.
|
||||
* Each movement is rendered with its persona and instruction content.
|
||||
*/
|
||||
export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' | 'ja'): string {
|
||||
return previews.map((p, i) => {
|
||||
const toolsStr = p.allowedTools.length > 0
|
||||
? p.allowedTools.join(', ')
|
||||
: (lang === 'ja' ? 'なし' : 'None');
|
||||
const editStr = p.canEdit
|
||||
? (lang === 'ja' ? '可' : 'Yes')
|
||||
: (lang === 'ja' ? '不可' : 'No');
|
||||
const personaLabel = lang === 'ja' ? 'ペルソナ' : 'Persona';
|
||||
const instructionLabel = lang === 'ja' ? 'インストラクション' : 'Instruction';
|
||||
const toolsLabel = lang === 'ja' ? 'ツール' : 'Tools';
|
||||
const editLabel = lang === 'ja' ? '編集' : 'Edit';
|
||||
|
||||
const lines = [
|
||||
`### ${i + 1}. ${p.name} (${p.personaDisplayName})`,
|
||||
];
|
||||
if (p.personaContent) {
|
||||
lines.push(`**${personaLabel}:**`, p.personaContent);
|
||||
}
|
||||
if (p.instructionContent) {
|
||||
lines.push(`**${instructionLabel}:**`, p.instructionContent);
|
||||
}
|
||||
lines.push(`**${toolsLabel}:** ${toolsStr}`, `**${editLabel}:** ${editStr}`);
|
||||
return lines.join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final task description from conversation history for executeTask.
|
||||
*/
|
||||
function buildTaskFromHistory(history: ConversationMessage[]): string {
|
||||
return history
|
||||
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
/** Default toolset for interactive mode */
|
||||
export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
/**
|
||||
* Build the summary prompt (used as both system prompt and user message).
|
||||
* Renders the complete score_summary_system_prompt template with conversation data.
|
||||
* Returns empty string if there is no conversation to summarize.
|
||||
*/
|
||||
export function buildSummaryPrompt(
|
||||
history: ConversationMessage[],
|
||||
hasSession: boolean,
|
||||
lang: 'en' | 'ja',
|
||||
noTranscriptNote: string,
|
||||
conversationLabel: string,
|
||||
pieceContext?: PieceContext,
|
||||
): string {
|
||||
let conversation = '';
|
||||
if (history.length > 0) {
|
||||
const historyText = buildTaskFromHistory(history);
|
||||
conversation = `${conversationLabel}\n${historyText}`;
|
||||
} else if (hasSession) {
|
||||
conversation = `${conversationLabel}\n${noTranscriptNote}`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hasPiece = !!pieceContext;
|
||||
const hasPreview = !!pieceContext?.movementPreviews?.length;
|
||||
const summaryMovementDetails = hasPreview
|
||||
? `\n### ${lang === 'ja' ? '処理するエージェント' : 'Processing Agents'}\n${formatMovementPreviews(pieceContext!.movementPreviews!, lang)}`
|
||||
: '';
|
||||
return loadTemplate('score_summary_system_prompt', lang, {
|
||||
pieceInfo: hasPiece,
|
||||
pieceName: pieceContext?.name ?? '',
|
||||
pieceDescription: pieceContext?.description ?? '',
|
||||
movementDetails: summaryMovementDetails,
|
||||
conversation,
|
||||
});
|
||||
}
|
||||
|
||||
export type PostSummaryAction = InteractiveModeAction | 'continue';
|
||||
|
||||
export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue';
|
||||
|
||||
export interface SummaryActionOption {
|
||||
label: string;
|
||||
value: SummaryActionValue;
|
||||
}
|
||||
|
||||
export type SummaryActionLabels = {
|
||||
execute: string;
|
||||
createIssue?: string;
|
||||
saveTask: string;
|
||||
continue: string;
|
||||
};
|
||||
|
||||
export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [
|
||||
'execute',
|
||||
'save_task',
|
||||
'continue',
|
||||
];
|
||||
|
||||
export function buildSummaryActionOptions(
|
||||
labels: SummaryActionLabels,
|
||||
append: readonly SummaryActionValue[] = [],
|
||||
): SummaryActionOption[] {
|
||||
const order = [...BASE_SUMMARY_ACTIONS, ...append];
|
||||
const seen = new Set<SummaryActionValue>();
|
||||
const options: SummaryActionOption[] = [];
|
||||
|
||||
for (const action of order) {
|
||||
if (seen.has(action)) continue;
|
||||
seen.add(action);
|
||||
|
||||
if (action === 'execute') {
|
||||
options.push({ label: labels.execute, value: action });
|
||||
continue;
|
||||
}
|
||||
if (action === 'create_issue') {
|
||||
if (labels.createIssue) {
|
||||
options.push({ label: labels.createIssue, value: action });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (action === 'save_task') {
|
||||
options.push({ label: labels.saveTask, value: action });
|
||||
continue;
|
||||
}
|
||||
options.push({ label: labels.continue, value: action });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function selectSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
actionPrompt: string,
|
||||
options: SummaryActionOption[],
|
||||
): Promise<PostSummaryAction | null> {
|
||||
blankLine();
|
||||
info(proposedLabel);
|
||||
console.log(task);
|
||||
|
||||
return selectOption<PostSummaryAction>(actionPrompt, options);
|
||||
}
|
||||
|
||||
export async function selectPostSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
ui: InteractiveUIText,
|
||||
): Promise<PostSummaryAction | null> {
|
||||
return selectSummaryAction(
|
||||
task,
|
||||
proposedLabel,
|
||||
ui.actionPrompt,
|
||||
buildSummaryActionOptions(
|
||||
{
|
||||
execute: ui.actions.execute,
|
||||
createIssue: ui.actions.createIssue,
|
||||
saveTask: ui.actions.saveTask,
|
||||
continue: ui.actions.continue,
|
||||
},
|
||||
['create_issue'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
|
||||
|
||||
export interface InteractiveModeResult {
|
||||
/** The action selected by the user */
|
||||
action: InteractiveModeAction;
|
||||
/** The assembled task text (only meaningful when action is not 'cancel') */
|
||||
task: string;
|
||||
}
|
||||
|
||||
export interface PieceContext {
|
||||
/** Piece name (e.g. "minimal") */
|
||||
name: string;
|
||||
/** Piece description */
|
||||
description: string;
|
||||
/** Piece structure (numbered list of movements) */
|
||||
pieceStructure: string;
|
||||
/** Movement previews (persona + instruction content for first N movements) */
|
||||
movementPreviews?: MovementPreview[];
|
||||
}
|
||||
|
||||
export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
export {
|
||||
buildSummaryPrompt,
|
||||
formatMovementPreviews,
|
||||
type ConversationMessage,
|
||||
type PieceContext,
|
||||
type TaskHistorySummaryItem,
|
||||
} from './interactive-summary.js';
|
||||
|
||||
/**
|
||||
* Run the interactive task input mode.
|
||||
@ -291,6 +116,7 @@ export async function interactiveMode(
|
||||
initialInput?: string,
|
||||
pieceContext?: PieceContext,
|
||||
sessionId?: string,
|
||||
runSessionContext?: RunSessionContext,
|
||||
): Promise<InteractiveModeResult> {
|
||||
const baseCtx = initializeSession(cwd, 'interactive');
|
||||
const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx;
|
||||
@ -298,10 +124,17 @@ export async function interactiveMode(
|
||||
displayAndClearSessionState(cwd, ctx.lang);
|
||||
|
||||
const hasPreview = !!pieceContext?.movementPreviews?.length;
|
||||
const hasRunSession = !!runSessionContext;
|
||||
const runPromptVars = hasRunSession
|
||||
? formatRunSessionForPrompt(runSessionContext)
|
||||
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
|
||||
|
||||
const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
|
||||
hasPiecePreview: hasPreview,
|
||||
pieceStructure: pieceContext?.pieceStructure ?? '',
|
||||
movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '',
|
||||
hasRunSession,
|
||||
...runPromptVars,
|
||||
});
|
||||
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
|
||||
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
|
||||
@ -327,3 +160,25 @@ export async function interactiveMode(
|
||||
introMessage: ui.intro,
|
||||
}, pieceContext, initialInput);
|
||||
}
|
||||
|
||||
export {
|
||||
type InteractiveModeAction,
|
||||
type InteractiveSummaryUIText,
|
||||
type PostSummaryAction,
|
||||
type SummaryActionLabels,
|
||||
type SummaryActionOption,
|
||||
type SummaryActionValue,
|
||||
selectPostSummaryAction,
|
||||
buildSummaryActionOptions,
|
||||
selectSummaryAction,
|
||||
formatTaskHistorySummary,
|
||||
normalizeTaskHistorySummary,
|
||||
BASE_SUMMARY_ACTIONS,
|
||||
} from './interactive-summary.js';
|
||||
|
||||
export interface InteractiveModeResult {
|
||||
/** The action selected by the user */
|
||||
action: InteractiveModeAction;
|
||||
/** The assembled task text (only meaningful when action is not 'cancel') */
|
||||
task: string;
|
||||
}
|
||||
|
||||
49
src/features/interactive/runSelector.ts
Normal file
49
src/features/interactive/runSelector.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Run selector for interactive mode
|
||||
*
|
||||
* Checks for recent runs and presents a selection UI
|
||||
* using the same selectOption pattern as sessionSelector.
|
||||
*/
|
||||
|
||||
import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
import { info } from '../../shared/ui/index.js';
|
||||
import { listRecentRuns, type RunSummary } from './runSessionReader.js';
|
||||
import { truncateForLabel, formatDateForSelector } from './selectorUtils.js';
|
||||
|
||||
/** Maximum label length for run task display */
|
||||
const MAX_TASK_LABEL_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Prompt user to select a run from recent runs.
|
||||
*
|
||||
* @returns Selected run slug, or null if no runs or cancelled
|
||||
*/
|
||||
export async function selectRun(
|
||||
cwd: string,
|
||||
lang: 'en' | 'ja',
|
||||
): Promise<string | null> {
|
||||
const runs = listRecentRuns(cwd);
|
||||
|
||||
if (runs.length === 0) {
|
||||
info(getLabel('interactive.runSelector.noRuns', lang));
|
||||
return null;
|
||||
}
|
||||
|
||||
const options: SelectOptionItem<string>[] = runs.map((run: RunSummary) => {
|
||||
const label = truncateForLabel(run.task, MAX_TASK_LABEL_LENGTH);
|
||||
const dateStr = formatDateForSelector(run.startTime, lang);
|
||||
const description = `${dateStr} | ${run.piece} | ${run.status}`;
|
||||
|
||||
return {
|
||||
label,
|
||||
value: run.slug,
|
||||
description,
|
||||
};
|
||||
});
|
||||
|
||||
const prompt = getLabel('interactive.runSelector.prompt', lang);
|
||||
const selected = await selectOption<string>(prompt, options);
|
||||
|
||||
return selected;
|
||||
}
|
||||
205
src/features/interactive/runSessionReader.ts
Normal file
205
src/features/interactive/runSessionReader.ts
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Run session reader for interactive mode
|
||||
*
|
||||
* Scans .takt/runs/ for recent runs, loads NDJSON logs and reports,
|
||||
* and formats them for injection into the interactive system prompt.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { loadNdjsonLog } from '../../infra/fs/index.js';
|
||||
import type { SessionLog } from '../../shared/utils/index.js';
|
||||
|
||||
/** Maximum number of runs to return from listing */
|
||||
const MAX_RUNS = 10;
|
||||
|
||||
/** Maximum character length for movement log content */
|
||||
const MAX_CONTENT_LENGTH = 500;
|
||||
|
||||
/** Summary of a run for selection UI */
|
||||
export interface RunSummary {
|
||||
readonly slug: string;
|
||||
readonly task: string;
|
||||
readonly piece: string;
|
||||
readonly status: string;
|
||||
readonly startTime: string;
|
||||
}
|
||||
|
||||
/** A single movement log entry for display */
|
||||
interface MovementLogEntry {
|
||||
readonly step: string;
|
||||
readonly persona: string;
|
||||
readonly status: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
/** A report file entry */
|
||||
interface ReportEntry {
|
||||
readonly filename: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
/** Full context loaded from a run for prompt injection */
|
||||
export interface RunSessionContext {
|
||||
readonly task: string;
|
||||
readonly piece: string;
|
||||
readonly status: string;
|
||||
readonly movementLogs: readonly MovementLogEntry[];
|
||||
readonly reports: readonly ReportEntry[];
|
||||
}
|
||||
|
||||
interface MetaJson {
|
||||
readonly task: string;
|
||||
readonly piece: string;
|
||||
readonly status: string;
|
||||
readonly startTime: string;
|
||||
readonly logsDirectory: string;
|
||||
readonly reportDirectory: string;
|
||||
readonly runSlug: string;
|
||||
}
|
||||
|
||||
function truncateContent(content: string, maxLength: number): string {
|
||||
if (content.length <= maxLength) {
|
||||
return content;
|
||||
}
|
||||
return content.slice(0, maxLength) + '…';
|
||||
}
|
||||
|
||||
function parseMetaJson(metaPath: string): MetaJson | null {
|
||||
if (!existsSync(metaPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = readFileSync(metaPath, 'utf-8');
|
||||
return JSON.parse(raw) as MetaJson;
|
||||
}
|
||||
|
||||
function buildMovementLogs(sessionLog: SessionLog): MovementLogEntry[] {
|
||||
return sessionLog.history.map((entry) => ({
|
||||
step: entry.step,
|
||||
persona: entry.persona,
|
||||
status: entry.status,
|
||||
content: truncateContent(entry.content, MAX_CONTENT_LENGTH),
|
||||
}));
|
||||
}
|
||||
|
||||
function loadReports(reportsDir: string): ReportEntry[] {
|
||||
if (!existsSync(reportsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = readdirSync(reportsDir).filter((f) => f.endsWith('.md')).sort();
|
||||
return files.map((filename) => ({
|
||||
filename,
|
||||
content: readFileSync(join(reportsDir, filename), 'utf-8'),
|
||||
}));
|
||||
}
|
||||
|
||||
function findSessionLogFile(logsDir: string): string | null {
|
||||
if (!existsSync(logsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = readdirSync(logsDir).filter(
|
||||
(f) => f.endsWith('.jsonl') && !f.includes('-provider-events'),
|
||||
);
|
||||
|
||||
const first = files[0];
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return join(logsDir, first);
|
||||
}
|
||||
|
||||
/**
|
||||
* List recent runs sorted by startTime descending.
|
||||
*/
|
||||
export function listRecentRuns(cwd: string): RunSummary[] {
|
||||
const runsDir = join(cwd, '.takt', 'runs');
|
||||
if (!existsSync(runsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = readdirSync(runsDir, { withFileTypes: true });
|
||||
const summaries: RunSummary[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const metaPath = join(runsDir, entry.name, 'meta.json');
|
||||
const meta = parseMetaJson(metaPath);
|
||||
if (!meta) continue;
|
||||
|
||||
summaries.push({
|
||||
slug: entry.name,
|
||||
task: meta.task,
|
||||
piece: meta.piece,
|
||||
status: meta.status,
|
||||
startTime: meta.startTime,
|
||||
});
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => b.startTime.localeCompare(a.startTime));
|
||||
return summaries.slice(0, MAX_RUNS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full run session context for prompt injection.
|
||||
*/
|
||||
export function loadRunSessionContext(cwd: string, slug: string): RunSessionContext {
|
||||
const metaPath = join(cwd, '.takt', 'runs', slug, 'meta.json');
|
||||
const meta = parseMetaJson(metaPath);
|
||||
if (!meta) {
|
||||
throw new Error(`Run not found: ${slug}`);
|
||||
}
|
||||
|
||||
const logsDir = join(cwd, meta.logsDirectory);
|
||||
const logFile = findSessionLogFile(logsDir);
|
||||
|
||||
let movementLogs: MovementLogEntry[] = [];
|
||||
if (logFile) {
|
||||
const sessionLog = loadNdjsonLog(logFile);
|
||||
if (sessionLog) {
|
||||
movementLogs = buildMovementLogs(sessionLog);
|
||||
}
|
||||
}
|
||||
|
||||
const reportsDir = join(cwd, meta.reportDirectory);
|
||||
const reports = loadReports(reportsDir);
|
||||
|
||||
return {
|
||||
task: meta.task,
|
||||
piece: meta.piece,
|
||||
status: meta.status,
|
||||
movementLogs,
|
||||
reports,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format run session context into a text block for the system prompt.
|
||||
*/
|
||||
export function formatRunSessionForPrompt(ctx: RunSessionContext): {
|
||||
runTask: string;
|
||||
runPiece: string;
|
||||
runStatus: string;
|
||||
runMovementLogs: string;
|
||||
runReports: string;
|
||||
} {
|
||||
const logLines = ctx.movementLogs.map((log) => {
|
||||
const header = `### ${log.step} (${log.persona}) — ${log.status}`;
|
||||
return `${header}\n${log.content}`;
|
||||
});
|
||||
|
||||
const reportLines = ctx.reports.map((report) => {
|
||||
return `### ${report.filename}\n${report.content}`;
|
||||
});
|
||||
|
||||
return {
|
||||
runTask: ctx.task,
|
||||
runPiece: ctx.piece,
|
||||
runStatus: ctx.status,
|
||||
runMovementLogs: logLines.join('\n\n'),
|
||||
runReports: reportLines.join('\n\n'),
|
||||
};
|
||||
}
|
||||
27
src/features/interactive/selectorUtils.ts
Normal file
27
src/features/interactive/selectorUtils.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Shared utilities for selector UI components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncate text to a single line with a maximum length for display as a label.
|
||||
*/
|
||||
export function truncateForLabel(text: string, maxLength: number): string {
|
||||
const singleLine = text.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) {
|
||||
return singleLine;
|
||||
}
|
||||
return singleLine.slice(0, maxLength) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string for display in selector options.
|
||||
*/
|
||||
export function formatDateForSelector(dateStr: string, lang: 'en' | 'ja'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/clau
|
||||
import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
import { info } from '../../shared/ui/index.js';
|
||||
import { truncateForLabel, formatDateForSelector } from './selectorUtils.js';
|
||||
|
||||
/** Maximum number of sessions to display */
|
||||
const MAX_DISPLAY_SESSIONS = 10;
|
||||
@ -16,30 +17,6 @@ const MAX_DISPLAY_SESSIONS = 10;
|
||||
/** Maximum length for last response preview */
|
||||
const MAX_RESPONSE_PREVIEW_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Format a modified date for display.
|
||||
*/
|
||||
function formatModifiedDate(modified: string, lang: 'en' | 'ja'): string {
|
||||
const date = new Date(modified);
|
||||
return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a single-line string for use as a label.
|
||||
*/
|
||||
function truncateForLabel(text: string, maxLength: number): string {
|
||||
const singleLine = text.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) {
|
||||
return singleLine;
|
||||
}
|
||||
return singleLine.slice(0, maxLength) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select from recent Claude Code sessions.
|
||||
*
|
||||
@ -70,7 +47,7 @@ export async function selectRecentSession(
|
||||
|
||||
for (const session of displaySessions) {
|
||||
const label = truncateForLabel(session.firstPrompt, 60);
|
||||
const dateStr = formatModifiedDate(session.modified, lang);
|
||||
const dateStr = formatDateForSelector(session.modified, lang);
|
||||
const messagesStr = getLabel('interactive.sessionSelector.messages', lang, {
|
||||
count: String(session.messageCount),
|
||||
});
|
||||
|
||||
@ -125,11 +125,6 @@ export async function createIssueAndSaveTask(cwd: string, task: string, piece?:
|
||||
}
|
||||
|
||||
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
|
||||
const useWorktree = await confirm('Create worktree?', true);
|
||||
if (!useWorktree) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const customPath = await promptInput('Worktree path (Enter for auto)');
|
||||
const worktree: boolean | string = customPath || true;
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
selectSummaryAction,
|
||||
type PieceContext,
|
||||
} from '../../interactive/interactive.js';
|
||||
import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js';
|
||||
import { loadTemplate } from '../../../shared/prompts/index.js';
|
||||
import { getLabelObject } from '../../../shared/i18n/index.js';
|
||||
import { loadGlobalConfig } from '../../../infra/config/index.js';
|
||||
@ -68,6 +69,7 @@ export async function runInstructMode(
|
||||
branchContext: string,
|
||||
branchName: string,
|
||||
pieceContext?: PieceContext,
|
||||
runSessionContext?: RunSessionContext,
|
||||
): Promise<InstructModeResult> {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const lang = resolveLanguage(globalConfig.language);
|
||||
@ -83,10 +85,17 @@ export async function runInstructMode(
|
||||
|
||||
const ui = getLabelObject<InstructUIText>('instruct.ui', ctx.lang);
|
||||
|
||||
const hasRunSession = !!runSessionContext;
|
||||
const runPromptVars = hasRunSession
|
||||
? formatRunSessionForPrompt(runSessionContext)
|
||||
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
|
||||
|
||||
const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
|
||||
hasPiecePreview: false,
|
||||
pieceStructure: '',
|
||||
movementDetails: '',
|
||||
hasRunSession,
|
||||
...runPromptVars,
|
||||
});
|
||||
|
||||
const branchIntro = ctx.lang === 'ja'
|
||||
|
||||
43
src/features/tasks/list/requeueHelpers.ts
Normal file
43
src/features/tasks/list/requeueHelpers.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { confirm } from '../../../shared/prompt/index.js';
|
||||
import { getLabel } from '../../../shared/i18n/index.js';
|
||||
import {
|
||||
selectRun,
|
||||
loadRunSessionContext,
|
||||
listRecentRuns,
|
||||
type RunSessionContext,
|
||||
} from '../../interactive/index.js';
|
||||
|
||||
export function appendRetryNote(existing: string | undefined, additional: string): string {
|
||||
const trimmedAdditional = additional.trim();
|
||||
if (trimmedAdditional === '') {
|
||||
throw new Error('Additional instruction is empty.');
|
||||
}
|
||||
if (!existing || existing.trim() === '') {
|
||||
return trimmedAdditional;
|
||||
}
|
||||
return `${existing}\n\n${trimmedAdditional}`;
|
||||
}
|
||||
|
||||
export async function selectRunSessionContext(
|
||||
projectDir: string,
|
||||
lang: 'en' | 'ja',
|
||||
): Promise<RunSessionContext | undefined> {
|
||||
if (listRecentRuns(projectDir).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const shouldReferenceRun = await confirm(
|
||||
getLabel('interactive.runSelector.confirm', lang),
|
||||
false,
|
||||
);
|
||||
if (!shouldReferenceRun) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedSlug = await selectRun(projectDir, lang);
|
||||
if (!selectedSlug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return loadRunSessionContext(projectDir, selectedSlug);
|
||||
}
|
||||
@ -65,7 +65,7 @@ export async function showDiffAndPromptActionForTask(
|
||||
`Action for ${branch}:`,
|
||||
[
|
||||
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
|
||||
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' },
|
||||
{ label: 'Instruct', value: 'instruct', description: 'Craft additional instructions and requeue this task' },
|
||||
{ label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
|
||||
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
|
||||
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
createTempCloneForBranch,
|
||||
removeClone,
|
||||
removeCloneMeta,
|
||||
TaskRunner,
|
||||
} from '../../../infra/task/index.js';
|
||||
import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js';
|
||||
import { info, success, error as logError } from '../../../shared/ui/index.js';
|
||||
import { info, success } from '../../../shared/ui/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { executeTask } from '../execute/taskExecution.js';
|
||||
import type { TaskExecutionOptions } from '../execute/types.js';
|
||||
import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from '../execute/taskResultHandler.js';
|
||||
import { runInstructMode } from './instructMode.js';
|
||||
import { saveTaskFile } from '../add/index.js';
|
||||
import { selectPiece } from '../../pieceSelection/index.js';
|
||||
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
|
||||
import type { PieceContext } from '../../interactive/interactive.js';
|
||||
import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js';
|
||||
import { detectDefaultBranch, autoCommitAndPush } from '../../../infra/task/index.js';
|
||||
import { resolveLanguage } from '../../interactive/index.js';
|
||||
import { type BranchActionTarget, resolveTargetBranch } from './taskActionTarget.js';
|
||||
import { detectDefaultBranch } from '../../../infra/task/index.js';
|
||||
import { appendRetryNote, selectRunSessionContext } from './requeueHelpers.js';
|
||||
|
||||
const log = createLogger('list-tasks');
|
||||
|
||||
@ -70,10 +66,13 @@ function getBranchContext(projectDir: string, branch: string): string {
|
||||
export async function instructBranch(
|
||||
projectDir: string,
|
||||
target: BranchActionTarget,
|
||||
options?: TaskExecutionOptions,
|
||||
_options?: TaskExecutionOptions,
|
||||
): Promise<boolean> {
|
||||
if (!('kind' in target)) {
|
||||
throw new Error('Instruct requeue requires a task target.');
|
||||
}
|
||||
|
||||
const branch = resolveTargetBranch(target);
|
||||
const worktreePath = resolveTargetWorktreePath(target);
|
||||
|
||||
const selectedPiece = await selectPiece(projectDir);
|
||||
if (!selectedPiece) {
|
||||
@ -90,96 +89,32 @@ export async function instructBranch(
|
||||
movementPreviews: pieceDesc.movementPreviews,
|
||||
};
|
||||
|
||||
const lang = resolveLanguage(globalConfig.language);
|
||||
const runSessionContext = await selectRunSessionContext(projectDir, lang);
|
||||
|
||||
const branchContext = getBranchContext(projectDir, branch);
|
||||
const result = await runInstructMode(projectDir, branchContext, branch, pieceContext);
|
||||
const result = await runInstructMode(projectDir, branchContext, branch, pieceContext, runSessionContext);
|
||||
|
||||
const requeueWithInstruction = async (instruction: string): Promise<boolean> => {
|
||||
const runner = new TaskRunner(projectDir);
|
||||
const retryNote = appendRetryNote(target.data?.retry_note, instruction);
|
||||
runner.requeueTask(target.name, ['completed', 'failed'], undefined, retryNote);
|
||||
success(`Task requeued with additional instructions: ${target.name}`);
|
||||
info(` Branch: ${branch}`);
|
||||
log.info('Requeued task from instruct mode', {
|
||||
name: target.name,
|
||||
branch,
|
||||
piece: selectedPiece,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
return dispatchConversationAction(result, {
|
||||
cancel: () => {
|
||||
info('Cancelled');
|
||||
return false;
|
||||
},
|
||||
save_task: async ({ task }) => {
|
||||
const created = await saveTaskFile(projectDir, task, {
|
||||
piece: selectedPiece,
|
||||
worktree: true,
|
||||
branch,
|
||||
autoPr: false,
|
||||
});
|
||||
success(`Task saved: ${created.taskName}`);
|
||||
info(` Branch: ${branch}`);
|
||||
log.info('Task saved from instruct mode', { branch, piece: selectedPiece });
|
||||
return true;
|
||||
},
|
||||
execute: async ({ task }) => {
|
||||
log.info('Instructing branch via temp clone', { branch, piece: selectedPiece });
|
||||
info(`Running instruction on ${branch}...`);
|
||||
|
||||
const clone = createTempCloneForBranch(projectDir, branch);
|
||||
const fullInstruction = branchContext
|
||||
? `${branchContext}## 追加指示\n${task}`
|
||||
: task;
|
||||
|
||||
const runner = new TaskRunner(projectDir);
|
||||
const taskRecord = runner.addTask(fullInstruction, {
|
||||
piece: selectedPiece,
|
||||
worktree: true,
|
||||
branch,
|
||||
auto_pr: false,
|
||||
...(worktreePath ? { worktree_path: worktreePath } : {}),
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const taskSuccess = await executeTask({
|
||||
task: fullInstruction,
|
||||
cwd: clone.path,
|
||||
pieceIdentifier: selectedPiece,
|
||||
projectCwd: projectDir,
|
||||
agentOverrides: options,
|
||||
});
|
||||
|
||||
const completedAt = new Date().toISOString();
|
||||
const taskResult = buildBooleanTaskResult({
|
||||
task: taskRecord,
|
||||
taskSuccess,
|
||||
successResponse: 'Instruction completed',
|
||||
failureResponse: 'Instruction failed',
|
||||
startedAt,
|
||||
completedAt,
|
||||
branch,
|
||||
...(worktreePath ? { worktreePath } : {}),
|
||||
});
|
||||
persistTaskResult(runner, taskResult, { emitStatusLog: false });
|
||||
|
||||
if (taskSuccess) {
|
||||
const commitResult = autoCommitAndPush(clone.path, task, projectDir);
|
||||
if (commitResult.success && commitResult.commitHash) {
|
||||
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||
} else if (!commitResult.success) {
|
||||
logError(`Auto-commit failed: ${commitResult.message}`);
|
||||
}
|
||||
|
||||
success(`Instruction completed on ${branch}`);
|
||||
log.info('Instruction completed', { branch });
|
||||
} else {
|
||||
logError(`Instruction failed on ${branch}`);
|
||||
log.error('Instruction failed', { branch });
|
||||
}
|
||||
|
||||
return taskSuccess;
|
||||
} catch (err) {
|
||||
const completedAt = new Date().toISOString();
|
||||
persistTaskError(runner, taskRecord, startedAt, completedAt, err, {
|
||||
emitStatusLog: false,
|
||||
responsePrefix: 'Instruction failed: ',
|
||||
});
|
||||
logError(`Instruction failed on ${branch}`);
|
||||
log.error('Instruction crashed', { branch, error: getErrorMessage(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
removeClone(clone.path);
|
||||
removeCloneMeta(projectDir, branch);
|
||||
}
|
||||
},
|
||||
execute: async ({ task }) => requeueWithInstruction(task),
|
||||
save_task: async ({ task }) => requeueWithInstruction(task),
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,11 +7,16 @@
|
||||
|
||||
import type { TaskListItem } from '../../../infra/task/index.js';
|
||||
import { TaskRunner } from '../../../infra/task/index.js';
|
||||
import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js';
|
||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||
import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js';
|
||||
import { selectOption } from '../../../shared/prompt/index.js';
|
||||
import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import type { PieceConfig } from '../../../core/models/index.js';
|
||||
import { runInstructMode } from './instructMode.js';
|
||||
import type { PieceContext } from '../../interactive/interactive.js';
|
||||
import { resolveLanguage, selectRun, loadRunSessionContext, listRecentRuns, type RunSessionContext } from '../../interactive/index.js';
|
||||
import { getLabel } from '../../../shared/i18n/index.js';
|
||||
import { confirm } from '../../../shared/prompt/index.js';
|
||||
|
||||
const log = createLogger('list-tasks');
|
||||
|
||||
@ -53,6 +58,38 @@ async function selectStartMovement(
|
||||
return await selectOption<string>('Start from movement:', options);
|
||||
}
|
||||
|
||||
function appendRetryNote(existing: string | undefined, additional: string): string {
|
||||
const trimmedAdditional = additional.trim();
|
||||
if (trimmedAdditional === '') {
|
||||
throw new Error('Additional instruction is empty.');
|
||||
}
|
||||
if (!existing || existing.trim() === '') {
|
||||
return trimmedAdditional;
|
||||
}
|
||||
return `${existing}\n\n${trimmedAdditional}`;
|
||||
}
|
||||
|
||||
function buildRetryBranchContext(task: TaskListItem): string {
|
||||
const lines = [
|
||||
'## 失敗情報',
|
||||
`- タスク名: ${task.name}`,
|
||||
`- 失敗日時: ${task.createdAt}`,
|
||||
];
|
||||
if (task.failure?.movement) {
|
||||
lines.push(`- 失敗ムーブメント: ${task.failure.movement}`);
|
||||
}
|
||||
if (task.failure?.error) {
|
||||
lines.push(`- エラー: ${task.failure.error}`);
|
||||
}
|
||||
if (task.failure?.last_message) {
|
||||
lines.push(`- 最終メッセージ: ${task.failure.last_message}`);
|
||||
}
|
||||
if (task.data?.retry_note) {
|
||||
lines.push('', '## 既存の再投入メモ', task.data.retry_note);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed task.
|
||||
*
|
||||
@ -62,9 +99,14 @@ export async function retryFailedTask(
|
||||
task: TaskListItem,
|
||||
projectDir: string,
|
||||
): Promise<boolean> {
|
||||
if (task.kind !== 'failed') {
|
||||
throw new Error(`retryFailedTask requires failed task. received: ${task.kind}`);
|
||||
}
|
||||
|
||||
displayFailureInfo(task);
|
||||
|
||||
const pieceName = task.data?.piece ?? loadGlobalConfig().defaultPiece ?? 'default';
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const pieceName = task.data?.piece ?? globalConfig.defaultPiece ?? 'default';
|
||||
const pieceConfig = loadPieceByIdentifier(pieceName, projectDir);
|
||||
|
||||
if (!pieceConfig) {
|
||||
@ -77,32 +119,65 @@ export async function retryFailedTask(
|
||||
return false;
|
||||
}
|
||||
|
||||
const pieceDesc = getPieceDescription(pieceName, projectDir, globalConfig.interactivePreviewMovements);
|
||||
const pieceContext: PieceContext = {
|
||||
name: pieceDesc.name,
|
||||
description: pieceDesc.description,
|
||||
pieceStructure: pieceDesc.pieceStructure,
|
||||
movementPreviews: pieceDesc.movementPreviews,
|
||||
};
|
||||
|
||||
const lang = resolveLanguage(globalConfig.language);
|
||||
let runSessionContext: RunSessionContext | undefined;
|
||||
const hasRuns = listRecentRuns(projectDir).length > 0;
|
||||
if (hasRuns) {
|
||||
const shouldReferenceRun = await confirm(
|
||||
getLabel('interactive.runSelector.confirm', lang),
|
||||
false,
|
||||
);
|
||||
if (shouldReferenceRun) {
|
||||
const selectedSlug = await selectRun(projectDir, lang);
|
||||
if (selectedSlug) {
|
||||
runSessionContext = loadRunSessionContext(projectDir, selectedSlug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blankLine();
|
||||
const retryNote = await promptInput('Retry note (optional, press Enter to skip):');
|
||||
const trimmedNote = retryNote?.trim();
|
||||
const branchContext = buildRetryBranchContext(task);
|
||||
const branchName = task.branch ?? task.name;
|
||||
const instructResult = await runInstructMode(
|
||||
projectDir,
|
||||
branchContext,
|
||||
branchName,
|
||||
pieceContext,
|
||||
runSessionContext,
|
||||
);
|
||||
if (instructResult.action !== 'execute') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const runner = new TaskRunner(projectDir);
|
||||
const startMovement = selectedMovement !== pieceConfig.initialMovement
|
||||
? selectedMovement
|
||||
: undefined;
|
||||
const retryNote = appendRetryNote(task.data?.retry_note, instructResult.task);
|
||||
|
||||
runner.requeueFailedTask(task.name, startMovement, trimmedNote || undefined);
|
||||
runner.requeueTask(task.name, ['failed'], startMovement, retryNote);
|
||||
|
||||
success(`Task requeued: ${task.name}`);
|
||||
if (startMovement) {
|
||||
info(` Will start from: ${startMovement}`);
|
||||
}
|
||||
if (trimmedNote) {
|
||||
info(` Retry note: ${trimmedNote}`);
|
||||
}
|
||||
info(' Retry note: updated');
|
||||
info(` File: ${task.filePath}`);
|
||||
|
||||
log.info('Requeued failed task', {
|
||||
name: task.name,
|
||||
tasksFile: task.filePath,
|
||||
startMovement,
|
||||
retryNote: trimmedNote,
|
||||
retryNote,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@ -58,3 +58,4 @@ export { stageAndCommit, getCurrentBranch } from './git.js';
|
||||
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
|
||||
export { summarizeTaskName } from './summarize.js';
|
||||
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';
|
||||
export { isStaleRunningTask } from './process.js';
|
||||
|
||||
@ -121,6 +121,9 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
|
||||
content: firstLine(resolveTaskContent(projectDir, task)),
|
||||
branch: task.branch,
|
||||
worktreePath: task.worktree_path,
|
||||
startedAt: task.started_at ?? undefined,
|
||||
completedAt: task.completed_at ?? undefined,
|
||||
ownerPid: task.owner_pid ?? undefined,
|
||||
data: toTaskData(projectDir, task),
|
||||
};
|
||||
}
|
||||
|
||||
23
src/infra/task/process.ts
Normal file
23
src/infra/task/process.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Shared process-level helpers.
|
||||
*/
|
||||
|
||||
export function isProcessAlive(ownerPid: number): boolean {
|
||||
try {
|
||||
process.kill(ownerPid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (nodeErr.code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function isStaleRunningTask(ownerPid: number | undefined): boolean {
|
||||
return ownerPid == null || !isProcessAlive(ownerPid);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { TaskFileData } from './schema.js';
|
||||
import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
|
||||
import type { TaskStatus } from './schema.js';
|
||||
import { TaskStore } from './store.js';
|
||||
import { TaskLifecycleService } from './taskLifecycleService.js';
|
||||
import { TaskQueryService } from './taskQueryService.js';
|
||||
@ -73,6 +74,15 @@ export class TaskRunner {
|
||||
return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote);
|
||||
}
|
||||
|
||||
requeueTask(
|
||||
taskRef: string,
|
||||
allowedStatuses: readonly TaskStatus[],
|
||||
startMovement?: string,
|
||||
retryNote?: string,
|
||||
): string {
|
||||
return this.lifecycle.requeueTask(taskRef, allowedStatuses, startMovement, retryNote);
|
||||
}
|
||||
|
||||
deletePendingTask(name: string): void {
|
||||
this.deletion.deletePendingTask(name);
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import type { TaskInfo, TaskResult } from './types.js';
|
||||
import { toTaskInfo } from './mapper.js';
|
||||
import { TaskStore } from './store.js';
|
||||
import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
|
||||
import { isStaleRunningTask } from './process.js';
|
||||
import type { TaskStatus } from './schema.js';
|
||||
|
||||
export class TaskLifecycleService {
|
||||
constructor(
|
||||
@ -151,12 +153,25 @@ export class TaskLifecycleService {
|
||||
}
|
||||
|
||||
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
|
||||
return this.requeueTask(taskRef, ['failed'], startMovement, retryNote);
|
||||
}
|
||||
|
||||
requeueTask(
|
||||
taskRef: string,
|
||||
allowedStatuses: readonly TaskStatus[],
|
||||
startMovement?: string,
|
||||
retryNote?: string,
|
||||
): string {
|
||||
const taskName = this.normalizeTaskRef(taskRef);
|
||||
|
||||
this.store.update((current) => {
|
||||
const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed');
|
||||
const index = current.tasks.findIndex((task) => (
|
||||
task.name === taskName
|
||||
&& allowedStatuses.includes(task.status)
|
||||
));
|
||||
if (index === -1) {
|
||||
throw new Error(`Failed task not found: ${taskRef}`);
|
||||
const expectedStatuses = allowedStatuses.join(', ');
|
||||
throw new Error(`Task not found for requeue: ${taskRef} (expected status: ${expectedStatuses})`);
|
||||
}
|
||||
|
||||
const target = current.tasks[index]!;
|
||||
@ -197,26 +212,7 @@ export class TaskLifecycleService {
|
||||
}
|
||||
|
||||
private isRunningTaskStale(task: TaskRecord): boolean {
|
||||
if (task.owner_pid == null) {
|
||||
return true;
|
||||
}
|
||||
return !this.isProcessAlive(task.owner_pid);
|
||||
}
|
||||
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (nodeErr.code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return isStaleRunningTask(task.owner_pid ?? undefined);
|
||||
}
|
||||
|
||||
private generateTaskName(content: string, existingNames: string[]): string {
|
||||
|
||||
@ -85,4 +85,7 @@ export interface TaskListItem {
|
||||
worktreePath?: string;
|
||||
data?: TaskFileData;
|
||||
failure?: TaskFailure;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
ownerPid?: number;
|
||||
}
|
||||
|
||||
@ -35,6 +35,10 @@ interactive:
|
||||
quietDescription: "Generate instructions without asking questions"
|
||||
passthrough: "Passthrough"
|
||||
passthroughDescription: "Pass your input directly as task text"
|
||||
runSelector:
|
||||
confirm: "Reference a previous run's results?"
|
||||
prompt: "Select a run to reference:"
|
||||
noRuns: "No previous runs found."
|
||||
sessionSelector:
|
||||
confirm: "Choose a previous session?"
|
||||
prompt: "Resume from a recent session?"
|
||||
|
||||
@ -35,6 +35,10 @@ interactive:
|
||||
quietDescription: "質問なしでベストエフォートの指示書を生成"
|
||||
passthrough: "パススルー"
|
||||
passthroughDescription: "入力をそのままタスクとして渡す"
|
||||
runSelector:
|
||||
confirm: "前回の実行結果を参照しますか?"
|
||||
prompt: "参照するrunを選択してください:"
|
||||
noRuns: "前回のrunが見つかりませんでした。"
|
||||
sessionSelector:
|
||||
confirm: "前回セッションを選択しますか?"
|
||||
prompt: "直近のセッションを引き継ぎますか?"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<!--
|
||||
template: score_interactive_system_prompt
|
||||
role: system prompt for interactive planning mode
|
||||
vars: hasPiecePreview, pieceStructure, movementDetails
|
||||
vars: hasPiecePreview, pieceStructure, movementDetails, hasRunSession, runTask, runPiece, runStatus, runMovementLogs, runReports
|
||||
caller: features/interactive
|
||||
-->
|
||||
# Interactive Mode Assistant
|
||||
@ -43,3 +43,27 @@ The following agents will process the task sequentially. Understand each agent's
|
||||
- Clearly include information that agents cannot resolve on their own (user intent, priorities, constraints, etc.)
|
||||
- Delegate codebase investigation, implementation details, and dependency analysis to the agents
|
||||
{{/if}}
|
||||
{{#if hasRunSession}}
|
||||
|
||||
## Previous Run Reference
|
||||
|
||||
The user has selected a previous run for reference. Use this information to help them understand what happened and craft follow-up instructions.
|
||||
|
||||
**Task:** {{runTask}}
|
||||
**Piece:** {{runPiece}}
|
||||
**Status:** {{runStatus}}
|
||||
|
||||
### Movement Logs
|
||||
|
||||
{{runMovementLogs}}
|
||||
|
||||
### Reports
|
||||
|
||||
{{runReports}}
|
||||
|
||||
### Guidance
|
||||
|
||||
- Reference specific movement results when discussing issues or improvements
|
||||
- Help the user identify what went wrong or what needs additional work
|
||||
- Suggest concrete follow-up instructions based on the run results
|
||||
{{/if}}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<!--
|
||||
template: score_summary_system_prompt
|
||||
role: system prompt for conversation-to-task summarization
|
||||
vars: pieceInfo, pieceName, pieceDescription, movementDetails, conversation
|
||||
vars: pieceInfo, pieceName, pieceDescription, movementDetails, taskHistory, conversation
|
||||
caller: features/interactive
|
||||
-->
|
||||
You are a task summarizer. Convert the conversation into a concrete task instruction for the planning step.
|
||||
@ -31,3 +31,7 @@ Create the instruction in the format expected by this piece.
|
||||
|
||||
{{conversation}}
|
||||
{{/if}}
|
||||
|
||||
{{#if taskHistory}}
|
||||
{{taskHistory}}
|
||||
{{/if}}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<!--
|
||||
template: score_interactive_system_prompt
|
||||
role: system prompt for interactive planning mode
|
||||
vars: hasPiecePreview, pieceStructure, movementDetails
|
||||
vars: hasPiecePreview, pieceStructure, movementDetails, hasRunSession, runTask, runPiece, runStatus, runMovementLogs, runReports
|
||||
caller: features/interactive
|
||||
-->
|
||||
# 対話モードアシスタント
|
||||
@ -43,3 +43,27 @@ TAKTの対話モードを担当し、ユーザーと会話してピース実行
|
||||
- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください
|
||||
- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください
|
||||
{{/if}}
|
||||
{{#if hasRunSession}}
|
||||
|
||||
## 前回実行の参照
|
||||
|
||||
ユーザーが前回の実行結果を参照として選択しました。この情報を使って、何が起きたかを理解し、追加指示の作成を支援してください。
|
||||
|
||||
**タスク:** {{runTask}}
|
||||
**ピース:** {{runPiece}}
|
||||
**ステータス:** {{runStatus}}
|
||||
|
||||
### ムーブメントログ
|
||||
|
||||
{{runMovementLogs}}
|
||||
|
||||
### レポート
|
||||
|
||||
{{runReports}}
|
||||
|
||||
### ガイダンス
|
||||
|
||||
- 問題点や改善点を議論する際は、具体的なムーブメントの結果を参照してください
|
||||
- 何がうまくいかなかったか、追加作業が必要な箇所をユーザーが特定できるよう支援してください
|
||||
- 実行結果に基づいて、具体的なフォローアップ指示を提案してください
|
||||
{{/if}}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<!--
|
||||
template: score_summary_system_prompt
|
||||
role: system prompt for conversation-to-task summarization
|
||||
vars: pieceInfo, pieceName, pieceDescription, movementDetails, conversation
|
||||
vars: pieceInfo, pieceName, pieceDescription, movementDetails, taskHistory, conversation
|
||||
caller: features/interactive
|
||||
-->
|
||||
あなたはTAKTの対話モードを担当しています。これまでの会話内容を、ピース実行用の具体的なタスク指示書に変換してください。
|
||||
@ -38,3 +38,7 @@
|
||||
|
||||
{{conversation}}
|
||||
{{/if}}
|
||||
|
||||
{{#if taskHistory}}
|
||||
{{taskHistory}}
|
||||
{{/if}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user