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([ ['default', {}], ['selected-piece', {}], ])), })); vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal>()), 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>()), 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) => { 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([ ['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'); }); });