- retryFailedTask / instructBranch でピース名の取得元を runInfo?.piece から task.data?.piece に変更 (worktree 内に .takt/runs/ が存在しないため runInfo は常に null だった) - ~/.takt/config.yaml テンプレートに不足していた設定項目を追加 (provider, model, concurrency, analytics, pipeline, persona_providers 等)
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const {
|
|
mockExistsSync,
|
|
mockStartReExecution,
|
|
mockRequeueTask,
|
|
mockExecuteAndCompleteTask,
|
|
mockRunInstructMode,
|
|
mockDispatchConversationAction,
|
|
mockSelectPiece,
|
|
mockConfirm,
|
|
mockGetLabel,
|
|
mockResolveLanguage,
|
|
mockListRecentRuns,
|
|
mockSelectRun,
|
|
mockLoadRunSessionContext,
|
|
mockFindRunForTask,
|
|
mockFindPreviousOrderContent,
|
|
mockWarn,
|
|
mockIsPiecePath,
|
|
mockLoadAllPiecesWithSources,
|
|
} = vi.hoisted(() => ({
|
|
mockExistsSync: vi.fn(() => true),
|
|
mockStartReExecution: vi.fn(),
|
|
mockRequeueTask: vi.fn(),
|
|
mockExecuteAndCompleteTask: 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(),
|
|
mockFindRunForTask: vi.fn(() => null),
|
|
mockFindPreviousOrderContent: vi.fn(() => null),
|
|
mockWarn: vi.fn(),
|
|
mockIsPiecePath: vi.fn(() => false),
|
|
mockLoadAllPiecesWithSources: vi.fn(() => new Map<string, unknown>([
|
|
['default', {}],
|
|
['selected-piece', {}],
|
|
])),
|
|
}));
|
|
|
|
vi.mock('node:fs', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
|
}));
|
|
|
|
vi.mock('../infra/task/index.js', () => ({
|
|
detectDefaultBranch: vi.fn(() => 'main'),
|
|
TaskRunner: class {
|
|
startReExecution(...args: unknown[]) {
|
|
return mockStartReExecution(...args);
|
|
}
|
|
requeueTask(...args: unknown[]) {
|
|
return mockRequeueTask(...args);
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('../infra/config/index.js', () => ({
|
|
resolvePieceConfigValues: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })),
|
|
getPieceDescription: vi.fn(() => ({
|
|
name: 'default',
|
|
description: 'desc',
|
|
pieceStructure: [],
|
|
movementPreviews: [],
|
|
})),
|
|
isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args),
|
|
loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args),
|
|
}));
|
|
|
|
vi.mock('../features/tasks/list/instructMode.js', () => ({
|
|
runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args),
|
|
}));
|
|
|
|
vi.mock('../features/pieceSelection/index.js', () => ({
|
|
selectPiece: (...args: unknown[]) => mockSelectPiece(...args),
|
|
}));
|
|
|
|
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),
|
|
findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args),
|
|
findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args),
|
|
}));
|
|
|
|
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
|
executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args),
|
|
}));
|
|
|
|
vi.mock('../shared/ui/index.js', () => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: mockWarn,
|
|
}));
|
|
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
import { instructBranch } from '../features/tasks/list/taskActions.js';
|
|
import { error as logError } from '../shared/ui/index.js';
|
|
|
|
const mockLogError = vi.mocked(logError);
|
|
|
|
describe('instructBranch direct execution flow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockExistsSync.mockReturnValue(true);
|
|
|
|
mockSelectPiece.mockResolvedValue('default');
|
|
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
|
|
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' }));
|
|
mockConfirm.mockResolvedValue(true);
|
|
mockGetLabel.mockImplementation((key: string, _lang?: string, vars?: Record<string, string>) => {
|
|
if (key === 'interactive.runSelector.confirm') {
|
|
return "Reference a previous run's results?";
|
|
}
|
|
if (vars?.piece) {
|
|
return `Use previous piece "${vars.piece}"?`;
|
|
}
|
|
return key;
|
|
});
|
|
mockResolveLanguage.mockReturnValue('en');
|
|
mockListRecentRuns.mockReturnValue([]);
|
|
mockSelectRun.mockResolvedValue(null);
|
|
mockFindRunForTask.mockReturnValue(null);
|
|
mockFindPreviousOrderContent.mockReturnValue(null);
|
|
mockIsPiecePath.mockImplementation((piece: string) => piece.startsWith('/') || piece.startsWith('~') || piece.startsWith('./') || piece.startsWith('../') || piece.endsWith('.yaml') || piece.endsWith('.yml'));
|
|
mockLoadAllPiecesWithSources.mockReturnValue(new Map<string, unknown>([
|
|
['default', {}],
|
|
['selected-piece', {}],
|
|
]));
|
|
mockStartReExecution.mockReturnValue({
|
|
name: 'done-task',
|
|
content: 'done',
|
|
data: { task: 'done' },
|
|
});
|
|
mockExecuteAndCompleteTask.mockResolvedValue(true);
|
|
});
|
|
|
|
it('should execute directly via startReExecution instead of requeuing', async () => {
|
|
const result = await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done', retry_note: '既存ノート' },
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockStartReExecution).toHaveBeenCalledWith(
|
|
'done-task',
|
|
['completed', 'failed'],
|
|
undefined,
|
|
'既存ノート\n\n追加指示A',
|
|
);
|
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should execute with selected piece without mutating taskInfo', async () => {
|
|
mockSelectPiece.mockResolvedValue('selected-piece');
|
|
const originalTaskInfo = {
|
|
name: 'done-task',
|
|
content: 'done',
|
|
data: { task: 'done', piece: 'original-piece' },
|
|
};
|
|
mockStartReExecution.mockReturnValue(originalTaskInfo);
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0];
|
|
expect(executeArg).not.toBe(originalTaskInfo);
|
|
expect(executeArg.data).not.toBe(originalTaskInfo.data);
|
|
expect(executeArg.data.piece).toBe('selected-piece');
|
|
expect(originalTaskInfo.data.piece).toBe('original-piece');
|
|
});
|
|
|
|
it('should reuse previous piece from task data when confirmed', async () => {
|
|
mockConfirm
|
|
.mockResolvedValueOnce(true);
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done', piece: 'default' },
|
|
});
|
|
|
|
expect(mockSelectPiece).not.toHaveBeenCalled();
|
|
const [message, defaultYes] = mockConfirm.mock.calls[0] ?? [];
|
|
expect(message).toEqual(expect.stringContaining('"default"'));
|
|
expect(defaultYes ?? true).toBe(true);
|
|
});
|
|
|
|
it('should call selectPiece when previous piece reuse is declined', async () => {
|
|
mockConfirm
|
|
.mockResolvedValueOnce(false);
|
|
mockSelectPiece.mockResolvedValue('selected-piece');
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done', piece: 'default' },
|
|
});
|
|
|
|
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
|
|
expect(mockStartReExecution).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip reuse prompt when task data has no piece', async () => {
|
|
mockSelectPiece.mockResolvedValue('selected-piece');
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockConfirm).not.toHaveBeenCalled();
|
|
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
|
|
});
|
|
|
|
it('should return false when replacement piece selection is cancelled after declining reuse', async () => {
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
mockSelectPiece.mockResolvedValue(null);
|
|
|
|
const result = await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done', piece: 'default' },
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockStartReExecution).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockStartReExecution).toHaveBeenCalledWith(
|
|
'done-task',
|
|
['completed', 'failed'],
|
|
undefined,
|
|
'追加指示A',
|
|
);
|
|
});
|
|
|
|
it('should run instruct mode in existing worktree', async () => {
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockRunInstructMode).toHaveBeenCalledWith(
|
|
'/project/.takt/worktrees/done-task',
|
|
expect.any(String),
|
|
'takt/done-task',
|
|
'done-task',
|
|
'done',
|
|
'',
|
|
expect.anything(),
|
|
undefined,
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('should search runs in worktree for run session context', 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 instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockConfirm).toHaveBeenCalledWith("Reference a previous run's results?", false);
|
|
// selectRunSessionContext uses worktreePath for run data
|
|
expect(mockListRecentRuns).toHaveBeenCalledWith('/project/.takt/worktrees/done-task');
|
|
expect(mockSelectRun).toHaveBeenCalledWith('/project/.takt/worktrees/done-task', 'en');
|
|
expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project/.takt/worktrees/done-task', 'run-1');
|
|
expect(mockRunInstructMode).toHaveBeenCalledWith(
|
|
'/project/.takt/worktrees/done-task',
|
|
expect.any(String),
|
|
'takt/done-task',
|
|
'done-task',
|
|
'done',
|
|
'',
|
|
expect.anything(),
|
|
runContext,
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('should show deprecated config warning when selected run order uses legacy provider fields', async () => {
|
|
mockListRecentRuns.mockReturnValue([
|
|
{ slug: 'run-1', task: 'fix', piece: 'default', status: 'completed', startTime: '2026-02-18T00:00:00Z' },
|
|
]);
|
|
mockSelectRun.mockResolvedValue('run-1');
|
|
mockLoadRunSessionContext.mockReturnValue({
|
|
task: 'fix',
|
|
piece: 'default',
|
|
status: 'completed',
|
|
movementLogs: [],
|
|
reports: [],
|
|
});
|
|
mockFindPreviousOrderContent.mockReturnValue([
|
|
'movements:',
|
|
' - name: review',
|
|
' provider: codex',
|
|
' model: gpt-5.3',
|
|
' provider_options:',
|
|
' codex:',
|
|
' network_access: true',
|
|
].join('\n'));
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockWarn).toHaveBeenCalledTimes(1);
|
|
expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('deprecated'));
|
|
});
|
|
|
|
it('should not warn for markdown explanatory snippets without piece config body', async () => {
|
|
mockFindPreviousOrderContent.mockReturnValue([
|
|
'# Deprecated examples',
|
|
'',
|
|
'```yaml',
|
|
'provider: codex',
|
|
'model: gpt-5.3',
|
|
'provider_options:',
|
|
' codex:',
|
|
' network_access: true',
|
|
'```',
|
|
].join('\n'));
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not warn when selected run order uses provider block format', async () => {
|
|
mockFindPreviousOrderContent.mockReturnValue([
|
|
'movements:',
|
|
' - name: review',
|
|
' provider:',
|
|
' type: codex',
|
|
' model: gpt-5.3',
|
|
' network_access: true',
|
|
].join('\n'));
|
|
|
|
await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(mockWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return false when worktree does not exist', async () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
const result = await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockLogError).toHaveBeenCalledWith('Worktree directory does not exist for task: done-task');
|
|
expect(mockStartReExecution).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should requeue task via requeueTask when save_task action', async () => {
|
|
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.save_task({ task: '追加指示A' }));
|
|
|
|
const result = await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done' },
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRequeueTask).toHaveBeenCalledWith('done-task', ['completed', 'failed'], undefined, '追加指示A');
|
|
expect(mockStartReExecution).not.toHaveBeenCalled();
|
|
expect(mockExecuteAndCompleteTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should requeue task with existing retry note appended when save_task', async () => {
|
|
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.save_task({ task: '追加指示A' }));
|
|
|
|
const result = await instructBranch('/project', {
|
|
kind: 'completed',
|
|
name: 'done-task',
|
|
createdAt: '2026-02-14T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
branch: 'takt/done-task',
|
|
worktreePath: '/project/.takt/worktrees/done-task',
|
|
data: { task: 'done', retry_note: '既存ノート' },
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRequeueTask).toHaveBeenCalledWith('done-task', ['completed', 'failed'], undefined, '既存ノート\n\n追加指示A');
|
|
});
|
|
});
|