github-issue-256-takt-list-instruct (#267)
* fix: OpenCode SDKサーバー起動タイムアウトを30秒に延長 * takt: github-issue-256-takt-list-instruct * refactor: 会話後アクションフローを共通化
This commit is contained in:
parent
02272e595c
commit
4e58c86643
39
src/__tests__/actionDispatcher.test.ts
Normal file
39
src/__tests__/actionDispatcher.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { dispatchConversationAction } from '../features/interactive/actionDispatcher.js';
|
||||
|
||||
describe('dispatchConversationAction', () => {
|
||||
it('should dispatch to matching handler with full result payload', async () => {
|
||||
const execute = vi.fn().mockResolvedValue('executed');
|
||||
const saveTask = vi.fn().mockResolvedValue('saved');
|
||||
const cancel = vi.fn().mockResolvedValue('cancelled');
|
||||
|
||||
const result = await dispatchConversationAction(
|
||||
{ action: 'save_task', task: 'refine branch docs' },
|
||||
{
|
||||
execute,
|
||||
save_task: saveTask,
|
||||
cancel,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('saved');
|
||||
expect(saveTask).toHaveBeenCalledWith({ action: 'save_task', task: 'refine branch docs' });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support synchronous handlers', async () => {
|
||||
const result = await dispatchConversationAction(
|
||||
{ action: 'cancel', task: '' },
|
||||
{
|
||||
execute: () => true,
|
||||
save_task: () => true,
|
||||
cancel: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
282
src/__tests__/instructMode.test.ts
Normal file
282
src/__tests__/instructMode.test.ts
Normal file
@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Tests for instruct mode
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
||||
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/providers/index.js', () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
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('../shared/context.js', () => ({
|
||||
isQuietMode: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadPersonaSessions: vi.fn(() => ({})),
|
||||
updatePersonaSession: vi.fn(),
|
||||
getProjectConfigDir: vi.fn(() => '/tmp'),
|
||||
loadSessionState: vi.fn(() => null),
|
||||
clearSessionState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
StreamDisplay: vi.fn().mockImplementation(() => ({
|
||||
createHandler: vi.fn(() => vi.fn()),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/i18n/index.js', () => ({
|
||||
getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'),
|
||||
getLabelObject: vi.fn(() => ({
|
||||
intro: 'Instruct mode intro',
|
||||
resume: 'Resuming',
|
||||
noConversation: 'No conversation',
|
||||
summarizeFailed: 'Summarize failed',
|
||||
continuePrompt: 'Continue',
|
||||
proposed: 'Proposed task:',
|
||||
actionPrompt: 'What to do?',
|
||||
actions: {
|
||||
execute: 'Execute',
|
||||
saveTask: 'Save task',
|
||||
continue: 'Continue',
|
||||
},
|
||||
cancelled: 'Cancelled',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompts/index.js', () => ({
|
||||
loadTemplate: vi.fn((_name: string, _lang: string) => 'Mock template content'),
|
||||
}));
|
||||
|
||||
import { getProvider } from '../infra/providers/index.js';
|
||||
import { runInstructMode } from '../features/tasks/list/instructMode.js';
|
||||
import { selectOption } from '../shared/prompt/index.js';
|
||||
import { info } from '../shared/ui/index.js';
|
||||
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
const mockSelectOption = vi.mocked(selectOption);
|
||||
const mockInfo = vi.mocked(info);
|
||||
|
||||
let savedIsTTY: boolean | undefined;
|
||||
let savedIsRaw: boolean | undefined;
|
||||
let savedSetRawMode: typeof process.stdin.setRawMode | undefined;
|
||||
let savedStdoutWrite: typeof process.stdout.write;
|
||||
let savedStdinOn: typeof process.stdin.on;
|
||||
let savedStdinRemoveListener: typeof process.stdin.removeListener;
|
||||
let savedStdinResume: typeof process.stdin.resume;
|
||||
let savedStdinPause: typeof process.stdin.pause;
|
||||
|
||||
function setupRawStdin(rawInputs: string[]): void {
|
||||
savedIsTTY = process.stdin.isTTY;
|
||||
savedIsRaw = process.stdin.isRaw;
|
||||
savedSetRawMode = process.stdin.setRawMode;
|
||||
savedStdoutWrite = process.stdout.write;
|
||||
savedStdinOn = process.stdin.on;
|
||||
savedStdinRemoveListener = process.stdin.removeListener;
|
||||
savedStdinResume = process.stdin.resume;
|
||||
savedStdinPause = process.stdin.pause;
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
|
||||
process.stdin.setRawMode = vi.fn((mode: boolean) => {
|
||||
(process.stdin as unknown as { isRaw: boolean }).isRaw = mode;
|
||||
return process.stdin;
|
||||
}) as unknown as typeof process.stdin.setRawMode;
|
||||
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
|
||||
process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume;
|
||||
process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause;
|
||||
|
||||
let currentHandler: ((data: Buffer) => void) | null = null;
|
||||
let inputIndex = 0;
|
||||
|
||||
process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === 'data') {
|
||||
currentHandler = handler as (data: Buffer) => void;
|
||||
if (inputIndex < rawInputs.length) {
|
||||
const data = rawInputs[inputIndex]!;
|
||||
inputIndex++;
|
||||
queueMicrotask(() => {
|
||||
if (currentHandler) {
|
||||
currentHandler(Buffer.from(data, 'utf-8'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return process.stdin;
|
||||
}) as typeof process.stdin.on);
|
||||
|
||||
process.stdin.removeListener = vi.fn(((event: string) => {
|
||||
if (event === 'data') {
|
||||
currentHandler = null;
|
||||
}
|
||||
return process.stdin;
|
||||
}) as typeof process.stdin.removeListener);
|
||||
}
|
||||
|
||||
function restoreStdin(): void {
|
||||
if (savedIsTTY !== undefined) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true });
|
||||
}
|
||||
if (savedIsRaw !== undefined) {
|
||||
Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true });
|
||||
}
|
||||
if (savedSetRawMode) {
|
||||
process.stdin.setRawMode = savedSetRawMode;
|
||||
}
|
||||
if (savedStdoutWrite) {
|
||||
process.stdout.write = savedStdoutWrite;
|
||||
}
|
||||
if (savedStdinOn) {
|
||||
process.stdin.on = savedStdinOn;
|
||||
}
|
||||
if (savedStdinRemoveListener) {
|
||||
process.stdin.removeListener = savedStdinRemoveListener;
|
||||
}
|
||||
if (savedStdinResume) {
|
||||
process.stdin.resume = savedStdinResume;
|
||||
}
|
||||
if (savedStdinPause) {
|
||||
process.stdin.pause = savedStdinPause;
|
||||
}
|
||||
}
|
||||
|
||||
function toRawInputs(inputs: (string | null)[]): string[] {
|
||||
return inputs.map((input) => {
|
||||
if (input === null) return '\x04';
|
||||
return input + '\r';
|
||||
});
|
||||
}
|
||||
|
||||
function setupMockProvider(responses: string[]): void {
|
||||
let callIndex = 0;
|
||||
const mockCall = vi.fn(async () => {
|
||||
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
|
||||
callIndex++;
|
||||
return {
|
||||
persona: 'instruct',
|
||||
status: 'done' as const,
|
||||
content: content!,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
});
|
||||
const mockProvider = {
|
||||
setup: () => ({ call: mockCall }),
|
||||
_call: mockCall,
|
||||
};
|
||||
mockGetProvider.mockReturnValue(mockProvider);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectOption.mockResolvedValue('execute');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreStdin();
|
||||
});
|
||||
|
||||
describe('runInstructMode', () => {
|
||||
it('should return action=cancel when user types /cancel', async () => {
|
||||
setupRawStdin(toRawInputs(['/cancel']));
|
||||
setupMockProvider([]);
|
||||
|
||||
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||
|
||||
expect(result.action).toBe('cancel');
|
||||
expect(result.task).toBe('');
|
||||
});
|
||||
|
||||
it('should include branch name in intro message', async () => {
|
||||
setupRawStdin(toRawInputs(['/cancel']));
|
||||
setupMockProvider([]);
|
||||
|
||||
await runInstructMode('/project', 'diff stats', 'my-feature-branch');
|
||||
|
||||
const introCall = mockInfo.mock.calls.find((call) =>
|
||||
call[0]?.includes('my-feature-branch')
|
||||
);
|
||||
expect(introCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return action=execute with task on /go after conversation', async () => {
|
||||
setupRawStdin(toRawInputs(['add more tests', '/go']));
|
||||
setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']);
|
||||
|
||||
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||
|
||||
expect(result.action).toBe('execute');
|
||||
expect(result.task).toBe('Add unit tests for the feature.');
|
||||
});
|
||||
|
||||
it('should return action=save_task when user selects save task', async () => {
|
||||
setupRawStdin(toRawInputs(['describe task', '/go']));
|
||||
setupMockProvider(['response', 'Summarized task.']);
|
||||
mockSelectOption.mockResolvedValue('save_task');
|
||||
|
||||
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||
|
||||
expect(result.action).toBe('save_task');
|
||||
expect(result.task).toBe('Summarized task.');
|
||||
});
|
||||
|
||||
it('should continue editing when user selects continue', async () => {
|
||||
setupRawStdin(toRawInputs(['describe task', '/go', '/cancel']));
|
||||
setupMockProvider(['response', 'Summarized task.']);
|
||||
mockSelectOption.mockResolvedValueOnce('continue');
|
||||
|
||||
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should reject /go with no prior conversation', async () => {
|
||||
setupRawStdin(toRawInputs(['/go', '/cancel']));
|
||||
setupMockProvider([]);
|
||||
|
||||
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||
|
||||
expect(result.action).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should use custom action selector without create_issue option', async () => {
|
||||
setupRawStdin(toRawInputs(['task', '/go']));
|
||||
setupMockProvider(['response', 'Task summary.']);
|
||||
|
||||
await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||
|
||||
const selectCall = mockSelectOption.mock.calls.find((call) =>
|
||||
Array.isArray(call[1])
|
||||
);
|
||||
expect(selectCall).toBeDefined();
|
||||
const options = selectCall![1] as Array<{ value: string }>;
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values).toContain('execute');
|
||||
expect(values).toContain('save_task');
|
||||
expect(values).toContain('continue');
|
||||
expect(values).not.toContain('create_issue');
|
||||
});
|
||||
});
|
||||
@ -22,6 +22,7 @@ import {
|
||||
resolveLanguage,
|
||||
type InteractiveModeResult,
|
||||
} from '../../features/interactive/index.js';
|
||||
import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js';
|
||||
import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
|
||||
import { program, resolvedCwd, pipelineMode } from './program.js';
|
||||
@ -202,33 +203,27 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'execute':
|
||||
await dispatchConversationAction(result, {
|
||||
execute: async ({ task: confirmedTask }) => {
|
||||
selectOptions.interactiveUserInput = true;
|
||||
selectOptions.piece = pieceId;
|
||||
selectOptions.interactiveMetadata = { confirmed: true, task: result.task };
|
||||
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
|
||||
break;
|
||||
|
||||
case 'create_issue':
|
||||
{
|
||||
const issueNumber = createIssueFromTask(result.task);
|
||||
selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask };
|
||||
await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides);
|
||||
},
|
||||
create_issue: async ({ task: confirmedTask }) => {
|
||||
const issueNumber = createIssueFromTask(confirmedTask);
|
||||
if (issueNumber !== undefined) {
|
||||
await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, {
|
||||
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, {
|
||||
issue: issueNumber,
|
||||
confirmAtEndMessage: 'Add this issue to tasks?',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'save_task':
|
||||
await saveTaskFromInteractive(resolvedCwd, result.task, pieceId);
|
||||
break;
|
||||
|
||||
case 'cancel':
|
||||
break;
|
||||
}
|
||||
},
|
||||
save_task: async ({ task: confirmedTask }) => {
|
||||
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId);
|
||||
},
|
||||
cancel: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
|
||||
20
src/features/interactive/actionDispatcher.ts
Normal file
20
src/features/interactive/actionDispatcher.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Shared dispatcher for post-conversation actions.
|
||||
*/
|
||||
|
||||
export interface ConversationActionResult<A extends string> {
|
||||
action: A;
|
||||
task: string;
|
||||
}
|
||||
|
||||
export type ConversationActionHandler<A extends string, R> = (
|
||||
result: ConversationActionResult<A>,
|
||||
) => Promise<R> | R;
|
||||
|
||||
export async function dispatchConversationAction<A extends string, R>(
|
||||
result: ConversationActionResult<A>,
|
||||
handlers: Record<A, ConversationActionHandler<A, R>>,
|
||||
): Promise<R> {
|
||||
return handlers[result.action](result);
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
type InteractiveModeResult,
|
||||
type InteractiveUIText,
|
||||
type ConversationMessage,
|
||||
type PostSummaryAction,
|
||||
resolveLanguage,
|
||||
buildSummaryPrompt,
|
||||
selectPostSummaryAction,
|
||||
@ -171,6 +172,8 @@ export async function callAIWithRetry(
|
||||
}
|
||||
}
|
||||
|
||||
export type { PostSummaryAction } from './interactive.js';
|
||||
|
||||
/** Strategy for customizing conversation loop behavior */
|
||||
export interface ConversationStrategy {
|
||||
/** System prompt for AI calls */
|
||||
@ -181,6 +184,8 @@ export interface ConversationStrategy {
|
||||
transformPrompt: (userMessage: string) => string;
|
||||
/** Intro message displayed at start */
|
||||
introMessage: string;
|
||||
/** Custom action selector (optional). If not provided, uses default selectPostSummaryAction. */
|
||||
selectAction?: (task: string, lang: 'en' | 'ja') => Promise<PostSummaryAction | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -284,7 +289,9 @@ export async function runConversationLoop(
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
const task = summaryResult.content.trim();
|
||||
const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui);
|
||||
const selectedAction = strategy.selectAction
|
||||
? await strategy.selectAction(task, ctx.lang)
|
||||
: await selectPostSummaryAction(task, ui.proposed, ui);
|
||||
if (selectedAction === 'continue' || selectedAction === null) {
|
||||
info(ui.continuePrompt);
|
||||
continue;
|
||||
|
||||
@ -169,21 +169,90 @@ export function buildSummaryPrompt(
|
||||
|
||||
export type PostSummaryAction = InteractiveModeAction | 'continue';
|
||||
|
||||
export async function selectPostSummaryAction(
|
||||
export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue';
|
||||
|
||||
export interface SummaryActionOption {
|
||||
label: string;
|
||||
value: SummaryActionValue;
|
||||
}
|
||||
|
||||
export type SummaryActionLabels = {
|
||||
execute: string;
|
||||
createIssue?: string;
|
||||
saveTask: string;
|
||||
continue: string;
|
||||
};
|
||||
|
||||
export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [
|
||||
'execute',
|
||||
'save_task',
|
||||
'continue',
|
||||
];
|
||||
|
||||
export function buildSummaryActionOptions(
|
||||
labels: SummaryActionLabels,
|
||||
append: readonly SummaryActionValue[] = [],
|
||||
): SummaryActionOption[] {
|
||||
const order = [...BASE_SUMMARY_ACTIONS, ...append];
|
||||
const seen = new Set<SummaryActionValue>();
|
||||
const options: SummaryActionOption[] = [];
|
||||
|
||||
for (const action of order) {
|
||||
if (seen.has(action)) continue;
|
||||
seen.add(action);
|
||||
|
||||
if (action === 'execute') {
|
||||
options.push({ label: labels.execute, value: action });
|
||||
continue;
|
||||
}
|
||||
if (action === 'create_issue') {
|
||||
if (labels.createIssue) {
|
||||
options.push({ label: labels.createIssue, value: action });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (action === 'save_task') {
|
||||
options.push({ label: labels.saveTask, value: action });
|
||||
continue;
|
||||
}
|
||||
options.push({ label: labels.continue, value: action });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function selectSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
ui: InteractiveUIText,
|
||||
actionPrompt: string,
|
||||
options: SummaryActionOption[],
|
||||
): Promise<PostSummaryAction | null> {
|
||||
blankLine();
|
||||
info(proposedLabel);
|
||||
console.log(task);
|
||||
|
||||
return selectOption<PostSummaryAction>(ui.actionPrompt, [
|
||||
{ label: ui.actions.execute, value: 'execute' },
|
||||
{ label: ui.actions.createIssue, value: 'create_issue' },
|
||||
{ label: ui.actions.saveTask, value: 'save_task' },
|
||||
{ label: ui.actions.continue, value: 'continue' },
|
||||
]);
|
||||
return selectOption<PostSummaryAction>(actionPrompt, options);
|
||||
}
|
||||
|
||||
export async function selectPostSummaryAction(
|
||||
task: string,
|
||||
proposedLabel: string,
|
||||
ui: InteractiveUIText,
|
||||
): Promise<PostSummaryAction | null> {
|
||||
return selectSummaryAction(
|
||||
task,
|
||||
proposedLabel,
|
||||
ui.actionPrompt,
|
||||
buildSummaryActionOptions(
|
||||
{
|
||||
execute: ui.actions.execute,
|
||||
createIssue: ui.actions.createIssue,
|
||||
saveTask: ui.actions.saveTask,
|
||||
continue: ui.actions.continue,
|
||||
},
|
||||
['create_issue'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
|
||||
|
||||
@ -44,6 +44,12 @@ export {
|
||||
instructBranch,
|
||||
} from './taskActions.js';
|
||||
|
||||
export {
|
||||
type InstructModeAction,
|
||||
type InstructModeResult,
|
||||
runInstructMode,
|
||||
} from './instructMode.js';
|
||||
|
||||
/** Task action type for pending task action selection menu */
|
||||
type PendingTaskAction = 'delete';
|
||||
|
||||
|
||||
123
src/features/tasks/list/instructMode.ts
Normal file
123
src/features/tasks/list/instructMode.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Instruct mode for branch-based tasks.
|
||||
*
|
||||
* Provides conversation loop for additional instructions on existing branches,
|
||||
* similar to interactive mode but with branch context and limited actions.
|
||||
*/
|
||||
|
||||
import {
|
||||
initializeSession,
|
||||
displayAndClearSessionState,
|
||||
runConversationLoop,
|
||||
type SessionContext,
|
||||
type ConversationStrategy,
|
||||
type PostSummaryAction,
|
||||
} from '../../interactive/conversationLoop.js';
|
||||
import {
|
||||
resolveLanguage,
|
||||
buildSummaryActionOptions,
|
||||
selectSummaryAction,
|
||||
} from '../../interactive/interactive.js';
|
||||
import { loadTemplate } from '../../../shared/prompts/index.js';
|
||||
import { getLabelObject } from '../../../shared/i18n/index.js';
|
||||
import { loadGlobalConfig } from '../../../infra/config/index.js';
|
||||
|
||||
export type InstructModeAction = 'execute' | 'save_task' | 'cancel';
|
||||
|
||||
export interface InstructModeResult {
|
||||
action: InstructModeAction;
|
||||
task: string;
|
||||
}
|
||||
|
||||
export interface InstructUIText {
|
||||
intro: string;
|
||||
resume: string;
|
||||
noConversation: string;
|
||||
summarizeFailed: string;
|
||||
continuePrompt: string;
|
||||
proposed: string;
|
||||
actionPrompt: string;
|
||||
actions: {
|
||||
execute: string;
|
||||
saveTask: string;
|
||||
continue: string;
|
||||
};
|
||||
cancelled: string;
|
||||
}
|
||||
|
||||
const INSTRUCT_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise<PostSummaryAction | null> {
|
||||
return async (task: string, _lang: 'en' | 'ja'): Promise<PostSummaryAction | null> => {
|
||||
return selectSummaryAction(
|
||||
task,
|
||||
ui.proposed,
|
||||
ui.actionPrompt,
|
||||
buildSummaryActionOptions({
|
||||
execute: ui.actions.execute,
|
||||
saveTask: ui.actions.saveTask,
|
||||
continue: ui.actions.continue,
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function runInstructMode(
|
||||
cwd: string,
|
||||
branchContext: string,
|
||||
branchName: string,
|
||||
): Promise<InstructModeResult> {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const lang = resolveLanguage(globalConfig.language);
|
||||
|
||||
if (!globalConfig.provider) {
|
||||
throw new Error('Provider is not configured.');
|
||||
}
|
||||
|
||||
const baseCtx = initializeSession(cwd, 'instruct');
|
||||
const ctx: SessionContext = { ...baseCtx, lang, personaName: 'instruct' };
|
||||
|
||||
displayAndClearSessionState(cwd, ctx.lang);
|
||||
|
||||
const ui = getLabelObject<InstructUIText>('instruct.ui', ctx.lang);
|
||||
|
||||
const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
|
||||
hasPiecePreview: false,
|
||||
pieceStructure: '',
|
||||
movementDetails: '',
|
||||
});
|
||||
|
||||
const branchIntro = ctx.lang === 'ja'
|
||||
? `## ブランチ: ${branchName}\n\n${branchContext}`
|
||||
: `## Branch: ${branchName}\n\n${branchContext}`;
|
||||
|
||||
const introMessage = `${branchIntro}\n\n${ui.intro}`;
|
||||
|
||||
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
|
||||
|
||||
function injectPolicy(userMessage: string): string {
|
||||
const policyIntro = ctx.lang === 'ja'
|
||||
? '以下のポリシーは行動規範です。必ず遵守してください。'
|
||||
: 'The following policy defines behavioral guidelines. Please follow them.';
|
||||
const reminderLabel = ctx.lang === 'ja'
|
||||
? '上記の Policy セクションで定義されたポリシー規範を遵守してください。'
|
||||
: 'Please follow the policy guidelines defined in the Policy section above.';
|
||||
return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
|
||||
}
|
||||
|
||||
const strategy: ConversationStrategy = {
|
||||
systemPrompt,
|
||||
allowedTools: INSTRUCT_TOOLS,
|
||||
transformPrompt: injectPolicy,
|
||||
introMessage,
|
||||
selectAction: createSelectInstructAction(ui),
|
||||
};
|
||||
|
||||
const result = await runConversationLoop(cwd, ctx, strategy, undefined, undefined);
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
return { action: 'cancel', task: '' };
|
||||
}
|
||||
|
||||
return { action: result.action as InstructModeAction, task: result.task };
|
||||
}
|
||||
@ -19,13 +19,16 @@ import {
|
||||
autoCommitAndPush,
|
||||
type BranchListItem,
|
||||
} from '../../../infra/task/index.js';
|
||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||
import { selectOption } from '../../../shared/prompt/index.js';
|
||||
import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { executeTask } from '../execute/taskExecution.js';
|
||||
import type { TaskExecutionOptions } from '../execute/types.js';
|
||||
import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js';
|
||||
import { runInstructMode } from './instructMode.js';
|
||||
import { saveTaskFile } from '../add/index.js';
|
||||
import { selectPiece } from '../../pieceSelection/index.js';
|
||||
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
|
||||
|
||||
const log = createLogger('list-tasks');
|
||||
|
||||
@ -302,8 +305,8 @@ function getBranchContext(projectDir: string, branch: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruct branch: create a temp clone, give additional instructions,
|
||||
* auto-commit+push, then remove clone.
|
||||
* Instruct branch: create a temp clone, give additional instructions via
|
||||
* interactive conversation, then auto-commit+push or save as task file.
|
||||
*/
|
||||
export async function instructBranch(
|
||||
projectDir: string,
|
||||
@ -312,33 +315,58 @@ export async function instructBranch(
|
||||
): Promise<boolean> {
|
||||
const { branch } = item.info;
|
||||
|
||||
const instruction = await promptInput('Enter instruction');
|
||||
if (!instruction) {
|
||||
info('Cancelled');
|
||||
return false;
|
||||
}
|
||||
const branchContext = getBranchContext(projectDir, branch);
|
||||
const result = await runInstructMode(projectDir, branchContext, branch);
|
||||
let selectedPiece: string | null = null;
|
||||
|
||||
const selectedPiece = await selectPiece(projectDir);
|
||||
const ensurePieceSelected = async (): Promise<string | null> => {
|
||||
if (selectedPiece) {
|
||||
return selectedPiece;
|
||||
}
|
||||
selectedPiece = await selectPiece(projectDir);
|
||||
if (!selectedPiece) {
|
||||
info('Cancelled');
|
||||
return null;
|
||||
}
|
||||
return selectedPiece;
|
||||
};
|
||||
|
||||
return dispatchConversationAction(result, {
|
||||
cancel: () => {
|
||||
info('Cancelled');
|
||||
return false;
|
||||
},
|
||||
save_task: async ({ task }) => {
|
||||
const piece = await ensurePieceSelected();
|
||||
if (!piece) {
|
||||
return false;
|
||||
}
|
||||
const created = await saveTaskFile(projectDir, task, { piece });
|
||||
success(`Task saved: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
log.info('Task saved from instruct mode', { branch, piece });
|
||||
return true;
|
||||
},
|
||||
execute: async ({ task }) => {
|
||||
const piece = await ensurePieceSelected();
|
||||
if (!piece) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info('Instructing branch via temp clone', { branch, piece: selectedPiece });
|
||||
log.info('Instructing branch via temp clone', { branch, piece });
|
||||
info(`Running instruction on ${branch}...`);
|
||||
|
||||
const clone = createTempCloneForBranch(projectDir, branch);
|
||||
|
||||
try {
|
||||
const branchContext = getBranchContext(projectDir, branch);
|
||||
const fullInstruction = branchContext
|
||||
? `${branchContext}## 追加指示\n${instruction}`
|
||||
: instruction;
|
||||
? `${branchContext}## 追加指示\n${task}`
|
||||
: task;
|
||||
|
||||
const taskSuccess = await executeTask({
|
||||
task: fullInstruction,
|
||||
cwd: clone.path,
|
||||
pieceIdentifier: selectedPiece,
|
||||
pieceIdentifier: piece,
|
||||
projectCwd: projectDir,
|
||||
agentOverrides: options,
|
||||
});
|
||||
@ -362,4 +390,6 @@ export async function instructBranch(
|
||||
removeClone(clone.path);
|
||||
removeCloneMeta(projectDir, branch);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -68,6 +68,22 @@ piece:
|
||||
sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms"
|
||||
sigintForce: "Ctrl+C: Force exit"
|
||||
|
||||
# ===== Instruct Mode UI (takt list -> instruct) =====
|
||||
instruct:
|
||||
ui:
|
||||
intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)"
|
||||
resume: "Resuming previous session"
|
||||
noConversation: "No conversation yet. Please describe your instructions first."
|
||||
summarizeFailed: "Failed to summarize conversation. Please try again."
|
||||
continuePrompt: "Okay, continue describing your instructions."
|
||||
proposed: "Proposed additional instructions:"
|
||||
actionPrompt: "What would you like to do?"
|
||||
actions:
|
||||
execute: "Execute now"
|
||||
saveTask: "Save as Task"
|
||||
continue: "Continue editing"
|
||||
cancelled: "Cancelled"
|
||||
|
||||
run:
|
||||
notifyComplete: "Run complete ({total} tasks)"
|
||||
notifyAbort: "Run finished with errors ({failed})"
|
||||
|
||||
@ -68,6 +68,22 @@ piece:
|
||||
sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)"
|
||||
sigintForce: "Ctrl+C: 強制終了します"
|
||||
|
||||
# ===== Instruct Mode UI (takt list -> instruct) =====
|
||||
instruct:
|
||||
ui:
|
||||
intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)"
|
||||
resume: "前回のセッションを再開します"
|
||||
noConversation: "まだ会話がありません。まず追加指示を入力してください。"
|
||||
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
|
||||
continuePrompt: "続けて追加指示を入力してください。"
|
||||
proposed: "提案された追加指示:"
|
||||
actionPrompt: "どうしますか?"
|
||||
actions:
|
||||
execute: "実行する"
|
||||
saveTask: "タスクにつむ"
|
||||
continue: "会話を続ける"
|
||||
cancelled: "キャンセルしました"
|
||||
|
||||
run:
|
||||
notifyComplete: "run完了 ({total} tasks)"
|
||||
notifyAbort: "runはエラー終了 ({failed})"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user