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:
parent
4565263ca4
commit
91731981d3
@ -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');
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -74,9 +74,9 @@ export class SdkOptionsBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.onStream) {
|
// Always enable — QueryExecutor uses the async iterator (`for await`)
|
||||||
|
// which only yields when this flag is true.
|
||||||
sdkOptions.includePartialMessages = true;
|
sdkOptions.includePartialMessages = true;
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.sessionId) {
|
if (this.options.sessionId) {
|
||||||
sdkOptions.resume = this.options.sessionId;
|
sdkOptions.resume = this.options.sessionId;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user