Select workflow:

(↑↓ to move, Enter to select, b to bookmark, r to remove)

[?7l  ❯ 🎼 default (current)
    🎼 minimal [*]
    📁 その他/
    📂 Builtin/ (8)
    Cancel
[?7h が  でハングする
This commit is contained in:
nrslib 2026-02-03 17:43:20 +09:00
parent 4565263ca4
commit 91731981d3
4 changed files with 58 additions and 64 deletions

View File

@ -24,7 +24,6 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
promptInput: vi.fn(), promptInput: vi.fn(),
confirm: vi.fn(), confirm: vi.fn(),
selectOption: vi.fn(),
})); }));
vi.mock('../infra/task/summarize.js', () => ({ vi.mock('../infra/task/summarize.js', () => ({
@ -46,12 +45,12 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}), }),
})); }));
vi.mock('../infra/config/loaders/workflowLoader.js', () => ({ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
listWorkflows: vi.fn(), determineWorkflow: vi.fn(),
})); }));
vi.mock('../infra/config/paths.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()), vi.mock('../infra/config/loaders/workflowResolver.js', () => ({
getCurrentWorkflow: vi.fn(() => 'default'), getWorkflowDescription: vi.fn(() => ({ name: 'default', description: '' })),
})); }));
vi.mock('../infra/github/issue.js', () => ({ vi.mock('../infra/github/issue.js', () => ({
@ -71,9 +70,10 @@ vi.mock('../infra/github/issue.js', () => ({
import { interactiveMode } from '../features/interactive/index.js'; import { interactiveMode } from '../features/interactive/index.js';
import { getProvider } from '../infra/providers/index.js'; import { getProvider } from '../infra/providers/index.js';
import { promptInput, confirm, selectOption } from '../shared/prompt/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js';
import { summarizeTaskName } from '../infra/task/summarize.js'; import { summarizeTaskName } from '../infra/task/summarize.js';
import { listWorkflows } from '../infra/config/loaders/workflowLoader.js'; import { determineWorkflow } from '../features/tasks/execute/selectAndExecute.js';
import { getWorkflowDescription } from '../infra/config/loaders/workflowResolver.js';
import { resolveIssueTask } from '../infra/github/issue.js'; import { resolveIssueTask } from '../infra/github/issue.js';
import { addTask, summarizeConversation } from '../features/tasks/index.js'; import { addTask, summarizeConversation } from '../features/tasks/index.js';
@ -83,9 +83,9 @@ const mockInteractiveMode = vi.mocked(interactiveMode);
const mockGetProvider = vi.mocked(getProvider); const mockGetProvider = vi.mocked(getProvider);
const mockPromptInput = vi.mocked(promptInput); const mockPromptInput = vi.mocked(promptInput);
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
const mockSelectOption = vi.mocked(selectOption);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockListWorkflows = vi.mocked(listWorkflows); const mockDetermineWorkflow = vi.mocked(determineWorkflow);
const mockGetWorkflowDescription = vi.mocked(getWorkflowDescription);
/** Helper: set up mocks for the full happy path */ /** Helper: set up mocks for the full happy path */
function setupFullFlowMocks(overrides?: { function setupFullFlowMocks(overrides?: {
@ -97,6 +97,8 @@ function setupFullFlowMocks(overrides?: {
const summary = overrides?.summaryContent ?? '# 認証機能追加\nJWT認証を実装する'; const summary = overrides?.summaryContent ?? '# 認証機能追加\nJWT認証を実装する';
const slug = overrides?.slug ?? 'add-auth'; const slug = overrides?.slug ?? 'add-auth';
mockDetermineWorkflow.mockResolvedValue('default');
mockGetWorkflowDescription.mockReturnValue({ name: 'default', description: '' });
mockInteractiveMode.mockResolvedValue({ confirmed: true, task }); mockInteractiveMode.mockResolvedValue({ confirmed: true, task });
const mockProviderCall = vi.fn().mockResolvedValue({ content: summary }); const mockProviderCall = vi.fn().mockResolvedValue({ content: summary });
@ -104,7 +106,6 @@ function setupFullFlowMocks(overrides?: {
mockSummarizeTaskName.mockResolvedValue(slug); mockSummarizeTaskName.mockResolvedValue(slug);
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
mockListWorkflows.mockReturnValue([]);
return { mockProviderCall }; return { mockProviderCall };
} }
@ -114,7 +115,8 @@ let testDir: string;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
mockListWorkflows.mockReturnValue([]); mockDetermineWorkflow.mockResolvedValue('default');
mockGetWorkflowDescription.mockReturnValue({ name: 'default', description: '' });
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
}); });
@ -127,6 +129,7 @@ afterEach(() => {
describe('addTask', () => { describe('addTask', () => {
it('should cancel when interactive mode is not confirmed', async () => { it('should cancel when interactive mode is not confirmed', async () => {
// Given: user cancels interactive mode // Given: user cancels interactive mode
mockDetermineWorkflow.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' }); mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' });
// When // When
@ -257,11 +260,11 @@ describe('addTask', () => {
}); });
it('should include workflow selection in task file', async () => { it('should include workflow selection in task file', async () => {
// Given: multiple workflows available // Given: determineWorkflow returns a non-default workflow
setupFullFlowMocks({ slug: 'with-workflow' }); setupFullFlowMocks({ slug: 'with-workflow' });
mockListWorkflows.mockReturnValue(['default', 'review']); mockDetermineWorkflow.mockResolvedValue('review');
mockGetWorkflowDescription.mockReturnValue({ name: 'review', description: 'Code review workflow' });
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
mockSelectOption.mockResolvedValue('review');
// When // When
await addTask(testDir); await addTask(testDir);
@ -273,11 +276,8 @@ describe('addTask', () => {
}); });
it('should cancel when workflow selection returns null', async () => { it('should cancel when workflow selection returns null', async () => {
// Given: workflows available but user cancels selection // Given: user cancels workflow selection
setupFullFlowMocks({ slug: 'cancelled' }); mockDetermineWorkflow.mockResolvedValue(null);
mockListWorkflows.mockReturnValue(['default', 'review']);
mockConfirm.mockResolvedValue(false);
mockSelectOption.mockResolvedValue(null);
// When // When
await addTask(testDir); await addTask(testDir);
@ -288,20 +288,19 @@ describe('addTask', () => {
expect(files.length).toBe(0); expect(files.length).toBe(0);
}); });
it('should not include workflow when current workflow is selected', async () => { it('should always include workflow from determineWorkflow', async () => {
// Given: current workflow selected (no need to record it) // Given: determineWorkflow returns 'default'
setupFullFlowMocks({ slug: 'default-wf' }); setupFullFlowMocks({ slug: 'default-wf' });
mockListWorkflows.mockReturnValue(['default', 'review']); mockDetermineWorkflow.mockResolvedValue('default');
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
mockSelectOption.mockResolvedValue('default');
// When // When
await addTask(testDir); await addTask(testDir);
// Then: workflow field should not be in the YAML // Then: workflow field is included
const taskFile = path.join(testDir, '.takt', 'tasks', 'default-wf.yaml'); const taskFile = path.join(testDir, '.takt', 'tasks', 'default-wf.yaml');
const content = fs.readFileSync(taskFile, 'utf-8'); const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).not.toContain('workflow:'); expect(content).toContain('workflow: default');
}); });
it('should fetch issue and use directly as task content when given issue reference', async () => { it('should fetch issue and use directly as task content when given issue reference', async () => {
@ -311,7 +310,6 @@ describe('addTask', () => {
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
mockListWorkflows.mockReturnValue([]);
// When // When
await addTask(testDir, '#99'); await addTask(testDir, '#99');
@ -329,13 +327,14 @@ describe('addTask', () => {
expect(content).toContain('Fix login timeout'); expect(content).toContain('Fix login timeout');
}); });
it('should proceed to worktree/workflow settings after issue fetch', async () => { it('should proceed to worktree settings after issue fetch', async () => {
// Given: issue with worktree enabled // Given: issue with worktree enabled
mockResolveIssueTask.mockReturnValue('Issue text'); mockResolveIssueTask.mockReturnValue('Issue text');
mockSummarizeTaskName.mockResolvedValue('issue-task'); mockSummarizeTaskName.mockResolvedValue('issue-task');
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockPromptInput.mockResolvedValue(''); mockPromptInput
mockListWorkflows.mockReturnValue([]); .mockResolvedValueOnce('') // worktree path (auto)
.mockResolvedValueOnce(''); // branch name (auto)
// When // When
await addTask(testDir, '#42'); await addTask(testDir, '#42');
@ -368,7 +367,6 @@ describe('addTask', () => {
mockResolveIssueTask.mockReturnValue(issueText); mockResolveIssueTask.mockReturnValue(issueText);
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
mockListWorkflows.mockReturnValue([]);
// When // When
await addTask(testDir, '#99'); await addTask(testDir, '#99');

View File

@ -45,9 +45,12 @@ export class OptionsBuilder {
? step.allowedTools?.filter((t) => t !== 'Write') ? step.allowedTools?.filter((t) => t !== 'Write')
: step.allowedTools; : step.allowedTools;
// Skip session resume when cwd !== projectCwd (worktree execution) to avoid cross-directory contamination
const shouldResumeSession = step.session !== 'refresh' && this.getCwd() === this.getProjectCwd();
return { return {
...this.buildBaseOptions(step), ...this.buildBaseOptions(step),
sessionId: step.session === 'refresh' ? undefined : this.getSessionId(step.agent ?? step.name), sessionId: shouldResumeSession ? this.getSessionId(step.agent ?? step.name) : undefined,
allowedTools, allowedTools,
}; };
} }

View File

@ -8,10 +8,11 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { stringify as stringifyYaml } from 'yaml'; import { stringify as stringifyYaml } from 'yaml';
import { promptInput, confirm, selectOption } from '../../../shared/prompt/index.js'; import { promptInput, confirm } from '../../../shared/prompt/index.js';
import { success, info } from '../../../shared/ui/index.js'; import { success, info } from '../../../shared/ui/index.js';
import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js'; import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js';
import { loadGlobalConfig, listWorkflows, getCurrentWorkflow } from '../../../infra/config/index.js'; import { loadGlobalConfig, getWorkflowDescription } from '../../../infra/config/index.js';
import { determineWorkflow } from '../execute/selectAndExecute.js';
import { getProvider, type ProviderType } from '../../../infra/providers/index.js'; import { getProvider, type ProviderType } from '../../../infra/providers/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { getPrompt } from '../../../shared/prompts/index.js'; import { getPrompt } from '../../../shared/prompts/index.js';
@ -62,18 +63,21 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri
* add command handler * add command handler
* *
* Flow: * Flow:
* 1. AI対話モードでタスクを詰める * 1.
* 2. AIがタスク要約を生成 * 2. AI対話モードでタスクを詰める
* 3. AIで生成 * 3. AIがタスク要約を生成
* 4. // * 4. AIで生成
* 5. YAMLファイル作成 * 5. /
* 6. YAMLファイル作成
*/ */
export async function addTask(cwd: string, task?: string): Promise<void> { export async function addTask(cwd: string, task?: string): Promise<void> {
const tasksDir = path.join(cwd, '.takt', 'tasks'); const tasksDir = path.join(cwd, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true }); fs.mkdirSync(tasksDir, { recursive: true });
// 1. ワークフロー選択Issue参照以外の場合、対話モードの前に実施
let taskContent: string; let taskContent: string;
let issueNumber: number | undefined; let issueNumber: number | undefined;
let workflow: string | undefined;
if (task && isIssueReference(task)) { if (task && isIssueReference(task)) {
// Issue reference: fetch issue and use directly as task content // Issue reference: fetch issue and use directly as task content
@ -91,8 +95,18 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
return; return;
} }
} else { } else {
// ワークフロー選択を先に行い、結果を対話モードに渡す
const workflowId = await determineWorkflow(cwd);
if (workflowId === null) {
info('Cancelled.');
return;
}
workflow = workflowId;
const workflowContext = getWorkflowDescription(workflowId, cwd);
// Interactive mode: AI conversation to refine task // Interactive mode: AI conversation to refine task
const result = await interactiveMode(cwd); const result = await interactiveMode(cwd, undefined, workflowContext);
if (!result.confirmed) { if (!result.confirmed) {
info('Cancelled.'); info('Cancelled.');
return; return;
@ -106,10 +120,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
const firstLine = taskContent.split('\n')[0] || taskContent; const firstLine = taskContent.split('\n')[0] || taskContent;
const filename = await generateFilename(tasksDir, firstLine, cwd); const filename = await generateFilename(tasksDir, firstLine, cwd);
// 4. ワークツリー/ブランチ/ワークフロー設定 // 4. ワークツリー/ブランチ設定
let worktree: boolean | string | undefined; let worktree: boolean | string | undefined;
let branch: string | undefined; let branch: string | undefined;
let workflow: string | undefined;
const useWorktree = await confirm('Create worktree?', true); const useWorktree = await confirm('Create worktree?', true);
if (useWorktree) { if (useWorktree) {
@ -122,26 +135,6 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
} }
} }
const availableWorkflows = listWorkflows(cwd);
if (availableWorkflows.length > 0) {
const currentWorkflow = getCurrentWorkflow(cwd);
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
? currentWorkflow
: availableWorkflows[0]!;
const options = availableWorkflows.map((name) => ({
label: name === currentWorkflow ? `${name} (current)` : name,
value: name,
}));
const selected = await selectOption('Select workflow:', options);
if (selected === null) {
info('Cancelled.');
return;
}
if (selected !== defaultWorkflow) {
workflow = selected;
}
}
// 5. YAMLファイル作成 // 5. YAMLファイル作成
const taskData: TaskFileData = { task: taskContent }; const taskData: TaskFileData = { task: taskContent };
if (worktree !== undefined) { if (worktree !== undefined) {

View File

@ -74,9 +74,9 @@ export class SdkOptionsBuilder {
}; };
} }
if (this.options.onStream) { // Always enable — QueryExecutor uses the async iterator (`for await`)
sdkOptions.includePartialMessages = true; // which only yields when this flag is true.
} sdkOptions.includePartialMessages = true;
if (this.options.sessionId) { if (this.options.sessionId) {
sdkOptions.resume = this.options.sessionId; sdkOptions.resume = this.options.sessionId;