takt: Execute アクションで tasks.yaml への追加をスキップする skipTaskList オプション (#334)

- SelectAndExecuteOptions に skipTaskList フラグを追加
- routing.ts の Execute アクションで skipTaskList: true を設定
- taskRecord の null チェックで条件分岐を統一
- テストを現在の taskResultHandler API に合わせて修正
This commit is contained in:
nrs 2026-02-22 22:05:13 +09:00 committed by GitHub
parent f307ed80f0
commit e5902b87ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 198 additions and 23 deletions

View File

@ -0,0 +1,165 @@
/**
* Tests for skipTaskList option in selectAndExecuteTask
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockAddTask,
mockExecuteTask,
mockPersistTaskResult,
mockPersistTaskError,
mockBuildBooleanTaskResult,
} = vi.hoisted(() => ({
mockAddTask: vi.fn(() => ({
name: 'test-task',
content: 'test task',
filePath: '/project/.takt/tasks.yaml',
createdAt: '2026-02-14T00:00:00.000Z',
status: 'pending',
data: { task: 'test task' },
})),
mockExecuteTask: vi.fn(),
mockPersistTaskResult: vi.fn(),
mockPersistTaskError: vi.fn(),
mockBuildBooleanTaskResult: vi.fn(() => ({ task: 'mock-result' })),
}));
vi.mock('../shared/prompt/index.js', () => ({
confirm: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({
resolvePieceConfigValue: vi.fn(),
loadPieceByIdentifier: vi.fn(() => ({ name: 'default' })),
listPieces: vi.fn(() => ['default']),
listPieceEntries: vi.fn(() => []),
isPiecePath: vi.fn(() => false),
}));
vi.mock('../infra/task/index.js', () => ({
createSharedClone: vi.fn(),
autoCommitAndPush: vi.fn(),
summarizeTaskName: vi.fn(),
resolveBaseBranch: vi.fn(() => ({ branch: 'main' })),
TaskRunner: vi.fn(() => ({
addTask: (...args: unknown[]) => mockAddTask(...args),
})),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
success: vi.fn(),
withProgress: async <T>(
_startMessage: string,
_completionMessage: string | ((result: T) => string),
operation: () => Promise<T>,
): Promise<T> => operation(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../infra/github/index.js', () => ({
createPullRequest: vi.fn(),
buildPrBody: vi.fn(),
pushBranch: vi.fn(),
findExistingPr: vi.fn(),
commentOnPr: vi.fn(),
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeTask: (...args: unknown[]) => mockExecuteTask(...args),
}));
vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({
buildBooleanTaskResult: (...args: unknown[]) => mockBuildBooleanTaskResult(...args),
persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args),
persistTaskError: (...args: unknown[]) => mockPersistTaskError(...args),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
selectPiece: vi.fn(),
}));
import { confirm } from '../shared/prompt/index.js';
import { selectAndExecuteTask } from '../features/tasks/execute/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm);
beforeEach(() => {
vi.clearAllMocks();
mockExecuteTask.mockResolvedValue(true);
// worktree を使わないconfirm で false
mockConfirm.mockResolvedValue(false);
});
describe('skipTaskList option in selectAndExecuteTask', () => {
it('skipTaskList: true の場合はタスクリストに追加しない', async () => {
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
skipTaskList: true,
});
expect(mockAddTask).not.toHaveBeenCalled();
expect(mockPersistTaskResult).not.toHaveBeenCalled();
expect(mockExecuteTask).toHaveBeenCalled();
});
it('skipTaskList: false の場合はタスクリストに追加する', async () => {
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
skipTaskList: false,
});
expect(mockAddTask).toHaveBeenCalled();
expect(mockBuildBooleanTaskResult).toHaveBeenCalled();
expect(mockPersistTaskResult).toHaveBeenCalled();
expect(mockExecuteTask).toHaveBeenCalled();
});
it('skipTaskList 未指定の場合はタスクリストに追加する', async () => {
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
});
expect(mockAddTask).toHaveBeenCalled();
expect(mockPersistTaskResult).toHaveBeenCalled();
expect(mockExecuteTask).toHaveBeenCalled();
});
it('skipTaskList: true でエラー時は persistTaskError を呼ばない', async () => {
mockExecuteTask.mockRejectedValue(new Error('Task execution failed'));
await expect(
selectAndExecuteTask('/project', 'test task', {
piece: 'default',
skipTaskList: true,
}),
).rejects.toThrow('Task execution failed');
expect(mockAddTask).not.toHaveBeenCalled();
expect(mockPersistTaskError).not.toHaveBeenCalled();
});
it('skipTaskList: false でエラー時は persistTaskError を呼ぶ', async () => {
mockExecuteTask.mockRejectedValue(new Error('Task execution failed'));
await expect(
selectAndExecuteTask('/project', 'test task', {
piece: 'default',
skipTaskList: false,
}),
).rejects.toThrow('Task execution failed');
expect(mockAddTask).toHaveBeenCalled();
expect(mockPersistTaskError).toHaveBeenCalled();
});
});

View File

@ -214,6 +214,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
selectOptions.interactiveUserInput = true;
selectOptions.piece = pieceId;
selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask };
selectOptions.skipTaskList = true;
await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides);
},
create_issue: async ({ task: confirmedTask }) => {

View File

@ -108,15 +108,18 @@ export async function selectAndExecuteTask(
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr, draftPr: shouldDraftPr });
const taskRunner = new TaskRunner(cwd);
const taskRecord = taskRunner.addTask(task, {
piece: pieceIdentifier,
...(isWorktree ? { worktree: true } : {}),
...(branch ? { branch } : {}),
...(isWorktree ? { worktree_path: execCwd } : {}),
auto_pr: shouldCreatePr,
draft_pr: shouldDraftPr,
...(taskSlug ? { slug: taskSlug } : {}),
});
let taskRecord: Awaited<ReturnType<TaskRunner['addTask']>> | null = null;
if (options?.skipTaskList !== true) {
taskRecord = taskRunner.addTask(task, {
piece: pieceIdentifier,
...(isWorktree ? { worktree: true } : {}),
...(branch ? { branch } : {}),
...(isWorktree ? { worktree_path: execCwd } : {}),
auto_pr: shouldCreatePr,
draft_pr: shouldDraftPr,
...(taskSlug ? { slug: taskSlug } : {}),
});
}
const startedAt = new Date().toISOString();
let taskSuccess: boolean;
@ -132,9 +135,11 @@ export async function selectAndExecuteTask(
});
} catch (err) {
const completedAt = new Date().toISOString();
persistTaskError(taskRunner, taskRecord, startedAt, completedAt, err, {
responsePrefix: 'Task failed: ',
});
if (taskRecord) {
persistTaskError(taskRunner, taskRecord, startedAt, completedAt, err, {
responsePrefix: 'Task failed: ',
});
}
throw err;
}
@ -160,17 +165,19 @@ export async function selectAndExecuteTask(
}
const effectiveSuccess = taskSuccess && !prFailed;
const taskResult = buildBooleanTaskResult({
task: taskRecord,
taskSuccess: effectiveSuccess,
successResponse: 'Task completed successfully',
failureResponse: prFailed ? `PR creation failed: ${prError}` : 'Task failed',
startedAt,
completedAt,
branch,
...(isWorktree ? { worktreePath: execCwd } : {}),
});
persistTaskResult(taskRunner, taskResult);
if (taskRecord) {
const taskResult = buildBooleanTaskResult({
task: taskRecord,
taskSuccess: effectiveSuccess,
successResponse: 'Task completed successfully',
failureResponse: prFailed ? `PR creation failed: ${prError}` : 'Task failed',
startedAt,
completedAt,
branch,
...(isWorktree ? { worktreePath: execCwd } : {}),
});
persistTaskResult(taskRunner, taskResult);
}
if (!effectiveSuccess) {
process.exit(1);

View File

@ -145,4 +145,6 @@ export interface SelectAndExecuteOptions {
interactiveMetadata?: InteractiveMetadata;
/** GitHub Issues to associate with the PR (adds "Closes #N" for each issue) */
issues?: GitHubIssue[];
/** Skip adding task to tasks.yaml */
skipTaskList?: boolean;
}