/** * Tests for issue resolution in routing module. * * Verifies that issue references (--issue N or #N positional arg) * are resolved before interactive mode and passed to selectAndExecuteTask * via selectOptions.issues. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/prompt/index.js', () => ({ confirm: vi.fn(() => true), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ info: vi.fn(), debug: vi.fn(), error: vi.fn(), }), })); vi.mock('../infra/github/issue.js', () => ({ parseIssueNumbers: vi.fn(() => []), checkGhCli: vi.fn(), fetchIssue: vi.fn(), formatIssueAsTask: vi.fn(), isIssueReference: vi.fn(), resolveIssueTask: vi.fn(), createIssue: vi.fn(), })); vi.mock('../features/tasks/index.js', () => ({ selectAndExecuteTask: vi.fn(), determinePiece: vi.fn(), saveTaskFromInteractive: vi.fn(), createIssueFromTask: vi.fn(), })); vi.mock('../features/pipeline/index.js', () => ({ executePipeline: vi.fn(), })); vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), 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 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', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), resolveConfigValue: vi.fn((_: string, key: string) => (key === 'piece' ? 'default' : false)), resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })), })); vi.mock('../shared/constants.js', () => ({ DEFAULT_PIECE_NAME: 'default', })); const mockOpts: Record = {}; vi.mock('../app/cli/program.js', () => { const chainable = { opts: vi.fn(() => mockOpts), argument: vi.fn().mockReturnThis(), action: vi.fn().mockReturnThis(), }; return { program: chainable, resolvedCwd: '/test/cwd', pipelineMode: false, }; }); vi.mock('../app/cli/helpers.js', () => ({ resolveAgentOverrides: vi.fn(), parseCreateWorktreeOption: vi.fn(), isDirectTask: vi.fn(() => false), })); import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; import { resolveConfigValues } from '../infra/config/index.js'; import { confirm } from '../shared/prompt/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import type { GitHubIssue } from '../infra/github/types.js'; const mockCheckGhCli = vi.mocked(checkGhCli); const mockFetchIssue = vi.mocked(fetchIssue); const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockInteractiveMode = vi.mocked(interactiveMode); const mockSelectRecentSession = vi.mocked(selectRecentSession); const mockResolveConfigValues = vi.mocked(resolveConfigValues); const mockConfirm = vi.mocked(confirm); const mockIsDirectTask = vi.mocked(isDirectTask); const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); function createMockIssue(number: number): GitHubIssue { return { number, title: `Issue #${number}`, body: `Body of issue #${number}`, labels: [], comments: [], }; } beforeEach(() => { vi.clearAllMocks(); // Reset opts for (const key of Object.keys(mockOpts)) { delete mockOpts[key]; } // Default setup mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); mockConfirm.mockResolvedValue(true); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); mockTaskRunnerListAllTaskItems.mockReturnValue([]); mockIsStaleRunningTask.mockReturnValue(false); }); describe('Issue resolution in routing', () => { describe('--issue option', () => { it('should resolve issue and pass to interactive mode when --issue is specified', async () => { // Given mockOpts.issue = 131; const issue131 = createMockIssue(131); mockCheckGhCli.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); // When await executeDefaultAction(); // Then: issue should be fetched expect(mockFetchIssue).toHaveBeenCalledWith(131); // Then: interactive mode should receive the formatted issue as initial input expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), undefined, ); // Then: selectAndExecuteTask should receive issues in options expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( '/test/cwd', 'summarized task', expect.objectContaining({ issues: [issue131], }), undefined, ); }); it('should exit with error when gh CLI is unavailable for --issue', async () => { // Given mockOpts.issue = 131; mockCheckGhCli.mockReturnValue({ available: false, error: 'gh CLI is not installed', }); const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); // When / Then await expect(executeDefaultAction()).rejects.toThrow('process.exit called'); expect(mockExit).toHaveBeenCalledWith(1); expect(mockInteractiveMode).not.toHaveBeenCalled(); mockExit.mockRestore(); }); }); describe('#N positional argument', () => { it('should resolve issue reference and pass to interactive mode', async () => { // Given const issue131 = createMockIssue(131); mockIsDirectTask.mockReturnValue(true); mockCheckGhCli.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); mockParseIssueNumbers.mockReturnValue([131]); // When await executeDefaultAction('#131'); // Then: interactive mode should be entered with formatted issue expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), undefined, ); // Then: selectAndExecuteTask should receive issues expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( '/test/cwd', 'summarized task', expect.objectContaining({ issues: [issue131], }), undefined, ); }); }); describe('non-issue input', () => { it('should pass regular text input to interactive mode without issues', async () => { // When await executeDefaultAction('refactor the code'); // Then: interactive mode should receive the original text expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', 'refactor the code', expect.anything(), undefined, ); // Then: no issue fetching should occur expect(mockFetchIssue).not.toHaveBeenCalled(); // Then: selectAndExecuteTask should be called without issues const callArgs = mockSelectAndExecuteTask.mock.calls[0]; expect(callArgs?.[2]?.issues).toBeUndefined(); }); it('should enter interactive mode with no input when no args provided', async () => { // When await executeDefaultAction(); // Then: interactive mode should be entered with undefined input expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), undefined, ); // Then: no issue fetching should occur expect(mockFetchIssue).not.toHaveBeenCalled(); }); }); 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 mockOpts.issue = 131; const issue131 = createMockIssue(131); mockCheckGhCli.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131'); mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); // When await executeDefaultAction(); // Then expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); describe('create_issue action', () => { it('should create issue first, then delegate final confirmation to saveTaskFromInteractive', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); mockCreateIssueFromTask.mockReturnValue(226); // When await executeDefaultAction(); // Then: issue is created first expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); // Then: saveTaskFromInteractive receives final confirmation message expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith( '/test/cwd', 'New feature request', 'default', { issue: 226, confirmAtEndMessage: 'Add this issue to tasks?' }, ); }); it('should skip confirmation and task save when issue creation fails', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); mockCreateIssueFromTask.mockReturnValue(undefined); // When await executeDefaultAction(); // Then expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); expect(mockSaveTaskFromInteractive).not.toHaveBeenCalled(); }); it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); // When await executeDefaultAction(); // Then: selectAndExecuteTask should NOT be called expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); describe('session selection with provider=claude', () => { it('should pass selected session ID to interactiveMode when provider is claude', async () => { // Given mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); mockConfirm.mockResolvedValue(true); mockSelectRecentSession.mockResolvedValue('session-xyz'); // When await executeDefaultAction(); // Then: selectRecentSession should be called expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); // Then: interactiveMode should receive the session ID as 4th argument expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), 'session-xyz', ); expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); }); it('should not call selectRecentSession when user selects no in confirmation', async () => { // Given mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); mockConfirm.mockResolvedValue(false); // When await executeDefaultAction(); // Then expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); expect(mockSelectRecentSession).not.toHaveBeenCalled(); expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), undefined, ); }); it('should not call selectRecentSession when provider is not claude', async () => { // Given mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'openai' }); // When await executeDefaultAction(); // Then: selectRecentSession should NOT be called expect(mockSelectRecentSession).not.toHaveBeenCalled(); // Then: interactiveMode should be called with undefined session ID expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), undefined, ); }); }); 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, ); }); }); });