From 91731981d3b1991f401d8831c57b42704a2c5113 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:43:20 +0900 Subject: [PATCH] =?UTF-8?q?Select=20workflow:=20=20=20(=E2=86=91=E2=86=93?= =?UTF-8?q?=20to=20move,=20Enter=20to=20select,=20b=20to=20bookmark,=20r?= =?UTF-8?q?=20to=20remove)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [?7l ❯ 🎼 default (current) 🎼 minimal [*] 📁 その他/ 📂 Builtin/ (8) Cancel [?7h が でハングする --- src/__tests__/addTask.test.ts | 58 +++++++++++----------- src/core/workflow/engine/OptionsBuilder.ts | 5 +- src/features/tasks/add/index.ts | 53 +++++++++----------- src/infra/claude/options-builder.ts | 6 +-- 4 files changed, 58 insertions(+), 64 deletions(-) diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index ef7b524..eaf33f2 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -24,7 +24,6 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({ promptInput: vi.fn(), confirm: vi.fn(), - selectOption: vi.fn(), })); 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', () => ({ - listWorkflows: vi.fn(), +vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ + determineWorkflow: vi.fn(), })); -vi.mock('../infra/config/paths.js', async (importOriginal) => ({ ...(await importOriginal>()), - getCurrentWorkflow: vi.fn(() => 'default'), +vi.mock('../infra/config/loaders/workflowResolver.js', () => ({ + getWorkflowDescription: vi.fn(() => ({ name: 'default', description: '' })), })); vi.mock('../infra/github/issue.js', () => ({ @@ -71,9 +70,10 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/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 { 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 { addTask, summarizeConversation } from '../features/tasks/index.js'; @@ -83,9 +83,9 @@ const mockInteractiveMode = vi.mocked(interactiveMode); const mockGetProvider = vi.mocked(getProvider); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); -const mockSelectOption = vi.mocked(selectOption); 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 */ function setupFullFlowMocks(overrides?: { @@ -97,6 +97,8 @@ function setupFullFlowMocks(overrides?: { const summary = overrides?.summaryContent ?? '# 認証機能追加\nJWT認証を実装する'; const slug = overrides?.slug ?? 'add-auth'; + mockDetermineWorkflow.mockResolvedValue('default'); + mockGetWorkflowDescription.mockReturnValue({ name: 'default', description: '' }); mockInteractiveMode.mockResolvedValue({ confirmed: true, task }); const mockProviderCall = vi.fn().mockResolvedValue({ content: summary }); @@ -104,7 +106,6 @@ function setupFullFlowMocks(overrides?: { mockSummarizeTaskName.mockResolvedValue(slug); mockConfirm.mockResolvedValue(false); - mockListWorkflows.mockReturnValue([]); return { mockProviderCall }; } @@ -114,7 +115,8 @@ let testDir: string; beforeEach(() => { vi.clearAllMocks(); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); - mockListWorkflows.mockReturnValue([]); + mockDetermineWorkflow.mockResolvedValue('default'); + mockGetWorkflowDescription.mockReturnValue({ name: 'default', description: '' }); mockConfirm.mockResolvedValue(false); }); @@ -127,6 +129,7 @@ afterEach(() => { describe('addTask', () => { it('should cancel when interactive mode is not confirmed', async () => { // Given: user cancels interactive mode + mockDetermineWorkflow.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' }); // When @@ -257,11 +260,11 @@ describe('addTask', () => { }); it('should include workflow selection in task file', async () => { - // Given: multiple workflows available + // Given: determineWorkflow returns a non-default workflow setupFullFlowMocks({ slug: 'with-workflow' }); - mockListWorkflows.mockReturnValue(['default', 'review']); + mockDetermineWorkflow.mockResolvedValue('review'); + mockGetWorkflowDescription.mockReturnValue({ name: 'review', description: 'Code review workflow' }); mockConfirm.mockResolvedValue(false); - mockSelectOption.mockResolvedValue('review'); // When await addTask(testDir); @@ -273,11 +276,8 @@ describe('addTask', () => { }); it('should cancel when workflow selection returns null', async () => { - // Given: workflows available but user cancels selection - setupFullFlowMocks({ slug: 'cancelled' }); - mockListWorkflows.mockReturnValue(['default', 'review']); - mockConfirm.mockResolvedValue(false); - mockSelectOption.mockResolvedValue(null); + // Given: user cancels workflow selection + mockDetermineWorkflow.mockResolvedValue(null); // When await addTask(testDir); @@ -288,20 +288,19 @@ describe('addTask', () => { expect(files.length).toBe(0); }); - it('should not include workflow when current workflow is selected', async () => { - // Given: current workflow selected (no need to record it) + it('should always include workflow from determineWorkflow', async () => { + // Given: determineWorkflow returns 'default' setupFullFlowMocks({ slug: 'default-wf' }); - mockListWorkflows.mockReturnValue(['default', 'review']); + mockDetermineWorkflow.mockResolvedValue('default'); mockConfirm.mockResolvedValue(false); - mockSelectOption.mockResolvedValue('default'); // When 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 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 () => { @@ -311,7 +310,6 @@ describe('addTask', () => { mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockConfirm.mockResolvedValue(false); - mockListWorkflows.mockReturnValue([]); // When await addTask(testDir, '#99'); @@ -329,13 +327,14 @@ describe('addTask', () => { 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 mockResolveIssueTask.mockReturnValue('Issue text'); mockSummarizeTaskName.mockResolvedValue('issue-task'); mockConfirm.mockResolvedValue(true); - mockPromptInput.mockResolvedValue(''); - mockListWorkflows.mockReturnValue([]); + mockPromptInput + .mockResolvedValueOnce('') // worktree path (auto) + .mockResolvedValueOnce(''); // branch name (auto) // When await addTask(testDir, '#42'); @@ -368,7 +367,6 @@ describe('addTask', () => { mockResolveIssueTask.mockReturnValue(issueText); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockConfirm.mockResolvedValue(false); - mockListWorkflows.mockReturnValue([]); // When await addTask(testDir, '#99'); diff --git a/src/core/workflow/engine/OptionsBuilder.ts b/src/core/workflow/engine/OptionsBuilder.ts index b5042a5..49faa10 100644 --- a/src/core/workflow/engine/OptionsBuilder.ts +++ b/src/core/workflow/engine/OptionsBuilder.ts @@ -45,9 +45,12 @@ export class OptionsBuilder { ? step.allowedTools?.filter((t) => t !== 'Write') : 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 { ...this.buildBaseOptions(step), - sessionId: step.session === 'refresh' ? undefined : this.getSessionId(step.agent ?? step.name), + sessionId: shouldResumeSession ? this.getSessionId(step.agent ?? step.name) : undefined, allowedTools, }; } diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 3230064..52c320b 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -8,10 +8,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; 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 { 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 { createLogger, getErrorMessage } from '../../../shared/utils/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 * * Flow: - * 1. AI対話モードでタスクを詰める - * 2. 会話履歴からAIがタスク要約を生成 - * 3. 要約からファイル名をAIで生成 - * 4. ワークツリー/ブランチ/ワークフロー設定 - * 5. YAMLファイル作成 + * 1. ワークフロー選択 + * 2. AI対話モードでタスクを詰める + * 3. 会話履歴からAIがタスク要約を生成 + * 4. 要約からファイル名をAIで生成 + * 5. ワークツリー/ブランチ設定 + * 6. YAMLファイル作成 */ export async function addTask(cwd: string, task?: string): Promise { const tasksDir = path.join(cwd, '.takt', 'tasks'); fs.mkdirSync(tasksDir, { recursive: true }); + // 1. ワークフロー選択(Issue参照以外の場合、対話モードの前に実施) let taskContent: string; let issueNumber: number | undefined; + let workflow: string | undefined; if (task && isIssueReference(task)) { // Issue reference: fetch issue and use directly as task content @@ -91,8 +95,18 @@ export async function addTask(cwd: string, task?: string): Promise { return; } } 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 - const result = await interactiveMode(cwd); + const result = await interactiveMode(cwd, undefined, workflowContext); if (!result.confirmed) { info('Cancelled.'); return; @@ -106,10 +120,9 @@ export async function addTask(cwd: string, task?: string): Promise { const firstLine = taskContent.split('\n')[0] || taskContent; const filename = await generateFilename(tasksDir, firstLine, cwd); - // 4. ワークツリー/ブランチ/ワークフロー設定 + // 4. ワークツリー/ブランチ設定 let worktree: boolean | string | undefined; let branch: string | undefined; - let workflow: string | undefined; const useWorktree = await confirm('Create worktree?', true); if (useWorktree) { @@ -122,26 +135,6 @@ export async function addTask(cwd: string, task?: string): Promise { } } - 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ファイル作成 const taskData: TaskFileData = { task: taskContent }; if (worktree !== undefined) { diff --git a/src/infra/claude/options-builder.ts b/src/infra/claude/options-builder.ts index 36adc79..d5b7357 100644 --- a/src/infra/claude/options-builder.ts +++ b/src/infra/claude/options-builder.ts @@ -74,9 +74,9 @@ export class SdkOptionsBuilder { }; } - if (this.options.onStream) { - sdkOptions.includePartialMessages = true; - } + // Always enable — QueryExecutor uses the async iterator (`for await`) + // which only yields when this flag is true. + sdkOptions.includePartialMessages = true; if (this.options.sessionId) { sdkOptions.resume = this.options.sessionId;