From 85c845057ec02049dd082e051141d571ed9bfac0 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:48:07 +0900 Subject: [PATCH] =?UTF-8?q?=E5=AF=BE=E8=A9=B1=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E3=81=AEE2E=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=A8stdin=E3=82=B7=E3=83=9F=E3=83=A5=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseMetaJsonの空ファイル・不正JSON耐性を修正し、実際のstdin入力を 再現するE2Eテスト(会話ルート20件、ランセッション連携6件)を追加。 3ファイルに散在していたstdinシミュレーションコードをhelpers/stdinSimulator.tsに集約。 --- src/__tests__/helpers/stdinSimulator.ts | 176 ++++++++ src/__tests__/instructMode.test.ts | 109 +---- src/__tests__/interactive.test.ts | 128 +----- src/__tests__/it-interactive-routes.test.ts | 427 ++++++++++++++++++ src/__tests__/it-run-session-instruct.test.ts | 294 ++++++++++++ src/features/interactive/runSessionReader.ts | 11 +- 6 files changed, 912 insertions(+), 233 deletions(-) create mode 100644 src/__tests__/helpers/stdinSimulator.ts create mode 100644 src/__tests__/it-interactive-routes.test.ts create mode 100644 src/__tests__/it-run-session-instruct.test.ts diff --git a/src/__tests__/helpers/stdinSimulator.ts b/src/__tests__/helpers/stdinSimulator.ts new file mode 100644 index 0000000..260b2c5 --- /dev/null +++ b/src/__tests__/helpers/stdinSimulator.ts @@ -0,0 +1,176 @@ +/** + * Stdin simulation helpers for testing interactive conversation loops. + * + * Simulates raw-mode TTY input by intercepting process.stdin events, + * feeding pre-defined input strings one-at-a-time as data events. + */ + +import { vi } from 'vitest'; + +interface SavedStdinState { + isTTY: boolean | undefined; + isRaw: boolean | undefined; + setRawMode: typeof process.stdin.setRawMode | undefined; + stdoutWrite: typeof process.stdout.write; + stdinOn: typeof process.stdin.on; + stdinRemoveListener: typeof process.stdin.removeListener; + stdinResume: typeof process.stdin.resume; + stdinPause: typeof process.stdin.pause; +} + +let saved: SavedStdinState | null = null; + +/** + * Set up raw stdin simulation with pre-defined inputs. + * + * Each string in rawInputs is delivered as a Buffer via 'data' event + * when the conversation loop registers a listener. + */ +export function setupRawStdin(rawInputs: string[]): void { + saved = { + isTTY: process.stdin.isTTY, + isRaw: process.stdin.isRaw, + setRawMode: process.stdin.setRawMode, + stdoutWrite: process.stdout.write, + stdinOn: process.stdin.on, + stdinRemoveListener: process.stdin.removeListener, + stdinResume: process.stdin.resume, + stdinPause: 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); +} + +/** + * Restore original stdin state after test. + */ +export function restoreStdin(): void { + if (!saved) return; + + if (saved.isTTY !== undefined) { + Object.defineProperty(process.stdin, 'isTTY', { value: saved.isTTY, configurable: true }); + } + if (saved.isRaw !== undefined) { + Object.defineProperty(process.stdin, 'isRaw', { value: saved.isRaw, configurable: true, writable: true }); + } + if (saved.setRawMode) process.stdin.setRawMode = saved.setRawMode; + if (saved.stdoutWrite) process.stdout.write = saved.stdoutWrite; + if (saved.stdinOn) process.stdin.on = saved.stdinOn; + if (saved.stdinRemoveListener) process.stdin.removeListener = saved.stdinRemoveListener; + if (saved.stdinResume) process.stdin.resume = saved.stdinResume; + if (saved.stdinPause) process.stdin.pause = saved.stdinPause; + + saved = null; +} + +/** + * Convert human-readable inputs to raw stdin data. + * + * Strings get a carriage return appended; null becomes EOF (Ctrl+D). + */ +export function toRawInputs(inputs: (string | null)[]): string[] { + return inputs.map((input) => { + if (input === null) return '\x04'; + return input + '\r'; + }); +} + +export interface MockProviderCapture { + systemPrompts: string[]; + callCount: number; + prompts: string[]; + sessionIds: Array; +} + +/** + * Create a mock provider that captures system prompts and returns + * pre-defined responses. Returns a capture object for assertions. + */ +export function createMockProvider(responses: string[]): { provider: unknown; capture: MockProviderCapture } { + return createScenarioProvider(responses.map((content) => ({ content }))); +} + +/** A single AI call scenario with configurable status and error behavior. */ +export interface CallScenario { + content: string; + status?: 'done' | 'blocked' | 'error'; + sessionId?: string; + throws?: Error; +} + +/** + * Create a mock provider with per-call scenario control. + * + * Each scenario controls what the AI returns for that call index. + * Captures system prompts, call arguments, and session IDs for assertions. + */ +export function createScenarioProvider(scenarios: CallScenario[]): { provider: unknown; capture: MockProviderCapture } { + const capture: MockProviderCapture = { systemPrompts: [], callCount: 0, prompts: [], sessionIds: [] }; + + const mockCall = vi.fn(async (prompt: string, options?: { sessionId?: string }) => { + const idx = capture.callCount; + capture.callCount++; + capture.prompts.push(prompt); + capture.sessionIds.push(options?.sessionId); + + const scenario = idx < scenarios.length + ? scenarios[idx]! + : { content: 'AI response' }; + + if (scenario.throws) { + throw scenario.throws; + } + + return { + persona: 'test', + status: scenario.status ?? ('done' as const), + content: scenario.content, + sessionId: scenario.sessionId, + timestamp: new Date(), + }; + }); + + const provider = { + setup: vi.fn(({ systemPrompt }: { systemPrompt: string }) => { + capture.systemPrompts.push(systemPrompt); + return { call: mockCall }; + }), + _call: mockCall, + }; + + return { provider, capture }; +} diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts index e9076a8..b0d9605 100644 --- a/src/__tests__/instructMode.test.ts +++ b/src/__tests__/instructMode.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupRawStdin, restoreStdin, toRawInputs, createMockProvider } from './helpers/stdinSimulator.js'; vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), @@ -83,113 +84,9 @@ const mockSelectOption = vi.mocked(selectOption); const mockInfo = vi.mocked(info); const mockLoadTemplate = vi.mocked(loadTemplate); -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); + const { provider } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); } beforeEach(() => { diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index cd3b623..dfa2781 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupRawStdin, restoreStdin, toRawInputs, createMockProvider } from './helpers/stdinSimulator.js'; vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), @@ -56,132 +57,9 @@ import { selectOption } from '../shared/prompt/index.js'; const mockGetProvider = vi.mocked(getProvider); const mockSelectOption = vi.mocked(selectOption); -// Store original stdin/stdout properties to restore -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; - -/** - * Captures the current data handler and provides sendData. - * - * When readMultilineInput registers process.stdin.on('data', handler), - * this captures the handler so tests can send raw input data. - * - * rawInputs: array of raw strings to send sequentially. Each time a new - * 'data' listener is registered, the next raw input is sent via queueMicrotask. - */ -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; - // Send next input when handler is registered - 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; - } -} - -/** - * Convert user-level inputs to raw stdin data. - * - * Each element is either: - * - A string: sent as typed characters + Enter (\r) - * - null: sent as Ctrl+D (\x04) - */ -function toRawInputs(inputs: (string | null)[]): string[] { - return inputs.map((input) => { - if (input === null) return '\x04'; - return input + '\r'; - }); -} - -/** Create a mock provider that returns given responses */ 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 mockProvider = { - setup: () => ({ call: mockCall }), - _call: mockCall, - }; - mockGetProvider.mockReturnValue(mockProvider); + const { provider } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); } beforeEach(() => { diff --git a/src/__tests__/it-interactive-routes.test.ts b/src/__tests__/it-interactive-routes.test.ts new file mode 100644 index 0000000..52bfb73 --- /dev/null +++ b/src/__tests__/it-interactive-routes.test.ts @@ -0,0 +1,427 @@ +/** + * E2E tests for interactive conversation loop routes. + * + * Exercises the real runConversationLoop via runInstructMode, + * simulating user stdin and verifying each conversation path. + * + * Real: runConversationLoop, callAIWithRetry, readMultilineInput, + * buildSummaryPrompt, selectPostSummaryAction + * Mocked: provider (scenario-based), config, UI, session persistence + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + createScenarioProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Infrastructure mocks (same pattern as instructMode.test.ts) --- + +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().mockResolvedValue('execute'), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task for /play', + cancelled: 'Cancelled', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { selectOption } from '../shared/prompt/index.js'; +import { error as logError } from '../shared/ui/index.js'; +import { runInstructMode } from '../features/tasks/list/instructMode.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockSelectOption = vi.mocked(selectOption); +const mockLogError = vi.mocked(logError); + +// --- Helpers --- + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +function setupScenarioProvider(...scenarios: Parameters[0]): MockProviderCapture { + const { provider, capture } = createScenarioProvider(scenarios); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +async function runInstruct() { + return runInstructMode('/test', '', 'takt/test-branch'); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSelectOption.mockResolvedValue('execute'); +}); + +afterEach(() => { + restoreStdin(); +}); + +// ================================================================= +// Route A: EOF (Ctrl+D) → cancel +// ================================================================= +describe('EOF handling', () => { + it('should cancel on Ctrl+D without any conversation', async () => { + setupRawStdin(toRawInputs([null])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); + + it('should cancel on Ctrl+D after some conversation', async () => { + setupRawStdin(toRawInputs(['hello', null])); + const capture = setupProvider(['Hi there.']); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(capture.callCount).toBe(1); + }); +}); + +// ================================================================= +// Route B: Empty input → skip, continue loop +// ================================================================= +describe('empty input handling', () => { + it('should skip empty lines and continue accepting input', async () => { + setupRawStdin(toRawInputs(['', ' ', '/cancel'])); + const capture = setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(capture.callCount).toBe(0); + }); +}); + +// ================================================================= +// Route C: /play → direct execute +// ================================================================= +describe('/play command', () => { + it('should return execute with the given task text', async () => { + setupRawStdin(toRawInputs(['/play fix the login bug'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('fix the login bug'); + }); + + it('should show error and continue when /play has no task', async () => { + setupRawStdin(toRawInputs(['/play', '/cancel'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + }); +}); + +// ================================================================= +// Route D: /go → summary flow +// ================================================================= +describe('/go summary flow', () => { + it('should summarize conversation and return execute', async () => { + // User: "add error handling" → AI: "What kind?" → /go → AI summary → execute + setupRawStdin(toRawInputs(['add error handling', '/go'])); + const capture = setupProvider(['What kind of error handling?', 'Add try-catch to all API calls.']); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Add try-catch to all API calls.'); + expect(capture.callCount).toBe(2); + }); + + it('should reject /go without prior conversation', async () => { + setupRawStdin(toRawInputs(['/go', '/cancel'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + }); + + it('should continue editing when user selects continue after /go', async () => { + setupRawStdin(toRawInputs(['task description', '/go', '/cancel'])); + setupProvider(['Understood.', 'Summary of task.']); + mockSelectOption.mockResolvedValueOnce('continue'); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + }); + + it('should return save_task when user selects save_task after /go', async () => { + setupRawStdin(toRawInputs(['implement feature', '/go'])); + setupProvider(['Got it.', 'Implement the feature.']); + mockSelectOption.mockResolvedValue('save_task'); + + const result = await runInstruct(); + + expect(result.action).toBe('save_task'); + expect(result.task).toBe('Implement the feature.'); + }); +}); + +// ================================================================= +// Route D2: /go with user note +// ================================================================= +describe('/go with user note', () => { + it('should append user note to summary prompt', async () => { + setupRawStdin(toRawInputs(['refactor auth', '/go also check security'])); + const capture = setupProvider(['Will do.', 'Refactor auth and check security.']); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Refactor auth and check security.'); + // /go summary call should include the user note in the prompt + expect(capture.prompts[1]).toContain('also check security'); + }); +}); + +// ================================================================= +// Route D3: /go summary AI returns null (call failure) +// ================================================================= +describe('/go summary AI failure', () => { + it('should show error and allow retry when summary AI throws', async () => { + // Turn 1: normal message → success + // Turn 2: /go → AI throws (summary fails) → "summarize failed" + // Turn 3: /cancel + setupRawStdin(toRawInputs(['describe task', '/go', '/cancel'])); + const capture = setupScenarioProvider( + { content: 'Understood.' }, + { content: '', throws: new Error('API timeout') }, + ); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(capture.callCount).toBe(2); + }); +}); + +// ================================================================= +// Route D4: /go summary AI returns blocked status +// ================================================================= +describe('/go summary AI blocked', () => { + it('should cancel when summary AI returns blocked', async () => { + setupRawStdin(toRawInputs(['some task', '/go'])); + setupScenarioProvider( + { content: 'OK.' }, + { content: 'Permission denied', status: 'blocked' }, + ); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(mockLogError).toHaveBeenCalledWith('Permission denied'); + }); +}); + +// ================================================================= +// Route E: /cancel +// ================================================================= +describe('/cancel command', () => { + it('should cancel immediately', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + }); + + it('should cancel mid-conversation', async () => { + setupRawStdin(toRawInputs(['hello', 'world', '/cancel'])); + const capture = setupProvider(['Hi.', 'Hello again.']); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(capture.callCount).toBe(2); + }); +}); + +// ================================================================= +// Route F: Regular messages → AI conversation +// ================================================================= +describe('regular conversation', () => { + it('should handle multi-turn conversation ending with /go', async () => { + setupRawStdin(toRawInputs([ + 'I need to add pagination', + 'Use cursor-based pagination', + 'Also add sorting', + '/go', + ])); + const capture = setupProvider([ + 'What kind of pagination?', + 'Cursor-based is a good choice.', + 'OK, pagination with sorting.', + 'Add cursor-based pagination and sorting to the API.', + ]); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Add cursor-based pagination and sorting to the API.'); + expect(capture.callCount).toBe(4); + }); +}); + +// ================================================================= +// Route F2: Regular message AI returns blocked +// ================================================================= +describe('regular message AI blocked', () => { + it('should cancel when regular message AI returns blocked', async () => { + setupRawStdin(toRawInputs(['hello'])); + setupScenarioProvider( + { content: 'Rate limited', status: 'blocked' }, + ); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(mockLogError).toHaveBeenCalledWith('Rate limited'); + }); +}); + +// ================================================================= +// Route G: /play command with empty task shows error +// ================================================================= +describe('/play empty task error', () => { + it('should show error message when /play has no argument', async () => { + setupRawStdin(toRawInputs(['/play', '/play ', '/cancel'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + // /play with no task should not trigger any AI calls + }); +}); + +// ================================================================= +// Session management: new sessionId propagates across calls +// ================================================================= +describe('session propagation', () => { + it('should use sessionId from first call in subsequent calls', async () => { + setupRawStdin(toRawInputs(['first message', 'second message', '/go'])); + const capture = setupScenarioProvider( + { content: 'Response 1.', sessionId: 'session-abc' }, + { content: 'Response 2.' }, + { content: 'Final summary.' }, + ); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Final summary.'); + // Second call should receive the sessionId from first call + expect(capture.sessionIds[1]).toBe('session-abc'); + }); +}); + +// ================================================================= +// Policy injection: transformPrompt wraps user input +// ================================================================= +describe('policy injection', () => { + it('should wrap user messages with policy content', async () => { + setupRawStdin(toRawInputs(['fix the bug', '/cancel'])); + const capture = setupProvider(['OK.']); + + await runInstructMode('/test', '', 'takt/test'); + + // The prompt sent to AI should contain Policy section + expect(capture.prompts[0]).toContain('Policy'); + expect(capture.prompts[0]).toContain('fix the bug'); + expect(capture.prompts[0]).toContain('Policy Reminder'); + }); +}); + +// ================================================================= +// System prompt: branch name appears in intro +// ================================================================= +describe('branch context', () => { + it('should include branch name and context in intro', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupProvider([]); + + const { info: mockInfo } = await import('../shared/ui/index.js'); + + await runInstructMode( + '/test', + '## Changes\n```\nsrc/auth.ts | 50 +++\n```', + 'takt/feature-auth', + ); + + const introCall = vi.mocked(mockInfo).mock.calls.find((call) => + call[0]?.includes('takt/feature-auth'), + ); + expect(introCall).toBeDefined(); + }); +}); diff --git a/src/__tests__/it-run-session-instruct.test.ts b/src/__tests__/it-run-session-instruct.test.ts new file mode 100644 index 0000000..b478029 --- /dev/null +++ b/src/__tests__/it-run-session-instruct.test.ts @@ -0,0 +1,294 @@ +/** + * E2E test: Run session loading → interactive instruct mode → prompt injection. + * + * Simulates the full interactive flow: + * 1. Create .takt/runs/ fixtures on real file system + * 2. Load run session with real listRecentRuns / loadRunSessionContext + * 3. Run instruct mode with stdin simulation (user types message → /go) + * 4. Mock provider captures the system prompt sent to AI + * 5. Verify run session data appears in the system prompt + * + * Real: listRecentRuns, loadRunSessionContext, formatRunSessionForPrompt, + * loadTemplate, runConversationLoop (actual conversation loop) + * Mocked: provider (captures system prompt), config, UI, session persistence + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Mocks (infrastructure only, not core logic) --- + +vi.mock('../infra/fs/session.js', () => ({ + loadNdjsonLog: vi.fn(), +})); + +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().mockResolvedValue('execute'), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Instruct intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task', + cancelled: 'Cancelled', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { loadNdjsonLog } from '../infra/fs/session.js'; +import { + listRecentRuns, + loadRunSessionContext, +} from '../features/interactive/runSessionReader.js'; +import { runInstructMode } from '../features/tasks/list/instructMode.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockLoadNdjsonLog = vi.mocked(loadNdjsonLog); + +// --- Fixture helpers --- + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createRunFixture( + cwd: string, + slug: string, + overrides?: { + meta?: Record; + reports?: Array<{ name: string; content: string }>; + emptyMeta?: boolean; + corruptMeta?: boolean; + }, +): void { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(join(runDir, 'logs'), { recursive: true }); + mkdirSync(join(runDir, 'reports'), { recursive: true }); + + if (overrides?.emptyMeta) { + writeFileSync(join(runDir, 'meta.json'), '', 'utf-8'); + } else if (overrides?.corruptMeta) { + writeFileSync(join(runDir, 'meta.json'), '{ broken json', 'utf-8'); + } else { + const meta = { + task: `Task for ${slug}`, + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + ...overrides?.meta, + }; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); + } + + writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8'); + + for (const report of overrides?.reports ?? []) { + writeFileSync(join(runDir, 'reports', report.name), report.content, 'utf-8'); + } +} + +function setupMockNdjsonLog(history: Array<{ step: string; persona: string; status: string; content: string }>): void { + mockLoadNdjsonLog.mockReturnValue({ + task: 'mock', + projectDir: '', + pieceName: 'default', + iterations: history.length, + startTime: '2026-02-01T00:00:00.000Z', + status: 'completed', + history: history.map((h) => ({ + ...h, + instruction: '', + timestamp: '2026-02-01T00:00:00.000Z', + })), + }); +} + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +// --- Tests --- + +describe('E2E: Run session → instruct mode with interactive flow', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreStdin(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should inject run session data into system prompt during interactive conversation', async () => { + // Fixture: run with movement logs and reports + createRunFixture(tmpDir, 'run-auth', { + meta: { task: 'Implement JWT auth' }, + reports: [ + { name: '00-plan.md', content: '# Plan\n\nJWT auth with refresh tokens.' }, + ], + }); + setupMockNdjsonLog([ + { step: 'plan', persona: 'architect', status: 'completed', content: 'Planned JWT auth flow' }, + { step: 'implement', persona: 'coder', status: 'completed', content: 'Created auth middleware' }, + ]); + + // Load run session (real code) + const context = loadRunSessionContext(tmpDir, 'run-auth'); + + // Simulate: user types "fix the token expiry" → /go → AI summarizes → user selects execute + setupRawStdin(toRawInputs(['fix the token expiry', '/go'])); + const capture = setupProvider(['Sure, I can help with that.', 'Fix token expiry handling in auth middleware.']); + + const result = await runInstructMode( + tmpDir, + '## Branch: takt/fix-auth\n', + 'takt/fix-auth', + { name: 'default', description: '', pieceStructure: '', movementPreviews: [] }, + context, + ); + + // Verify: system prompt contains run session data + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).toContain('Previous Run Reference'); + expect(systemPrompt).toContain('Implement JWT auth'); + expect(systemPrompt).toContain('Planned JWT auth flow'); + expect(systemPrompt).toContain('Created auth middleware'); + expect(systemPrompt).toContain('00-plan.md'); + expect(systemPrompt).toContain('JWT auth with refresh tokens'); + + // Verify: interactive flow completed with execute action + expect(result.action).toBe('execute'); + expect(result.task).toBe('Fix token expiry handling in auth middleware.'); + + // Verify: AI was called twice (user message + /go summary) + expect(capture.callCount).toBe(2); + }); + + it('should produce system prompt without run section when no context', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupProvider([]); + + const result = await runInstructMode(tmpDir, '', 'takt/fix', undefined, undefined); + + expect(result.action).toBe('cancel'); + }); + + it('should cancel cleanly mid-conversation with run session', async () => { + createRunFixture(tmpDir, 'run-1'); + setupMockNdjsonLog([]); + + const context = loadRunSessionContext(tmpDir, 'run-1'); + + setupRawStdin(toRawInputs(['some thought', '/cancel'])); + const capture = setupProvider(['I understand.']); + + const result = await runInstructMode( + tmpDir, '', 'takt/branch', undefined, context, + ); + + expect(result.action).toBe('cancel'); + // AI was called once for "some thought", then /cancel exits + expect(capture.callCount).toBe(1); + }); + + it('should skip empty and corrupt meta.json in listRecentRuns', () => { + createRunFixture(tmpDir, 'valid-run'); + createRunFixture(tmpDir, 'empty-meta', { emptyMeta: true }); + createRunFixture(tmpDir, 'corrupt-meta', { corruptMeta: true }); + + const runs = listRecentRuns(tmpDir); + expect(runs).toHaveLength(1); + expect(runs[0]!.slug).toBe('valid-run'); + }); + + it('should sort runs by startTime descending', () => { + createRunFixture(tmpDir, 'old', { meta: { startTime: '2026-01-01T00:00:00Z' } }); + createRunFixture(tmpDir, 'new', { meta: { startTime: '2026-02-15T00:00:00Z' } }); + + const runs = listRecentRuns(tmpDir); + expect(runs[0]!.slug).toBe('new'); + expect(runs[1]!.slug).toBe('old'); + }); + + it('should truncate long movement content to 500 chars', () => { + createRunFixture(tmpDir, 'long'); + setupMockNdjsonLog([ + { step: 'implement', persona: 'coder', status: 'completed', content: 'X'.repeat(800) }, + ]); + + const context = loadRunSessionContext(tmpDir, 'long'); + expect(context.movementLogs[0]!.content.length).toBe(501); + expect(context.movementLogs[0]!.content.endsWith('…')).toBe(true); + }); +}); diff --git a/src/features/interactive/runSessionReader.ts b/src/features/interactive/runSessionReader.ts index 02c980b..3e7a293 100644 --- a/src/features/interactive/runSessionReader.ts +++ b/src/features/interactive/runSessionReader.ts @@ -69,8 +69,15 @@ function parseMetaJson(metaPath: string): MetaJson | null { if (!existsSync(metaPath)) { return null; } - const raw = readFileSync(metaPath, 'utf-8'); - return JSON.parse(raw) as MetaJson; + const raw = readFileSync(metaPath, 'utf-8').trim(); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as MetaJson; + } catch { + return null; + } } function buildMovementLogs(sessionLog: SessionLog): MovementLogEntry[] {