diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b622913..41c53b1 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -45,6 +45,11 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), + selectInteractiveMode: vi.fn(() => 'assistant'), + passthroughMode: vi.fn(), + quietMode: vi.fn(), + personaMode: vi.fn(), + resolveLanguage: vi.fn(() => 'en'), })); vi.mock('../infra/config/index.js', () => ({ diff --git a/src/__tests__/interactive-mode.test.ts b/src/__tests__/interactive-mode.test.ts new file mode 100644 index 0000000..93c1028 --- /dev/null +++ b/src/__tests__/interactive-mode.test.ts @@ -0,0 +1,532 @@ +/** + * Tests for interactive mode variants (assistant, persona, quiet, passthrough) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────── + +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>()), + 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>()), + 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(), + selectOptionWithDefault: vi.fn(), +})); + +import { getProvider } from '../infra/providers/index.js'; +import { selectOptionWithDefault, selectOption } from '../shared/prompt/index.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockSelectOptionWithDefault = vi.mocked(selectOptionWithDefault); +const mockSelectOption = vi.mocked(selectOption); + +// ── Stdin helpers (same pattern as interactive.test.ts) ── + +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: 'interactive', + status: 'done' as const, + content: content!, + timestamp: new Date(), + }; + }); + const mockSetup = vi.fn(() => ({ call: mockCall })); + const mockProvider = { + setup: mockSetup, + _call: mockCall, + _setup: mockSetup, + }; + mockGetProvider.mockReturnValue(mockProvider); +} + +// ── Imports (after mocks) ── + +import { INTERACTIVE_MODES, DEFAULT_INTERACTIVE_MODE } from '../core/models/interactive-mode.js'; +import { selectInteractiveMode } from '../features/interactive/modeSelection.js'; +import { passthroughMode } from '../features/interactive/passthroughMode.js'; +import { quietMode } from '../features/interactive/quietMode.js'; +import { personaMode } from '../features/interactive/personaMode.js'; +import type { PieceContext } from '../features/interactive/interactive.js'; +import type { FirstMovementInfo } from '../infra/config/loaders/pieceResolver.js'; + +// ── Setup ── + +beforeEach(() => { + vi.clearAllMocks(); + mockSelectOptionWithDefault.mockResolvedValue('assistant'); + mockSelectOption.mockResolvedValue('execute'); +}); + +afterEach(() => { + restoreStdin(); +}); + +// ── InteractiveMode type & constants tests ── + +describe('InteractiveMode type', () => { + it('should define all four modes', () => { + expect(INTERACTIVE_MODES).toEqual(['assistant', 'persona', 'quiet', 'passthrough']); + }); + + it('should have assistant as default mode', () => { + expect(DEFAULT_INTERACTIVE_MODE).toBe('assistant'); + }); +}); + +// ── Mode selection tests ── + +describe('selectInteractiveMode', () => { + it('should call selectOptionWithDefault with four mode options', async () => { + // When + await selectInteractiveMode('en'); + + // Then + expect(mockSelectOptionWithDefault).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ value: 'assistant' }), + expect.objectContaining({ value: 'persona' }), + expect.objectContaining({ value: 'quiet' }), + expect.objectContaining({ value: 'passthrough' }), + ]), + 'assistant', + ); + }); + + it('should use piece default when provided', async () => { + // When + await selectInteractiveMode('en', 'quiet'); + + // Then + expect(mockSelectOptionWithDefault).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + 'quiet', + ); + }); + + it('should return null when user cancels', async () => { + // Given + mockSelectOptionWithDefault.mockResolvedValue(null); + + // When + const result = await selectInteractiveMode('en'); + + // Then + expect(result).toBeNull(); + }); + + it('should return selected mode value', async () => { + // Given + mockSelectOptionWithDefault.mockResolvedValue('persona'); + + // When + const result = await selectInteractiveMode('ja'); + + // Then + expect(result).toBe('persona'); + }); + + it('should present options in correct order', async () => { + // When + await selectInteractiveMode('en'); + + // Then + const options = mockSelectOptionWithDefault.mock.calls[0]?.[1] as Array<{ value: string }>; + expect(options?.[0]?.value).toBe('assistant'); + expect(options?.[1]?.value).toBe('persona'); + expect(options?.[2]?.value).toBe('quiet'); + expect(options?.[3]?.value).toBe('passthrough'); + }); +}); + +// ── Passthrough mode tests ── + +describe('passthroughMode', () => { + it('should return initialInput directly when provided', async () => { + // When + const result = await passthroughMode('en', 'my task text'); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('my task text'); + }); + + it('should return cancel when user sends EOF', async () => { + // Given + setupRawStdin(toRawInputs([null])); + + // When + const result = await passthroughMode('en'); + + // Then + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); + + it('should return cancel when user enters empty input', async () => { + // Given + setupRawStdin(toRawInputs([''])); + + // When + const result = await passthroughMode('en'); + + // Then + expect(result.action).toBe('cancel'); + }); + + it('should return user input as task when entered', async () => { + // Given + setupRawStdin(toRawInputs(['implement login feature'])); + + // When + const result = await passthroughMode('en'); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('implement login feature'); + }); + + it('should trim whitespace from user input', async () => { + // Given + setupRawStdin(toRawInputs([' my task '])); + + // When + const result = await passthroughMode('en'); + + // Then + expect(result.task).toBe('my task'); + }); +}); + +// ── Quiet mode tests ── + +describe('quietMode', () => { + it('should generate instructions from initialInput without questions', async () => { + // Given + setupMockProvider(['Generated task instruction for login feature.']); + mockSelectOption.mockResolvedValue('execute'); + + // When + const result = await quietMode('/project', 'implement login feature'); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('Generated task instruction for login feature.'); + }); + + it('should return cancel when user sends EOF for input', async () => { + // Given + setupRawStdin(toRawInputs([null])); + setupMockProvider([]); + + // When + const result = await quietMode('/project'); + + // Then + expect(result.action).toBe('cancel'); + }); + + it('should return cancel when user enters empty input', async () => { + // Given + setupRawStdin(toRawInputs([''])); + setupMockProvider([]); + + // When + const result = await quietMode('/project'); + + // Then + expect(result.action).toBe('cancel'); + }); + + it('should prompt for input when no initialInput is provided', async () => { + // Given + setupRawStdin(toRawInputs(['fix the bug'])); + setupMockProvider(['Fix the bug instruction.']); + mockSelectOption.mockResolvedValue('execute'); + + // When + const result = await quietMode('/project'); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('Fix the bug instruction.'); + }); + + it('should include piece context in summary generation', async () => { + // Given + const pieceContext: PieceContext = { + name: 'test-piece', + description: 'A test piece', + pieceStructure: '1. implement\n2. review', + movementPreviews: [], + }; + setupMockProvider(['Instruction with piece context.']); + mockSelectOption.mockResolvedValue('execute'); + + // When + const result = await quietMode('/project', 'some task', pieceContext); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('Instruction with piece context.'); + }); +}); + +// ── Persona mode tests ── + +describe('personaMode', () => { + const mockFirstMovement: FirstMovementInfo = { + personaContent: 'You are a senior coder. Write clean, maintainable code.', + personaDisplayName: 'Coder', + allowedTools: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'], + }; + + it('should return cancel when user types /cancel', async () => { + // Given + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + // When + const result = await personaMode('/project', mockFirstMovement); + + // Then + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); + + it('should return cancel on EOF', async () => { + // Given + setupRawStdin(toRawInputs([null])); + setupMockProvider([]); + + // When + const result = await personaMode('/project', mockFirstMovement); + + // Then + expect(result.action).toBe('cancel'); + }); + + it('should use first movement persona as system prompt', async () => { + // Given + setupRawStdin(toRawInputs(['fix bug', '/cancel'])); + setupMockProvider(['I see the issue.']); + + // When + await personaMode('/project', mockFirstMovement); + + // Then: the provider should be set up with persona content as system prompt + const mockProvider = mockGetProvider.mock.results[0]!.value as { _setup: ReturnType }; + expect(mockProvider._setup).toHaveBeenCalledWith( + expect.objectContaining({ + systemPrompt: 'You are a senior coder. Write clean, maintainable code.', + }), + ); + }); + + it('should use first movement allowed tools', async () => { + // Given + setupRawStdin(toRawInputs(['check the code', '/cancel'])); + setupMockProvider(['Looking at the code.']); + + // When + await personaMode('/project', mockFirstMovement); + + // Then + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + allowedTools: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'], + }), + ); + }); + + it('should process initialInput as first message', async () => { + // Given + setupRawStdin(toRawInputs(['/go'])); + setupMockProvider(['I analyzed the issue.', 'Task summary.']); + mockSelectOption.mockResolvedValue('execute'); + + // When + const result = await personaMode('/project', mockFirstMovement, 'fix the login'); + + // Then + expect(result.action).toBe('execute'); + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledTimes(2); + const firstPrompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(firstPrompt).toBe('fix the login'); + }); + + it('should handle /play command', async () => { + // Given + setupRawStdin(toRawInputs(['/play direct task text'])); + setupMockProvider([]); + + // When + const result = await personaMode('/project', mockFirstMovement); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('direct task text'); + }); + + it('should fall back to default tools when first movement has none', async () => { + // Given + const noToolsMovement: FirstMovementInfo = { + personaContent: 'Persona prompt', + personaDisplayName: 'Agent', + allowedTools: [], + }; + setupRawStdin(toRawInputs(['test', '/cancel'])); + setupMockProvider(['response']); + + // When + await personaMode('/project', noToolsMovement); + + // Then + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], + }), + ); + }); + + it('should handle multi-turn conversation before /go', async () => { + // Given + setupRawStdin(toRawInputs(['first message', 'second message', '/go'])); + setupMockProvider(['reply 1', 'reply 2', 'Final summary.']); + mockSelectOption.mockResolvedValue('execute'); + + // When + const result = await personaMode('/project', mockFirstMovement); + + // Then + expect(result.action).toBe('execute'); + expect(result.task).toBe('Final summary.'); + }); +}); diff --git a/src/__tests__/pieceResolver.test.ts b/src/__tests__/pieceResolver.test.ts index c58da23..a6b1c4a 100644 --- a/src/__tests__/pieceResolver.test.ts +++ b/src/__tests__/pieceResolver.test.ts @@ -563,3 +563,215 @@ movements: expect(result.movementPreviews[0].personaDisplayName).toBe('Custom Agent Name'); }); }); + +describe('getPieceDescription interactiveMode field', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-interactive-mode-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return interactiveMode when piece defines interactive_mode', () => { + const pieceYaml = `name: test-mode +initial_movement: step1 +max_iterations: 1 +interactive_mode: quiet + +movements: + - name: step1 + persona: agent + instruction: "Do something" +`; + + const piecePath = join(tempDir, 'test-mode.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.interactiveMode).toBe('quiet'); + }); + + it('should return undefined interactiveMode when piece omits interactive_mode', () => { + const pieceYaml = `name: test-no-mode +initial_movement: step1 +max_iterations: 1 + +movements: + - name: step1 + persona: agent + instruction: "Do something" +`; + + const piecePath = join(tempDir, 'test-no-mode.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.interactiveMode).toBeUndefined(); + }); + + it('should return interactiveMode for each valid mode value', () => { + for (const mode of ['assistant', 'persona', 'quiet', 'passthrough'] as const) { + const pieceYaml = `name: test-${mode} +initial_movement: step1 +max_iterations: 1 +interactive_mode: ${mode} + +movements: + - name: step1 + persona: agent + instruction: "Do something" +`; + + const piecePath = join(tempDir, `test-${mode}.yaml`); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.interactiveMode).toBe(mode); + } + }); +}); + +describe('getPieceDescription firstMovement field', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-first-movement-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return firstMovement with inline persona content', () => { + const pieceYaml = `name: test-first +initial_movement: plan +max_iterations: 1 + +movements: + - name: plan + persona: You are a planner. + persona_name: Planner + instruction: "Plan the task" + allowed_tools: + - Read + - Glob +`; + + const piecePath = join(tempDir, 'test-first.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.firstMovement).toBeDefined(); + expect(result.firstMovement!.personaContent).toBe('You are a planner.'); + expect(result.firstMovement!.personaDisplayName).toBe('Planner'); + expect(result.firstMovement!.allowedTools).toEqual(['Read', 'Glob']); + }); + + it('should return firstMovement with persona file content', () => { + const personaContent = '# Expert Planner\nYou plan tasks with precision.'; + const personaPath = join(tempDir, 'planner-persona.md'); + writeFileSync(personaPath, personaContent); + + const pieceYaml = `name: test-persona-file +initial_movement: plan +max_iterations: 1 + +personas: + planner: ./planner-persona.md + +movements: + - name: plan + persona: planner + persona_name: Planner + instruction: "Plan the task" +`; + + const piecePath = join(tempDir, 'test-persona-file.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.firstMovement).toBeDefined(); + expect(result.firstMovement!.personaContent).toBe(personaContent); + }); + + it('should return undefined firstMovement when initialMovement not found', () => { + const pieceYaml = `name: test-missing +initial_movement: nonexistent +max_iterations: 1 + +movements: + - name: step1 + persona: agent + instruction: "Do something" +`; + + const piecePath = join(tempDir, 'test-missing.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.firstMovement).toBeUndefined(); + }); + + it('should return empty allowedTools array when movement has no tools', () => { + const pieceYaml = `name: test-no-tools +initial_movement: step1 +max_iterations: 1 + +movements: + - name: step1 + persona: agent + persona_name: Agent + instruction: "Do something" +`; + + const piecePath = join(tempDir, 'test-no-tools.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.firstMovement).toBeDefined(); + expect(result.firstMovement!.allowedTools).toEqual([]); + }); + + it('should fallback to inline persona when personaPath is unreadable', () => { + const personaPath = join(tempDir, 'unreadable.md'); + writeFileSync(personaPath, '# Persona'); + chmodSync(personaPath, 0o000); + + const pieceYaml = `name: test-fallback +initial_movement: step1 +max_iterations: 1 + +personas: + myagent: ./unreadable.md + +movements: + - name: step1 + persona: myagent + persona_name: Agent + instruction: "Do something" +`; + + const piecePath = join(tempDir, 'test-fallback.yaml'); + writeFileSync(piecePath, pieceYaml); + + try { + const result = getPieceDescription(piecePath, tempDir); + + expect(result.firstMovement).toBeDefined(); + // personaPath is unreadable, so fallback to empty (persona was resolved to a path) + expect(result.firstMovement!.personaContent).toBe(''); + } finally { + chmodSync(personaPath, 0o644); + } + }); +}); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 279be15..53dff76 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -7,10 +7,19 @@ import { info, error } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; -import { interactiveMode } from '../../features/interactive/index.js'; +import { + interactiveMode, + selectInteractiveMode, + passthroughMode, + quietMode, + personaMode, + resolveLanguage, + type InteractiveModeResult, +} from '../../features/interactive/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; @@ -118,16 +127,57 @@ export async function executeDefaultAction(task?: string): Promise { } // All paths below go through interactive mode + const globalConfig = loadGlobalConfig(); + const lang = resolveLanguage(globalConfig.language); + const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); if (pieceId === null) { - info('Cancelled'); + info(getLabel('interactive.ui.cancelled', lang)); return; } - const globalConfig = loadGlobalConfig(); const previewCount = globalConfig.interactivePreviewMovements; - const pieceContext = getPieceDescription(pieceId, resolvedCwd, previewCount); - const result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + const pieceDesc = getPieceDescription(pieceId, resolvedCwd, previewCount); + + // Mode selection after piece selection + const selectedMode = await selectInteractiveMode(lang, pieceDesc.interactiveMode); + if (selectedMode === null) { + info(getLabel('interactive.ui.cancelled', lang)); + return; + } + + const pieceContext = { + name: pieceDesc.name, + description: pieceDesc.description, + pieceStructure: pieceDesc.pieceStructure, + movementPreviews: pieceDesc.movementPreviews, + }; + + let result: InteractiveModeResult; + + switch (selectedMode) { + case 'assistant': + result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + break; + + case 'passthrough': + result = await passthroughMode(lang, initialInput); + break; + + case 'quiet': + result = await quietMode(resolvedCwd, initialInput, pieceContext); + break; + + case 'persona': { + if (!pieceDesc.firstMovement) { + info(getLabel('interactive.ui.personaFallback', lang)); + result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + } else { + result = await personaMode(resolvedCwd, pieceDesc.firstMovement, initialInput, pieceContext); + } + break; + } + } switch (result.action) { case 'execute': diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 17abed1..166becd 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -35,6 +35,9 @@ export * from './config.js'; // Re-export from schemas.ts export * from './schemas.js'; +// Re-export from interactive-mode.ts +export { INTERACTIVE_MODES, DEFAULT_INTERACTIVE_MODE, type InteractiveMode } from './interactive-mode.js'; + // Re-export from session.ts (functions only, not types) export { createSessionState, diff --git a/src/core/models/interactive-mode.ts b/src/core/models/interactive-mode.ts new file mode 100644 index 0000000..733b477 --- /dev/null +++ b/src/core/models/interactive-mode.ts @@ -0,0 +1,18 @@ +/** + * Interactive mode variants for conversational task input. + * + * Defines the four modes available when using interactive mode: + * - assistant: Asks clarifying questions before generating instructions (default) + * - persona: Uses the first movement's persona for conversation + * - quiet: Generates instructions without asking questions (best-effort) + * - passthrough: Passes user input directly as task text + */ + +/** Available interactive mode variants */ +export const INTERACTIVE_MODES = ['assistant', 'persona', 'quiet', 'passthrough'] as const; + +/** Interactive mode type */ +export type InteractiveMode = typeof INTERACTIVE_MODES[number]; + +/** Default interactive mode */ +export const DEFAULT_INTERACTIVE_MODE: InteractiveMode = 'assistant'; diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 9fb954e..aec8147 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from './status.js'; import type { AgentResponse } from './response.js'; +import type { InteractiveMode } from './interactive-mode.js'; /** Rule-based transition configuration (unified format) */ export interface PieceRule { @@ -184,6 +185,8 @@ export interface PieceConfig { * instead of prompting the user interactively. */ answerAgent?: string; + /** Default interactive mode for this piece (overrides user default) */ + interactiveMode?: InteractiveMode; } /** Runtime state of a piece execution */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index cd39906..b68daf8 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -7,6 +7,7 @@ import { z } from 'zod/v4'; import { DEFAULT_LANGUAGE } from '../../shared/constants.js'; import { McpServersSchema } from './mcp-schemas.js'; +import { INTERACTIVE_MODES } from './interactive-mode.js'; export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js'; @@ -218,6 +219,9 @@ export const LoopMonitorSchema = z.object({ judge: LoopMonitorJudgeSchema, }); +/** Interactive mode schema for piece-level default */ +export const InteractiveModeSchema = z.enum(INTERACTIVE_MODES); + /** Piece configuration schema - raw YAML format */ export const PieceConfigRawSchema = z.object({ name: z.string().min(1), @@ -237,6 +241,8 @@ export const PieceConfigRawSchema = z.object({ max_iterations: z.number().int().positive().optional().default(10), loop_monitors: z.array(LoopMonitorSchema).optional(), answer_agent: z.string().optional(), + /** Default interactive mode for this piece (overrides user default) */ + interactive_mode: InteractiveModeSchema.optional(), }); /** Custom agent configuration schema */ diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts new file mode 100644 index 0000000..45c3989 --- /dev/null +++ b/src/features/interactive/conversationLoop.ts @@ -0,0 +1,300 @@ +/** + * Shared conversation loop for interactive modes (assistant & persona). + * + * Extracts the common patterns: + * - Provider/session initialization + * - AI call with retry on stale session + * - Session state display/clear + * - Conversation loop (slash commands, AI messaging, /go summary) + */ + +import chalk from 'chalk'; +import { + loadGlobalConfig, + loadPersonaSessions, + updatePersonaSession, + loadSessionState, + clearSessionState, +} from '../../infra/config/index.js'; +import { isQuietMode } from '../../shared/context.js'; +import { getProvider, type ProviderType } from '../../infra/providers/index.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; +import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; +import { readMultilineInput } from './lineEditor.js'; +import { + type PieceContext, + type InteractiveModeResult, + type InteractiveUIText, + type ConversationMessage, + resolveLanguage, + buildSummaryPrompt, + selectPostSummaryAction, + formatSessionStatus, +} from './interactive.js'; + +const log = createLogger('conversation-loop'); + +/** Result from a single AI call */ +export interface CallAIResult { + content: string; + sessionId?: string; + success: boolean; +} + +/** Initialized session context for conversation loops */ +export interface SessionContext { + provider: ReturnType; + providerType: ProviderType; + model: string | undefined; + lang: 'en' | 'ja'; + personaName: string; + sessionId: string | undefined; +} + +/** + * Initialize provider, session, and language for interactive conversation. + */ +export function initializeSession(cwd: string, personaName: string): SessionContext { + const globalConfig = loadGlobalConfig(); + const lang = resolveLanguage(globalConfig.language); + if (!globalConfig.provider) { + throw new Error('Provider is not configured.'); + } + const providerType = globalConfig.provider as ProviderType; + const provider = getProvider(providerType); + const model = globalConfig.model as string | undefined; + const savedSessions = loadPersonaSessions(cwd, providerType); + const sessionId: string | undefined = savedSessions[personaName]; + + return { provider, providerType, model, lang, personaName, sessionId }; +} + +/** + * Display and clear previous session state if present. + */ +export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): void { + const sessionState = loadSessionState(cwd); + if (sessionState) { + const statusLabel = formatSessionStatus(sessionState, lang); + info(statusLabel); + blankLine(); + clearSessionState(cwd); + } +} + +/** + * Call AI with automatic retry on stale/invalid session. + * + * On session failure, clears sessionId and retries once without session. + * Updates sessionId and persists it on success. + */ +export async function callAIWithRetry( + prompt: string, + systemPrompt: string, + allowedTools: string[], + cwd: string, + ctx: SessionContext, +): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { + const display = new StreamDisplay('assistant', isQuietMode()); + let { sessionId } = ctx; + + try { + const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); + const response = await agent.call(prompt, { + cwd, + model: ctx.model, + sessionId, + allowedTools, + onStream: display.createHandler(), + }); + display.flush(); + const success = response.status !== 'blocked'; + + if (!success && sessionId) { + log.info('Session invalid, retrying without session'); + sessionId = undefined; + const retryDisplay = new StreamDisplay('assistant', isQuietMode()); + const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); + const retry = await retryAgent.call(prompt, { + cwd, + model: ctx.model, + sessionId: undefined, + allowedTools, + onStream: retryDisplay.createHandler(), + }); + retryDisplay.flush(); + if (retry.sessionId) { + sessionId = retry.sessionId; + updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); + } + return { + result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' }, + sessionId, + }; + } + + if (response.sessionId) { + sessionId = response.sessionId; + updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); + } + return { + result: { content: response.content, sessionId: response.sessionId, success }, + sessionId, + }; + } catch (e) { + const msg = getErrorMessage(e); + log.error('AI call failed', { error: msg }); + error(msg); + blankLine(); + return { result: null, sessionId }; + } +} + +/** Strategy for customizing conversation loop behavior */ +export interface ConversationStrategy { + /** System prompt for AI calls */ + systemPrompt: string; + /** Allowed tools for AI calls */ + allowedTools: string[]; + /** Transform user message before sending to AI (e.g., policy injection) */ + transformPrompt: (userMessage: string) => string; + /** Intro message displayed at start */ + introMessage: string; +} + +/** + * Run the shared conversation loop. + * + * Handles: EOF, /play, /go (summary), /cancel, regular AI messaging. + * The Strategy object controls system prompt, tool access, and prompt transformation. + */ +export async function runConversationLoop( + cwd: string, + ctx: SessionContext, + strategy: ConversationStrategy, + pieceContext: PieceContext | undefined, + initialInput: string | undefined, +): Promise { + const history: ConversationMessage[] = []; + let sessionId = ctx.sessionId; + const ui = getLabelObject('interactive.ui', ctx.lang); + const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang); + const noTranscript = getLabel('interactive.noTranscript', ctx.lang); + + info(strategy.introMessage); + if (sessionId) { + info(ui.resume); + } + blankLine(); + + /** Helper: call AI with current session and update session state */ + async function doCallAI(prompt: string, sysPrompt: string, tools: string[]): Promise { + const { result, sessionId: newSessionId } = await callAIWithRetry( + prompt, sysPrompt, tools, cwd, { ...ctx, sessionId }, + ); + sessionId = newSessionId; + return result; + } + + if (initialInput) { + history.push({ role: 'user', content: initialInput }); + log.debug('Processing initial input', { initialInput, sessionId }); + + const promptWithTransform = strategy.transformPrompt(initialInput); + const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools); + if (result) { + if (!result.success) { + error(result.content); + blankLine(); + return { action: 'cancel', task: '' }; + } + history.push({ role: 'assistant', content: result.content }); + blankLine(); + } else { + history.pop(); + } + } + + while (true) { + const input = await readMultilineInput(chalk.green('> ')); + + if (input === null) { + blankLine(); + info(ui.cancelled); + return { action: 'cancel', task: '' }; + } + + const trimmed = input.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('/play')) { + const task = trimmed.slice(5).trim(); + if (!task) { + info(ui.playNoTask); + continue; + } + log.info('Play command', { task }); + return { action: 'execute', task }; + } + + if (trimmed.startsWith('/go')) { + const userNote = trimmed.slice(3).trim(); + let summaryPrompt = buildSummaryPrompt( + history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext, + ); + if (!summaryPrompt) { + info(ui.noConversation); + continue; + } + if (userNote) { + summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`; + } + const summaryResult = await doCallAI(summaryPrompt, summaryPrompt, strategy.allowedTools); + if (!summaryResult) { + info(ui.summarizeFailed); + continue; + } + if (!summaryResult.success) { + error(summaryResult.content); + blankLine(); + return { action: 'cancel', task: '' }; + } + const task = summaryResult.content.trim(); + const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui); + if (selectedAction === 'continue' || selectedAction === null) { + info(ui.continuePrompt); + continue; + } + log.info('Conversation action selected', { action: selectedAction, messageCount: history.length }); + return { action: selectedAction, task }; + } + + if (trimmed === '/cancel') { + info(ui.cancelled); + return { action: 'cancel', task: '' }; + } + + history.push({ role: 'user', content: trimmed }); + log.debug('Sending to AI', { messageCount: history.length, sessionId }); + process.stdin.pause(); + + const promptWithTransform = strategy.transformPrompt(trimmed); + const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools); + if (result) { + if (!result.success) { + error(result.content); + blankLine(); + history.pop(); + return { action: 'cancel', task: '' }; + } + history.push({ role: 'assistant', content: result.content }); + blankLine(); + } else { + history.pop(); + } + } +} diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index fc5c54b..66b5e9d 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -4,7 +4,17 @@ export { interactiveMode, + resolveLanguage, + buildSummaryPrompt, + selectPostSummaryAction, + formatMovementPreviews, + formatSessionStatus, type PieceContext, type InteractiveModeResult, type InteractiveModeAction, } from './interactive.js'; + +export { selectInteractiveMode } from './modeSelection.js'; +export { passthroughMode } from './passthroughMode.js'; +export { quietMode } from './quietMode.js'; +export { personaMode } from './personaMode.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 5afe470..0e67737 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -10,29 +10,23 @@ * /cancel - Cancel and exit */ -import chalk from 'chalk'; import type { Language } from '../../core/models/index.js'; import { - loadGlobalConfig, - loadPersonaSessions, - updatePersonaSession, - loadSessionState, - clearSessionState, type SessionState, type MovementPreview, } from '../../infra/config/index.js'; -import { isQuietMode } from '../../shared/context.js'; -import { getProvider, type ProviderType } from '../../infra/providers/index.js'; import { selectOption } from '../../shared/prompt/index.js'; -import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; -import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; +import { info, blankLine } from '../../shared/ui/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; -import { readMultilineInput } from './lineEditor.js'; -const log = createLogger('interactive'); +import { + initializeSession, + displayAndClearSessionState, + runConversationLoop, +} from './conversationLoop.js'; /** Shape of interactive UI text */ -interface InteractiveUIText { +export interface InteractiveUIText { intro: string; resume: string; noConversation: string; @@ -53,7 +47,7 @@ interface InteractiveUIText { /** * Format session state for display */ -function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string { +export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string { const lines: string[] = []; // Status line @@ -87,7 +81,7 @@ function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string { return lines.join('\n'); } -function resolveLanguage(lang?: Language): 'en' | 'ja' { +export function resolveLanguage(lang?: Language): 'en' | 'ja' { return lang === 'ja' ? 'ja' : 'en'; } @@ -122,37 +116,11 @@ export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' | }).join('\n\n'); } -function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) { - const hasPreview = !!pieceContext?.movementPreviews?.length; - const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, { - hasPiecePreview: hasPreview, - pieceStructure: pieceContext?.pieceStructure ?? '', - movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, lang) : '', - }); - const policyContent = loadTemplate('score_interactive_policy', lang, {}); - - return { - systemPrompt, - policyContent, - lang, - pieceContext, - conversationLabel: getLabel('interactive.conversationLabel', lang), - noTranscript: getLabel('interactive.noTranscript', lang), - ui: getLabelObject('interactive.ui', lang), - }; -} - -interface ConversationMessage { +export interface ConversationMessage { role: 'user' | 'assistant'; content: string; } -interface CallAIResult { - content: string; - sessionId?: string; - success: boolean; -} - /** * Build the final task description from conversation history for executeTask. */ @@ -167,7 +135,7 @@ function buildTaskFromHistory(history: ConversationMessage[]): string { * Renders the complete score_summary_system_prompt template with conversation data. * Returns empty string if there is no conversation to summarize. */ -function buildSummaryPrompt( +export function buildSummaryPrompt( history: ConversationMessage[], hasSession: boolean, lang: 'en' | 'ja', @@ -199,9 +167,9 @@ function buildSummaryPrompt( }); } -type PostSummaryAction = InteractiveModeAction | 'continue'; +export type PostSummaryAction = InteractiveModeAction | 'continue'; -async function selectPostSummaryAction( +export async function selectPostSummaryAction( task: string, proposedLabel: string, ui: InteractiveUIText, @@ -218,34 +186,6 @@ async function selectPostSummaryAction( ]); } -/** - * Call AI with the same pattern as piece execution. - * The key requirement is passing onStream — the Agent SDK requires - * includePartialMessages to be true for the async iterator to yield. - */ -async function callAI( - provider: ReturnType, - prompt: string, - cwd: string, - model: string | undefined, - sessionId: string | undefined, - display: StreamDisplay, - systemPrompt: string, -): Promise { - const agent = provider.setup({ name: 'interactive', systemPrompt }); - const response = await agent.call(prompt, { - cwd, - model, - sessionId, - allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], - onStream: display.createHandler(), - }); - - display.flush(); - const success = response.status !== 'blocked'; - return { content: response.content, sessionId: response.sessionId, success }; -} - export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; export interface InteractiveModeResult { @@ -266,6 +206,8 @@ export interface PieceContext { movementPreviews?: MovementPreview[]; } +export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + /** * Run the interactive task input mode. * @@ -280,206 +222,37 @@ export async function interactiveMode( initialInput?: string, pieceContext?: PieceContext, ): Promise { - const globalConfig = loadGlobalConfig(); - const lang = resolveLanguage(globalConfig.language); - const prompts = getInteractivePrompts(lang, pieceContext); - if (!globalConfig.provider) { - throw new Error('Provider is not configured.'); - } - const providerType = globalConfig.provider as ProviderType; - const provider = getProvider(providerType); - const model = (globalConfig.model as string | undefined); + const ctx = initializeSession(cwd, 'interactive'); - const history: ConversationMessage[] = []; - const personaName = 'interactive'; - const savedSessions = loadPersonaSessions(cwd, providerType); - let sessionId: string | undefined = savedSessions[personaName]; + displayAndClearSessionState(cwd, ctx.lang); - // Load and display previous task state - const sessionState = loadSessionState(cwd); - if (sessionState) { - const statusLabel = formatSessionStatus(sessionState, lang); - info(statusLabel); - blankLine(); - clearSessionState(cwd); - } - - info(prompts.ui.intro); - if (sessionId) { - info(prompts.ui.resume); - } - blankLine(); - - /** Call AI with automatic retry on session error (stale/invalid session ID). */ - async function callAIWithRetry(prompt: string, systemPrompt: string): Promise { - const display = new StreamDisplay('assistant', isQuietMode()); - try { - const result = await callAI( - provider, - prompt, - cwd, - model, - sessionId, - display, - systemPrompt, - ); - // If session failed, clear it and retry without session - if (!result.success && sessionId) { - log.info('Session invalid, retrying without session'); - sessionId = undefined; - const retryDisplay = new StreamDisplay('assistant', isQuietMode()); - const retry = await callAI( - provider, - prompt, - cwd, - model, - undefined, - retryDisplay, - systemPrompt, - ); - if (retry.sessionId) { - sessionId = retry.sessionId; - updatePersonaSession(cwd, personaName, sessionId, providerType); - } - return retry; - } - if (result.sessionId) { - sessionId = result.sessionId; - updatePersonaSession(cwd, personaName, sessionId, providerType); - } - return result; - } catch (e) { - const msg = getErrorMessage(e); - log.error('AI call failed', { error: msg }); - error(msg); - blankLine(); - return null; - } - } + const hasPreview = !!pieceContext?.movementPreviews?.length; + const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, { + hasPiecePreview: hasPreview, + pieceStructure: pieceContext?.pieceStructure ?? '', + movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '', + }); + const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); + const ui = getLabelObject('interactive.ui', ctx.lang); /** * Inject policy into user message for AI call. * Follows the same pattern as piece execution (perform_phase1_message.md). */ function injectPolicy(userMessage: string): string { - const policyIntro = lang === 'ja' + const policyIntro = ctx.lang === 'ja' ? '以下のポリシーは行動規範です。必ず遵守してください。' : 'The following policy defines behavioral guidelines. Please follow them.'; - const reminderLabel = lang === 'ja' + const reminderLabel = ctx.lang === 'ja' ? '上記の Policy セクションで定義されたポリシー規範を遵守してください。' : 'Please follow the policy guidelines defined in the Policy section above.'; - return `## Policy\n${policyIntro}\n\n${prompts.policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; + return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; } - // Process initial input if provided (e.g. from `takt a`) - if (initialInput) { - history.push({ role: 'user', content: initialInput }); - log.debug('Processing initial input', { initialInput, sessionId }); - - const promptWithPolicy = injectPolicy(initialInput); - const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt); - if (result) { - if (!result.success) { - error(result.content); - blankLine(); - return { action: 'cancel', task: '' }; - } - history.push({ role: 'assistant', content: result.content }); - blankLine(); - } else { - history.pop(); - } - } - - while (true) { - const input = await readMultilineInput(chalk.green('> ')); - - // EOF (Ctrl+D) - if (input === null) { - blankLine(); - info('Cancelled'); - return { action: 'cancel', task: '' }; - } - - const trimmed = input.trim(); - - // Empty input — skip - if (!trimmed) { - continue; - } - - // Handle slash commands - if (trimmed.startsWith('/play')) { - const task = trimmed.slice(5).trim(); - if (!task) { - info(prompts.ui.playNoTask); - continue; - } - log.info('Play command', { task }); - return { action: 'execute', task }; - } - - if (trimmed.startsWith('/go')) { - const userNote = trimmed.slice(3).trim(); - let summaryPrompt = buildSummaryPrompt( - history, - !!sessionId, - prompts.lang, - prompts.noTranscript, - prompts.conversationLabel, - prompts.pieceContext, - ); - if (!summaryPrompt) { - info(prompts.ui.noConversation); - continue; - } - if (userNote) { - summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`; - } - const summaryResult = await callAIWithRetry(summaryPrompt, summaryPrompt); - if (!summaryResult) { - info(prompts.ui.summarizeFailed); - continue; - } - if (!summaryResult.success) { - error(summaryResult.content); - blankLine(); - return { action: 'cancel', task: '' }; - } - const task = summaryResult.content.trim(); - const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui); - if (selectedAction === 'continue' || selectedAction === null) { - info(prompts.ui.continuePrompt); - continue; - } - log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length }); - return { action: selectedAction, task }; - } - - if (trimmed === '/cancel') { - info(prompts.ui.cancelled); - return { action: 'cancel', task: '' }; - } - - // Regular input — send to AI - history.push({ role: 'user', content: trimmed }); - - log.debug('Sending to AI', { messageCount: history.length, sessionId }); - process.stdin.pause(); - - const promptWithPolicy = injectPolicy(trimmed); - const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt); - if (result) { - if (!result.success) { - error(result.content); - blankLine(); - history.pop(); - return { action: 'cancel', task: '' }; - } - history.push({ role: 'assistant', content: result.content }); - blankLine(); - } else { - history.pop(); - } - } + return runConversationLoop(cwd, ctx, { + systemPrompt, + allowedTools: DEFAULT_INTERACTIVE_TOOLS, + transformPrompt: injectPolicy, + introMessage: ui.intro, + }, pieceContext, initialInput); } diff --git a/src/features/interactive/modeSelection.ts b/src/features/interactive/modeSelection.ts new file mode 100644 index 0000000..291809a --- /dev/null +++ b/src/features/interactive/modeSelection.ts @@ -0,0 +1,35 @@ +/** + * Interactive mode selection UI. + * + * Presents the four interactive mode options after piece selection + * and returns the user's choice. + */ + +import type { InteractiveMode } from '../../core/models/index.js'; +import { DEFAULT_INTERACTIVE_MODE, INTERACTIVE_MODES } from '../../core/models/index.js'; +import { selectOptionWithDefault } from '../../shared/prompt/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; + +/** + * Prompt the user to select an interactive mode. + * + * @param lang - Display language + * @param pieceDefault - Piece-level default mode (overrides user default) + * @returns Selected mode, or null if cancelled + */ +export async function selectInteractiveMode( + lang: 'en' | 'ja', + pieceDefault?: InteractiveMode, +): Promise { + const defaultMode = pieceDefault ?? DEFAULT_INTERACTIVE_MODE; + + const options: { label: string; value: InteractiveMode; description: string }[] = INTERACTIVE_MODES.map((mode) => ({ + label: getLabel(`interactive.modeSelection.${mode}`, lang), + value: mode, + description: getLabel(`interactive.modeSelection.${mode}Description`, lang), + })); + + const prompt = getLabel('interactive.modeSelection.prompt', lang); + + return selectOptionWithDefault(prompt, options, defaultMode); +} diff --git a/src/features/interactive/passthroughMode.ts b/src/features/interactive/passthroughMode.ts new file mode 100644 index 0000000..15343b7 --- /dev/null +++ b/src/features/interactive/passthroughMode.ts @@ -0,0 +1,50 @@ +/** + * Passthrough interactive mode. + * + * Passes user input directly as the task string without any + * AI-assisted instruction generation or system prompt injection. + */ + +import chalk from 'chalk'; +import { info, blankLine } from '../../shared/ui/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; +import { readMultilineInput } from './lineEditor.js'; +import type { InteractiveModeResult } from './interactive.js'; + +/** + * Run passthrough mode: collect user input and return it as-is. + * + * If initialInput is provided, it is used directly as the task. + * Otherwise, prompts the user for input. + * + * @param lang - Display language + * @param initialInput - Pre-filled input (e.g., from issue reference) + * @returns Result with the raw user input as task + */ +export async function passthroughMode( + lang: 'en' | 'ja', + initialInput?: string, +): Promise { + if (initialInput) { + return { action: 'execute', task: initialInput }; + } + + info(getLabel('interactive.ui.intro', lang)); + blankLine(); + + const input = await readMultilineInput(chalk.green('> ')); + + if (input === null) { + blankLine(); + info(getLabel('interactive.ui.cancelled', lang)); + return { action: 'cancel', task: '' }; + } + + const trimmed = input.trim(); + if (!trimmed) { + info(getLabel('interactive.ui.cancelled', lang)); + return { action: 'cancel', task: '' }; + } + + return { action: 'execute', task: trimmed }; +} diff --git a/src/features/interactive/personaMode.ts b/src/features/interactive/personaMode.ts new file mode 100644 index 0000000..da4eb96 --- /dev/null +++ b/src/features/interactive/personaMode.ts @@ -0,0 +1,58 @@ +/** + * Persona interactive mode. + * + * Uses the first movement's persona and tools for the interactive + * conversation. The persona acts as the conversational agent, + * performing code exploration and analysis while discussing the task. + * The conversation result is passed as the task to the piece. + */ + +import type { FirstMovementInfo } from '../../infra/config/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; +import { + type PieceContext, + type InteractiveModeResult, + DEFAULT_INTERACTIVE_TOOLS, +} from './interactive.js'; +import { + initializeSession, + displayAndClearSessionState, + runConversationLoop, +} from './conversationLoop.js'; + +/** + * Run persona mode: converse as the first movement's persona. + * + * The persona's system prompt is used for all AI calls. + * The first movement's allowed tools are made available. + * After the conversation, the result is summarized as a task. + * + * @param cwd - Working directory + * @param firstMovement - First movement's persona and tool info + * @param initialInput - Pre-filled input + * @param pieceContext - Piece context for summary generation + * @returns Result with conversation-derived task + */ +export async function personaMode( + cwd: string, + firstMovement: FirstMovementInfo, + initialInput?: string, + pieceContext?: PieceContext, +): Promise { + const ctx = initializeSession(cwd, 'persona-interactive'); + + displayAndClearSessionState(cwd, ctx.lang); + + const allowedTools = firstMovement.allowedTools.length > 0 + ? firstMovement.allowedTools + : DEFAULT_INTERACTIVE_TOOLS; + + const introMessage = `${getLabel('interactive.ui.intro', ctx.lang)} [${firstMovement.personaDisplayName}]`; + + return runConversationLoop(cwd, ctx, { + systemPrompt: firstMovement.personaContent, + allowedTools, + transformPrompt: (msg) => msg, + introMessage, + }, pieceContext, initialInput); +} diff --git a/src/features/interactive/quietMode.ts b/src/features/interactive/quietMode.ts new file mode 100644 index 0000000..13a8093 --- /dev/null +++ b/src/features/interactive/quietMode.ts @@ -0,0 +1,111 @@ +/** + * Quiet interactive mode. + * + * Generates task instructions without asking clarifying questions. + * Uses the same summarization logic as assistant mode but skips + * the conversational loop — goes directly to summary generation. + */ + +import chalk from 'chalk'; +import { createLogger } from '../../shared/utils/index.js'; +import { info, error, blankLine } from '../../shared/ui/index.js'; +import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; +import { readMultilineInput } from './lineEditor.js'; +import { + type PieceContext, + type InteractiveModeResult, + type InteractiveUIText, + type ConversationMessage, + DEFAULT_INTERACTIVE_TOOLS, + buildSummaryPrompt, + selectPostSummaryAction, +} from './interactive.js'; +import { + initializeSession, + callAIWithRetry, +} from './conversationLoop.js'; + +const log = createLogger('quiet-mode'); + +/** + * Run quiet mode: collect user input and generate instructions without questions. + * + * Flow: + * 1. If initialInput is provided, use it; otherwise prompt for input + * 2. Build summary prompt from the user input + * 3. Call AI to generate task instructions (best-effort, no questions) + * 4. Present the result and let user choose action + * + * @param cwd - Working directory + * @param initialInput - Pre-filled input (e.g., from issue reference) + * @param pieceContext - Piece context for template rendering + * @returns Result with generated task instructions + */ +export async function quietMode( + cwd: string, + initialInput?: string, + pieceContext?: PieceContext, +): Promise { + const ctx = initializeSession(cwd, 'interactive'); + + let userInput = initialInput; + + if (!userInput) { + info(getLabel('interactive.ui.intro', ctx.lang)); + blankLine(); + + const input = await readMultilineInput(chalk.green('> ')); + if (input === null) { + blankLine(); + info(getLabel('interactive.ui.cancelled', ctx.lang)); + return { action: 'cancel', task: '' }; + } + const trimmed = input.trim(); + if (!trimmed) { + info(getLabel('interactive.ui.cancelled', ctx.lang)); + return { action: 'cancel', task: '' }; + } + userInput = trimmed; + } + + const history: ConversationMessage[] = [ + { role: 'user', content: userInput }, + ]; + + const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang); + const noTranscript = getLabel('interactive.noTranscript', ctx.lang); + + const summaryPrompt = buildSummaryPrompt( + history, !!ctx.sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext, + ); + + if (!summaryPrompt) { + info(getLabel('interactive.ui.noConversation', ctx.lang)); + return { action: 'cancel', task: '' }; + } + + const { result } = await callAIWithRetry( + summaryPrompt, summaryPrompt, DEFAULT_INTERACTIVE_TOOLS, cwd, ctx, + ); + + if (!result) { + return { action: 'cancel', task: '' }; + } + + if (!result.success) { + error(result.content); + blankLine(); + return { action: 'cancel', task: '' }; + } + + const task = result.content.trim(); + const ui = getLabelObject('interactive.ui', ctx.lang); + + const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui); + if (selectedAction === 'continue' || selectedAction === null) { + return { action: 'cancel', task: '' }; + } + + log.info('Quiet mode action selected', { action: selectedAction }); + return { action: selectedAction, task }; +} diff --git a/src/infra/config/loaders/index.ts b/src/infra/config/loaders/index.ts index 4bd9f54..dca855a 100644 --- a/src/infra/config/loaders/index.ts +++ b/src/infra/config/loaders/index.ts @@ -13,6 +13,7 @@ export { listPieces, listPieceEntries, type MovementPreview, + type FirstMovementInfo, type PieceDirEntry, type PieceSource, type PieceWithSource, diff --git a/src/infra/config/loaders/pieceLoader.ts b/src/infra/config/loaders/pieceLoader.ts index 115adae..10fd8a3 100644 --- a/src/infra/config/loaders/pieceLoader.ts +++ b/src/infra/config/loaders/pieceLoader.ts @@ -21,6 +21,7 @@ export { listPieces, listPieceEntries, type MovementPreview, + type FirstMovementInfo, type PieceDirEntry, type PieceSource, type PieceWithSource, diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 7359f82..9b17bd6 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -280,6 +280,7 @@ export function normalizePieceConfig( maxIterations: parsed.max_iterations, loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context), answerAgent: parsed.answer_agent, + interactiveMode: parsed.interactive_mode, }; } diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 7709969..7a60f9d 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; -import type { PieceConfig, PieceMovement } from '../../../core/models/index.js'; +import type { PieceConfig, PieceMovement, InteractiveMode } from '../../../core/models/index.js'; import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js'; import { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; @@ -219,24 +219,10 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr const movement = movementMap.get(currentName); if (!movement) break; - let personaContent = ''; - if (movement.personaPath) { - try { - personaContent = readFileSync(movement.personaPath, 'utf-8'); - } catch (err) { - log.debug('Failed to read persona file for preview', { - path: movement.personaPath, - error: getErrorMessage(err), - }); - } - } else if (movement.persona) { - personaContent = movement.persona; - } - previews.push({ name: movement.name, personaDisplayName: movement.personaDisplayName, - personaContent, + personaContent: readMovementPersona(movement), instructionContent: movement.instructionTemplate, allowedTools: movement.allowedTools ?? [], canEdit: movement.edit === true, @@ -250,26 +236,86 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr return previews; } +/** + * Read persona content from a movement. + * When personaPath is set, reads from file (returns empty on failure). + * Otherwise uses inline persona string. + */ +function readMovementPersona(movement: PieceMovement): string { + if (movement.personaPath) { + try { + return readFileSync(movement.personaPath, 'utf-8'); + } catch (err) { + log.debug('Failed to read persona file', { + path: movement.personaPath, + error: getErrorMessage(err), + }); + return ''; + } + } + return movement.persona ?? ''; +} + +/** First movement info for persona mode */ +export interface FirstMovementInfo { + /** Persona prompt content */ + personaContent: string; + /** Persona display name */ + personaDisplayName: string; + /** Allowed tools for this movement */ + allowedTools: string[]; +} + /** * Get piece description by identifier. - * Returns the piece name, description, workflow structure, and optional movement previews. + * Returns the piece name, description, workflow structure, optional movement previews, + * piece-level interactive mode default, and first movement info for persona mode. */ export function getPieceDescription( identifier: string, projectCwd: string, previewCount?: number, -): { name: string; description: string; pieceStructure: string; movementPreviews: MovementPreview[] } { +): { + name: string; + description: string; + pieceStructure: string; + movementPreviews: MovementPreview[]; + interactiveMode?: InteractiveMode; + firstMovement?: FirstMovementInfo; +} { const piece = loadPieceByIdentifier(identifier, projectCwd); if (!piece) { return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] }; } + + const previews = previewCount && previewCount > 0 + ? buildMovementPreviews(piece, previewCount) + : []; + + const firstMovement = buildFirstMovementInfo(piece); + return { name: piece.name, description: piece.description ?? '', pieceStructure: buildWorkflowString(piece.movements), - movementPreviews: previewCount && previewCount > 0 - ? buildMovementPreviews(piece, previewCount) - : [], + movementPreviews: previews, + interactiveMode: piece.interactiveMode, + firstMovement, + }; +} + +/** + * Build first movement info for persona mode. + * Reads persona content from the initial movement. + */ +function buildFirstMovementInfo(piece: PieceConfig): FirstMovementInfo | undefined { + const movement = piece.movements.find((m) => m.name === piece.initialMovement); + if (!movement) return undefined; + + return { + personaContent: readMovementPersona(movement), + personaDisplayName: movement.personaDisplayName, + allowedTools: movement.allowedTools ?? [], }; } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 00387a3..1fdbba1 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -24,6 +24,17 @@ interactive: continue: "Continue editing" cancelled: "Cancelled" playNoTask: "Please specify task content: /play " + personaFallback: "No persona available for the first movement. Falling back to assistant mode." + modeSelection: + prompt: "Select interactive mode:" + assistant: "Assistant" + assistantDescription: "Ask clarifying questions before generating instructions" + persona: "Persona" + personaDescription: "Converse as the first agent's persona" + quiet: "Quiet" + quietDescription: "Generate instructions without asking questions" + passthrough: "Passthrough" + passthroughDescription: "Pass your input directly as task text" previousTask: success: "✅ Previous task completed successfully" error: "❌ Previous task failed: {error}" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index da7c810..21af472 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -24,6 +24,17 @@ interactive: continue: "会話を続ける" cancelled: "キャンセルしました" playNoTask: "タスク内容を指定してください: /play <タスク内容>" + personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。" + modeSelection: + prompt: "対話モードを選択してください:" + assistant: "アシスタント" + assistantDescription: "確認質問をしてから指示書を作成" + persona: "ペルソナ" + personaDescription: "先頭エージェントのペルソナで対話" + quiet: "クワイエット" + quietDescription: "質問なしでベストエフォートの指示書を生成" + passthrough: "パススルー" + passthroughDescription: "入力をそのままタスクとして渡す" previousTask: success: "✅ 前回のタスクは正常に完了しました" error: "❌ 前回のタスクはエラーで終了しました: {error}"