対話ループのE2Eテスト追加とstdinシミュレーション共通化
parseMetaJsonの空ファイル・不正JSON耐性を修正し、実際のstdin入力を 再現するE2Eテスト(会話ルート20件、ランセッション連携6件)を追加。 3ファイルに散在していたstdinシミュレーションコードをhelpers/stdinSimulator.tsに集約。
This commit is contained in:
parent
620e384251
commit
85c845057e
176
src/__tests__/helpers/stdinSimulator.ts
Normal file
176
src/__tests__/helpers/stdinSimulator.ts
Normal file
@ -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<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
427
src/__tests__/it-interactive-routes.test.ts
Normal file
427
src/__tests__/it-interactive-routes.test.ts
Normal file
@ -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<Record<string, unknown>>()),
|
||||
createLogger: () => ({ info: vi.fn(), debug: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/context.js', () => ({
|
||||
isQuietMode: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadPersonaSessions: vi.fn(() => ({})),
|
||||
updatePersonaSession: vi.fn(),
|
||||
getProjectConfigDir: vi.fn(() => '/tmp'),
|
||||
loadSessionState: vi.fn(() => null),
|
||||
clearSessionState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
StreamDisplay: vi.fn().mockImplementation(() => ({
|
||||
createHandler: vi.fn(() => vi.fn()),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: vi.fn().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<typeof createScenarioProvider>[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();
|
||||
});
|
||||
});
|
||||
294
src/__tests__/it-run-session-instruct.test.ts
Normal file
294
src/__tests__/it-run-session-instruct.test.ts
Normal file
@ -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<Record<string, unknown>>()),
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/context.js', () => ({
|
||||
isQuietMode: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadPersonaSessions: vi.fn(() => ({})),
|
||||
updatePersonaSession: vi.fn(),
|
||||
getProjectConfigDir: vi.fn(() => '/tmp'),
|
||||
loadSessionState: vi.fn(() => null),
|
||||
clearSessionState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
StreamDisplay: vi.fn().mockImplementation(() => ({
|
||||
createHandler: vi.fn(() => vi.fn()),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
selectOption: vi.fn().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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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[] {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user