diff --git a/src/__tests__/resolveTask.test.ts b/src/__tests__/resolveTask.test.ts new file mode 100644 index 0000000..ac9c736 --- /dev/null +++ b/src/__tests__/resolveTask.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for task execution resolution. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { TaskInfo } from '../infra/task/index.js'; +import { resolveTaskExecution } from '../features/tasks/execute/resolveTask.js'; + +const tempRoots = new Set(); + +afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +function createTempProjectDir(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-resolve-task-test-')); + tempRoots.add(root); + return root; +} + +function createTask(overrides: Partial): TaskInfo { + return { + filePath: '/tasks/task.yaml', + name: 'task-name', + content: 'Run task', + createdAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + data: { task: 'Run task' }, + ...overrides, + }; +} + +describe('resolveTaskExecution', () => { + it('should return defaults when task data is null', async () => { + const root = createTempProjectDir(); + const task = createTask({ data: null }); + + const result = await resolveTaskExecution(task, root, 'default'); + + expect(result).toEqual({ + execCwd: root, + execPiece: 'default', + isWorktree: false, + autoPr: false, + }); + }); + + it('should generate report context and copy issue-bearing task spec', async () => { + const root = createTempProjectDir(); + const taskDir = '.takt/tasks/issue-task-123'; + const sourceTaskDir = path.join(root, taskDir); + const sourceOrderPath = path.join(sourceTaskDir, 'order.md'); + fs.mkdirSync(sourceTaskDir, { recursive: true }); + fs.writeFileSync(sourceOrderPath, '# task instruction'); + + const task = createTask({ + taskDir, + data: { + task: 'Run issue task', + issue: 12345, + auto_pr: true, + }, + }); + + const result = await resolveTaskExecution(task, root, 'default'); + const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md'); + + expect(result).toMatchObject({ + execCwd: root, + execPiece: 'default', + isWorktree: false, + autoPr: true, + reportDirName: 'issue-task-123', + issueNumber: 12345, + taskPrompt: expect.stringContaining('Primary spec: `.takt/runs/issue-task-123/context/task/order.md`'), + }); + expect(fs.existsSync(expectedReportOrderPath)).toBe(true); + expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction'); + }); +}); diff --git a/src/__tests__/task-prefix-writer.test.ts b/src/__tests__/task-prefix-writer.test.ts index cf03865..7425498 100644 --- a/src/__tests__/task-prefix-writer.test.ts +++ b/src/__tests__/task-prefix-writer.test.ts @@ -15,6 +15,16 @@ describe('TaskPrefixWriter', () => { }); describe('constructor', () => { + it('should use issue number when provided', () => { + const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, issue: 123, writeFn }); + + writer.writeLine('Issue task'); + + expect(output).toHaveLength(1); + expect(output[0]).toContain('[#123]'); + expect(output[0]).not.toContain('[my-t]'); + }); + it('should cycle colors for different colorIndex values', () => { const writer0 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn }); const writer4 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 4, writeFn }); @@ -27,6 +37,16 @@ describe('TaskPrefixWriter', () => { expect(output[1]).toContain('\x1b[36m'); }); + it('should use display label when provided', () => { + const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, displayLabel: '#12345', writeFn }); + + writer.writeLine('Hello World'); + + expect(output).toHaveLength(1); + expect(output[0]).toContain('[#12345]'); + expect(output[0]).not.toContain('[my-t]'); + }); + it('should assign correct colors in order', () => { const writers = [0, 1, 2, 3].map( (i) => new TaskPrefixWriter({ taskName: `t${i}`, colorIndex: i, writeFn }), diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 2ce647e..27c6ada 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -1,66 +1,55 @@ /** - * Tests for resolveTaskExecution + * Tests for execute task option propagation. */ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { TaskInfo } from '../infra/task/index.js'; + +const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPostExecutionFlow } = + vi.hoisted(() => ({ + mockResolveTaskExecution: vi.fn(), + mockExecutePiece: vi.fn(), + mockLoadPieceByIdentifier: vi.fn(), + mockLoadGlobalConfig: vi.fn(), + mockLoadProjectConfig: vi.fn(), + mockBuildTaskResult: vi.fn(), + mockPersistTaskResult: vi.fn(), + mockPostExecutionFlow: vi.fn(), + })); + +vi.mock('../features/tasks/execute/resolveTask.js', () => ({ + resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args), +})); + +vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ + executePiece: (...args: unknown[]) => mockExecutePiece(...args), +})); + +vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({ + buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args), + persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args), +})); + +vi.mock('../features/tasks/execute/postExecution.js', () => ({ + postExecutionFlow: (...args: unknown[]) => mockPostExecutionFlow(...args), +})); -// Mock dependencies before importing the module under test vi.mock('../infra/config/index.js', () => ({ - loadPieceByIdentifier: vi.fn(), - isPiecePath: vi.fn(() => false), - loadGlobalConfig: vi.fn(() => ({})), + loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), + isPiecePath: () => false, + loadGlobalConfig: () => mockLoadGlobalConfig(), + loadProjectConfig: () => mockLoadProjectConfig(), })); -import { loadGlobalConfig } from '../infra/config/index.js'; -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); - -vi.mock('../infra/task/index.js', async (importOriginal) => ({ - ...(await importOriginal>()), - TaskRunner: vi.fn(), +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + info: vi.fn(), + error: vi.fn(), + status: vi.fn(), + success: vi.fn(), + blankLine: vi.fn(), })); -vi.mock('../infra/task/clone.js', async (importOriginal) => ({ - ...(await importOriginal>()), - createSharedClone: vi.fn(), - removeClone: vi.fn(), -})); - -vi.mock('../infra/task/git.js', async (importOriginal) => ({ - ...(await importOriginal>()), - getCurrentBranch: vi.fn(() => 'main'), -})); - -vi.mock('../infra/task/autoCommit.js', async (importOriginal) => ({ - ...(await importOriginal>()), - autoCommitAndPush: vi.fn(), -})); - -vi.mock('../infra/task/summarize.js', async (importOriginal) => ({ - ...(await importOriginal>()), - summarizeTaskName: vi.fn(), -})); - -vi.mock('../shared/ui/index.js', () => { - const info = vi.fn(); - return { - header: vi.fn(), - info, - error: vi.fn(), - success: vi.fn(), - status: vi.fn(), - blankLine: vi.fn(), - withProgress: vi.fn(async (start, done, operation) => { - info(start); - const result = await operation(); - info(typeof done === 'function' ? done(result) : done); - return result; - }), - }; -}); - vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ @@ -68,560 +57,81 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ debug: vi.fn(), error: vi.fn(), }), - getErrorMessage: vi.fn((e) => e.message), + getErrorMessage: vi.fn((error: unknown) => String(error)), })); -vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ - executePiece: vi.fn(), +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((key: string) => key), })); -vi.mock('../shared/context.js', () => ({ - isQuietMode: vi.fn(() => false), -})); +import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js'; -vi.mock('../shared/constants.js', () => ({ - DEFAULT_PIECE_NAME: 'default', - DEFAULT_LANGUAGE: 'en', -})); - -import { createSharedClone } from '../infra/task/clone.js'; -import { getCurrentBranch } from '../infra/task/git.js'; -import { summarizeTaskName } from '../infra/task/summarize.js'; -import { info } from '../shared/ui/index.js'; -import { resolveTaskExecution } from '../features/tasks/index.js'; -import type { TaskInfo } from '../infra/task/index.js'; - -const mockCreateSharedClone = vi.mocked(createSharedClone); -const mockGetCurrentBranch = vi.mocked(getCurrentBranch); -const mockSummarizeTaskName = vi.mocked(summarizeTaskName); -const mockInfo = vi.mocked(info); - -beforeEach(() => { - vi.clearAllMocks(); +const createTask = (name: string): TaskInfo => ({ + name, + content: `Task: ${name}`, + filePath: `/tasks/${name}.yaml`, + createdAt: '2026-02-16T00:00:00.000Z', + status: 'pending', + data: { task: `Task: ${name}` }, }); -describe('resolveTaskExecution', () => { - it('should return defaults when task has no data', async () => { - // Given: Task without structured data - const task: TaskInfo = { - name: 'simple-task', - content: 'Simple task content', - filePath: '/tasks/simple-task.yaml', - createdAt: '2026-02-09T00:00:00.000Z', - status: 'pending', - data: null, - }; +describe('executeAndCompleteTask', () => { + beforeEach(() => { + vi.clearAllMocks(); - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result).toEqual({ + mockLoadPieceByIdentifier.mockReturnValue({ + name: 'default', + movements: [], + }); + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + provider: 'claude', + personaProviders: {}, + providerProfiles: {}, + }); + mockLoadProjectConfig.mockReturnValue({ + provider: 'claude', + providerProfiles: {}, + }); + mockBuildTaskResult.mockReturnValue({ success: true }); + mockResolveTaskExecution.mockResolvedValue({ execCwd: '/project', execPiece: 'default', isWorktree: false, autoPr: false, - }); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - expect(mockCreateSharedClone).not.toHaveBeenCalled(); - }); - - it('should return defaults when data has no worktree option', async () => { - // Given: Task with data but no worktree - const task: TaskInfo = { - name: 'task-with-data', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.isWorktree).toBe(false); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - }); - - it('should create shared clone with AI-summarized slug when worktree option is true', async () => { - // Given: Task with worktree option - const task: TaskInfo = { - name: 'japanese-task', - content: '認証機能を追加する', - filePath: '/tasks/japanese-task.yaml', - data: { - task: '認証機能を追加する', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('add-auth'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../20260128T0504-add-auth', - branch: 'takt/20260128T0504-add-auth', - }); - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' }); - expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { - worktree: true, + taskPrompt: undefined, + reportDirName: undefined, branch: undefined, - taskSlug: 'add-auth', - }); - expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project'); - expect(result).toEqual({ - execCwd: '/project/../20260128T0504-add-auth', - execPiece: 'default', - isWorktree: true, - autoPr: false, - branch: 'takt/20260128T0504-add-auth', - worktreePath: '/project/../20260128T0504-add-auth', - baseBranch: 'main', + worktreePath: undefined, + baseBranch: undefined, + startMovement: undefined, + retryNote: undefined, + issueNumber: undefined, }); + mockExecutePiece.mockResolvedValue({ success: true }); }); - it('should display generating message before AI call', async () => { - // Given: Task with worktree - const task: TaskInfo = { - name: 'test-task', - content: 'Test task', - filePath: '/tasks/test.yaml', - data: { - task: 'Test task', - worktree: true, - }, - }; + it('should pass taskDisplayLabel from parallel options into executePiece', async () => { + // Given: Parallel execution passes an issue-style taskDisplayLabel. + const task = createTask('task-with-issue'); + const taskDisplayLabel = '#12345'; + const abortController = new AbortController(); - mockSummarizeTaskName.mockResolvedValue('test-task'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../test-task', - branch: 'takt/test-task', + // When + await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, { + abortSignal: abortController.signal, + taskPrefix: taskDisplayLabel, + taskColorIndex: 0, + taskDisplayLabel, }); - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); - expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task'); - }); - - it('should use task content (not name) for AI summarization', async () => { - // Given: Task where name differs from content - const task: TaskInfo = { - name: 'old-file-name', - content: 'New feature implementation details', - filePath: '/tasks/old-file-name.yaml', - data: { - task: 'New feature implementation details', - worktree: true, - }, + // Then: executePiece receives the propagated display label. + expect(mockExecutePiece).toHaveBeenCalledTimes(1); + const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as { + taskDisplayLabel?: string; + taskPrefix?: string; }; - - mockSummarizeTaskName.mockResolvedValue('new-feature'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../new-feature', - branch: 'takt/new-feature', - }); - - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then: Should use content, not file name - expect(mockSummarizeTaskName).toHaveBeenCalledWith('New feature implementation details', { cwd: '/project' }); - }); - - it('should use piece override from task data', async () => { - // Given: Task with piece override - const task: TaskInfo = { - name: 'task-with-piece', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - piece: 'custom-piece', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.execPiece).toBe('custom-piece'); - }); - - it('should pass branch option to createSharedClone when specified', async () => { - // Given: Task with custom branch - const task: TaskInfo = { - name: 'task-with-branch', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - worktree: true, - branch: 'feature/custom-branch', - }, - }; - - mockSummarizeTaskName.mockResolvedValue('custom-task'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../custom-task', - branch: 'feature/custom-branch', - }); - - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { - worktree: true, - branch: 'feature/custom-branch', - taskSlug: 'custom-task', - }); - }); - - it('should display clone creation info', async () => { - // Given: Task with worktree - const task: TaskInfo = { - name: 'info-task', - content: 'Info task', - filePath: '/tasks/info.yaml', - data: { - task: 'Info task', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('info-task'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../20260128-info-task', - branch: 'takt/20260128-info-task', - }); - - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockInfo).toHaveBeenCalledWith( - 'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)' - ); - }); - - it('should return autoPr from task YAML when specified', async () => { - // Given: Task with auto_pr option - const task: TaskInfo = { - name: 'task-with-auto-pr', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: true, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(true); - }); - - it('should return autoPr: false from task YAML when specified as false', async () => { - // Given: Task with auto_pr: false - const task: TaskInfo = { - name: 'task-no-auto-pr', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: false, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should fall back to global config autoPr when task YAML does not specify', async () => { - // Given: Task without auto_pr, global config has autoPr - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - autoPr: true, - }); - - const task: TaskInfo = { - name: 'task-no-auto-pr-setting', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(true); - }); - - it('should return false autoPr when neither task nor config specifies', async () => { - // Given: Neither task nor config has autoPr - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - }); - - const task: TaskInfo = { - name: 'task-default', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should prioritize task YAML auto_pr over global config', async () => { - // Given: Task has auto_pr: false, global config has autoPr: true - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - autoPr: true, - }); - - const task: TaskInfo = { - name: 'task-override', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: false, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should capture baseBranch from getCurrentBranch when worktree is used', async () => { - // Given: Task with worktree, on 'develop' branch - mockGetCurrentBranch.mockReturnValue('develop'); - const task: TaskInfo = { - name: 'task-on-develop', - content: 'Task on develop branch', - filePath: '/tasks/task.yaml', - data: { - task: 'Task on develop branch', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('task-develop'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../task-develop', - branch: 'takt/task-develop', - }); - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project'); - expect(result.baseBranch).toBe('develop'); - }); - - it('should not set baseBranch when worktree is not used', async () => { - // Given: Task without worktree - const task: TaskInfo = { - name: 'task-no-worktree', - content: 'Task without worktree', - filePath: '/tasks/task.yaml', - data: { - task: 'Task without worktree', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockGetCurrentBranch).not.toHaveBeenCalled(); - expect(result.baseBranch).toBeUndefined(); - }); - - it('should return issueNumber from task data when specified', async () => { - // Given: Task with issue number - const task: TaskInfo = { - name: 'task-with-issue', - content: 'Fix authentication bug', - filePath: '/tasks/task.yaml', - data: { - task: 'Fix authentication bug', - issue: 131, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.issueNumber).toBe(131); - }); - - it('should return undefined issueNumber when task data has no issue', async () => { - // Given: Task without issue - const task: TaskInfo = { - name: 'task-no-issue', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.issueNumber).toBeUndefined(); - }); - - it('should not start clone creation when abortSignal is already aborted', async () => { - // Given: Worktree task with pre-aborted signal - const task: TaskInfo = { - name: 'aborted-before-clone', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - worktree: true, - }, - }; - const controller = new AbortController(); - controller.abort(); - - // When / Then - await expect(resolveTaskExecution(task, '/project', 'default', controller.signal)).rejects.toThrow('Task execution aborted'); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - expect(mockCreateSharedClone).not.toHaveBeenCalled(); - }); - - it('should stage task_dir spec into run context and return reportDirName', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-normal-')); - const projectDir = path.join(tmpRoot, 'project'); - fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); - const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); - fs.writeFileSync(sourceOrder, '# normal task spec\n', 'utf-8'); - - const task: TaskInfo = { - name: 'task-with-dir', - content: 'Task content', - taskDir: '.takt/tasks/20260201-015714-foptng', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - const result = await resolveTaskExecution(task, projectDir, 'default'); - - expect(result.reportDirName).toBe('20260201-015714-foptng'); - expect(result.execCwd).toBe(projectDir); - const stagedOrder = path.join(projectDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); - expect(fs.existsSync(stagedOrder)).toBe(true); - expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('normal task spec'); - expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); - expect(result.taskPrompt).not.toContain(projectDir); - }); - - it('should throw when taskDir format is invalid', async () => { - const task: TaskInfo = { - name: 'task-with-invalid-dir', - content: 'Task content', - taskDir: '.takt/reports/20260201-015714-foptng', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( - 'Invalid task_dir format: .takt/reports/20260201-015714-foptng', - ); - }); - - it('should throw when taskDir contains parent directory segment', async () => { - const task: TaskInfo = { - name: 'task-with-parent-dir', - content: 'Task content', - taskDir: '.takt/tasks/..', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( - 'Invalid task_dir format: .takt/tasks/..', - ); - }); - - it('should stage task_dir spec into worktree run context and return run-scoped task prompt', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-')); - const projectDir = path.join(tmpRoot, 'project'); - const cloneDir = path.join(tmpRoot, 'clone'); - fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); - fs.mkdirSync(cloneDir, { recursive: true }); - const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); - fs.writeFileSync(sourceOrder, '# webhook task\n', 'utf-8'); - - const task: TaskInfo = { - name: 'task-with-taskdir-worktree', - content: 'Task content', - taskDir: '.takt/tasks/20260201-015714-foptng', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('webhook-task'); - mockCreateSharedClone.mockReturnValue({ - path: cloneDir, - branch: 'takt/webhook-task', - }); - - const result = await resolveTaskExecution(task, projectDir, 'default'); - - const stagedOrder = path.join(cloneDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); - expect(fs.existsSync(stagedOrder)).toBe(true); - expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('webhook task'); - - expect(result.taskPrompt).toContain('Implement using only the files in `.takt/runs/20260201-015714-foptng/context/task`.'); - expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); - expect(result.taskPrompt).not.toContain(projectDir); + expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); + expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); }); }); diff --git a/src/__tests__/workerPool.test.ts b/src/__tests__/workerPool.test.ts index 7254ab8..c35dbb1 100644 --- a/src/__tests__/workerPool.test.ts +++ b/src/__tests__/workerPool.test.ts @@ -45,11 +45,17 @@ const mockInfo = vi.mocked(info); const TEST_POLL_INTERVAL_MS = 50; -function createTask(name: string): TaskInfo { +function createTask(name: string, options?: { issue?: number }): TaskInfo { return { name, content: `Task: ${name}`, filePath: `/tasks/${name}.yaml`, + createdAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + data: { + task: `Task: ${name}`, + ...(options?.issue !== undefined ? { issue: options.issue } : {}), + }, }; } @@ -135,10 +141,41 @@ describe('runWithWorkerPool', () => { // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; - expect(parallelOpts).toEqual({ + expect(parallelOpts).toMatchObject({ abortSignal: expect.any(AbortSignal), taskPrefix: 'my-task', taskColorIndex: 0, + taskDisplayLabel: undefined, + }); + }); + + it('should use full issue number as taskPrefix label when task has issue in parallel execution', async () => { + // Given: task with 5-digit issue number should not be truncated + const issueNumber = 12345; + const tasks = [createTask('issue-task', { issue: issueNumber })]; + const runner = createMockTaskRunner([]); + const stdoutChunks: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + stdoutChunks.push(String(chunk)); + return true; + }); + + // When + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + + // Then: Issue label is used instead of truncated task name + writeSpy.mockRestore(); + const allOutput = stdoutChunks.join(''); + expect(allOutput).toContain('[#12345]'); + expect(allOutput).not.toContain('[#123]'); + + expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); + const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; + expect(parallelOpts).toEqual({ + abortSignal: expect.any(AbortSignal), + taskPrefix: `#${issueNumber}`, + taskDisplayLabel: `#${issueNumber}`, + taskColorIndex: 0, }); }); @@ -153,10 +190,11 @@ describe('runWithWorkerPool', () => { // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; - expect(parallelOpts).toEqual({ + expect(parallelOpts).toMatchObject({ abortSignal: expect.any(AbortSignal), taskPrefix: undefined, taskColorIndex: undefined, + taskDisplayLabel: undefined, }); }); diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 39a67fd..93b9dc6 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -205,9 +205,17 @@ function fillSlots( const task = queue.shift()!; const isParallel = concurrency > 1; const colorIndex = colorCounter.value++; + const issueNumber = task.data?.issue; + const taskPrefix = issueNumber === undefined ? task.name : `#${issueNumber}`; + const taskDisplayLabel = issueNumber === undefined ? undefined : taskPrefix; if (isParallel) { - const writer = new TaskPrefixWriter({ taskName: task.name, colorIndex }); + const writer = new TaskPrefixWriter({ + taskName: task.name, + colorIndex, + issue: issueNumber, + displayLabel: taskDisplayLabel, + }); writer.writeLine(`=== Task: ${task.name} ===`); } else { blankLine(); @@ -216,8 +224,9 @@ function fillSlots( const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { abortSignal: abortController.signal, - taskPrefix: isParallel ? task.name : undefined, + taskPrefix: isParallel ? taskPrefix : undefined, taskColorIndex: isParallel ? colorIndex : undefined, + taskDisplayLabel: isParallel ? taskDisplayLabel : undefined, }); active.set(promise, task); } diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index c958cc3..dbc05ac 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -232,7 +232,11 @@ export async function executePiece( // When taskPrefix is set (parallel execution), route all output through TaskPrefixWriter const prefixWriter = options.taskPrefix != null - ? new TaskPrefixWriter({ taskName: options.taskPrefix, colorIndex: options.taskColorIndex! }) + ? new TaskPrefixWriter({ + taskName: options.taskPrefix, + colorIndex: options.taskColorIndex!, + displayLabel: options.taskDisplayLabel, + }) : undefined; const out = createOutputFns(prefixWriter); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 2f443cb..63f2afd 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -51,7 +51,22 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { - const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, reportDirName, abortSignal, taskPrefix, taskColorIndex } = options; + const { + task, + cwd, + pieceIdentifier, + projectCwd, + agentOverrides, + interactiveUserInput, + interactiveMetadata, + startMovement, + retryNote, + reportDirName, + abortSignal, + taskPrefix, + taskColorIndex, + taskDisplayLabel, + } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { @@ -91,8 +106,9 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const startedAt = new Date().toISOString(); const taskAbortController = new AbortController(); @@ -164,6 +180,7 @@ export async function executeAndCompleteTask( abortSignal: taskAbortSignal, taskPrefix: parallelOptions?.taskPrefix, taskColorIndex: parallelOptions?.taskColorIndex, + taskDisplayLabel: parallelOptions?.taskDisplayLabel, }); const taskSuccess = taskRunResult.success; diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index d09c9f1..ade69cd 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -57,6 +57,8 @@ export interface PieceExecutionOptions { abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ taskPrefix?: string; + /** Optional full task label used instead of taskName truncation when prefixed output is rendered */ + taskDisplayLabel?: string; /** Color index for task prefix (cycled mod 4 across concurrent tasks) */ taskColorIndex?: number; } @@ -91,6 +93,8 @@ export interface ExecuteTaskOptions { abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ taskPrefix?: string; + /** Optional full task label used instead of taskName truncation when prefixed output is rendered */ + taskDisplayLabel?: string; /** Color index for task prefix (cycled mod 4 across concurrent tasks) */ taskColorIndex?: number; } diff --git a/src/shared/ui/TaskPrefixWriter.ts b/src/shared/ui/TaskPrefixWriter.ts index 7cf508b..99cc05c 100644 --- a/src/shared/ui/TaskPrefixWriter.ts +++ b/src/shared/ui/TaskPrefixWriter.ts @@ -21,6 +21,10 @@ const RESET = '\x1b[0m'; export interface TaskPrefixWriterOptions { /** Task name used in the prefix */ taskName: string; + /** Optional pre-computed label used in the prefix (overrides taskName truncation). */ + displayLabel?: string; + /** Optional issue number used in the prefix (overrides taskName and display label). */ + issue?: number; /** Color index for the prefix (cycled mod 4) */ colorIndex: number; /** Override process.stdout.write for testing */ @@ -49,7 +53,8 @@ export class TaskPrefixWriter { constructor(options: TaskPrefixWriterOptions) { const color = TASK_COLORS[options.colorIndex % TASK_COLORS.length]; - const taskLabel = options.taskName.slice(0, 4); + const issueLabel = options.issue == null ? undefined : `#${options.issue}`; + const taskLabel = issueLabel ?? options.displayLabel ?? options.taskName.slice(0, 4); this.taskPrefix = `${color}[${taskLabel}]${RESET}`; this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text)); }