takt: github-issue-215-issue (#294)
This commit is contained in:
parent
16d7f9f979
commit
3de574e81b
86
src/__tests__/resolveTask.test.ts
Normal file
86
src/__tests__/resolveTask.test.ts
Normal file
@ -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<string>();
|
||||
|
||||
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>): 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');
|
||||
});
|
||||
});
|
||||
@ -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 }),
|
||||
|
||||
@ -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<Record<string, unknown>>()),
|
||||
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<Record<string, unknown>>()),
|
||||
createSharedClone: vi.fn(),
|
||||
removeClone: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/git.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCurrentBranch: vi.fn(() => 'main'),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/autoCommit.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
autoCommitAndPush: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/summarize.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
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<Record<string, unknown>>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -51,7 +51,22 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
|
||||
}
|
||||
|
||||
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
||||
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<Piece
|
||||
abortSignal,
|
||||
taskPrefix,
|
||||
taskColorIndex,
|
||||
taskDisplayLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single task with piece.
|
||||
@ -116,7 +132,7 @@ export async function executeAndCompleteTask(
|
||||
cwd: string,
|
||||
pieceName: string,
|
||||
options?: TaskExecutionOptions,
|
||||
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number },
|
||||
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number; taskDisplayLabel?: string },
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user