import { describe, it, expect, vi } from 'vitest'; import { formatPrompt, parseCommand } from './minimal.js'; import type { ModelConfig } from '../../config/schema.js'; import type { ManagedSession } from '../../session/index.js'; import type { ModelClient } from '../../models/types.js'; import type { ModelRouter } from '../../models/router.js'; import type { NativeAgent } from '../../backends/native/agent.js'; import { MinimalTui } from './minimal.js'; type TuiRouterStub = Pick & Partial & Partial & { getLocalProviderName: () => string | undefined; setLocalClient: ReturnType; }; function asSession(value: unknown): ManagedSession { return value as ManagedSession; } function asRouter(value: unknown): ModelClient & ModelRouter { return value as ModelClient & ModelRouter; } function asModelClient(value: unknown): ModelClient { return value as ModelClient; } function asAgent(value: unknown): NativeAgent { return value as NativeAgent; } function minimalTuiPrivates(value: MinimalTui): { handleBackendCommand: (provider: string) => Promise; handleModelCommand: (tier: string, providerModel?: string) => void; handleContextCommand: () => void; handleVerboseCommand: () => void; handleToolEvent: (event: unknown) => void; handleCommand: (command: unknown) => Promise; handleMessage: (content: string) => Promise; handleEscapeAction: () => boolean; handleCtrlCPress: (nowMs?: number) => boolean; clearSubmittedPromptLine: () => boolean; prompt: (text: string) => Promise; rl: { once: (event: string, cb: () => void) => void; removeListener: (event: string, cb: () => void) => void; question: (text: string, cb: (answer: string) => void) => void; write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void; prompt: () => void; }; activePromptCancel: (() => void) | null; activeOperationCancel: (() => void) | null; commandInFlight: boolean; running: boolean; } { return value as unknown as { handleBackendCommand: (provider: string) => Promise; handleModelCommand: (tier: string, providerModel?: string) => void; handleContextCommand: () => void; handleVerboseCommand: () => void; handleToolEvent: (event: unknown) => void; handleCommand: (command: unknown) => Promise; handleMessage: (content: string) => Promise; handleEscapeAction: () => boolean; handleCtrlCPress: (nowMs?: number) => boolean; clearSubmittedPromptLine: () => boolean; prompt: (text: string) => Promise; rl: { once: (event: string, cb: () => void) => void; removeListener: (event: string, cb: () => void) => void; question: (text: string, cb: (answer: string) => void) => void; write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void; prompt: () => void; }; activePromptCancel: (() => void) | null; activeOperationCancel: (() => void) | null; commandInFlight: boolean; running: boolean; }; } describe('formatPrompt', () => { it('formats default prompt', () => { const prompt = formatPrompt('default'); expect(prompt).toContain('flynn>'); }); it('formats thinking prompt', () => { const prompt = formatPrompt('thinking'); expect(prompt).toContain('flynn...'); }); }); describe('parseCommand (re-exported)', () => { it('parses /quit command', () => { const result = parseCommand('/quit'); expect(result).toEqual({ type: 'quit' }); }); it('parses /model command', () => { const result = parseCommand('/model local'); expect(result).toEqual({ type: 'model', name: 'local' }); }); it('parses regular message', () => { const result = parseCommand('Hello, Flynn!'); expect(result).toEqual({ type: 'message', content: 'Hello, Flynn!' }); }); it('returns null for empty input', () => { const result = parseCommand(''); expect(result).toBeNull(); }); }); describe('MinimalTui backend command', () => { it('switches local backend when provider is configured', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default', 'local'], setTier: vi.fn(() => true), getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), chat: vi.fn(), getClient: vi.fn(), }; const localProviders: Record = { llamacpp: { provider: 'llamacpp', model: '', endpoint: 'http://localhost:8080', }, }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter(mockRouter), modelRouter: asRouter(mockRouter), systemPrompt: 'test', localProviders, }); // Access private method for testing await minimalTuiPrivates(tui).handleBackendCommand('llamacpp'); expect(mockRouter.setLocalClient).toHaveBeenCalled(); }); it('syncs agent tier when /model command switches tier', () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default', 'local'], setTier: vi.fn(() => true), getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), chat: vi.fn(), getClient: vi.fn(), }; const mockAgent = { setModelTier: vi.fn(), getModelTier: vi.fn(() => 'default'), process: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter(mockRouter), modelRouter: asRouter(mockRouter), agent: asAgent(mockAgent), systemPrompt: 'test', }); // Call private handleModelCommand to switch to local minimalTuiPrivates(tui).handleModelCommand('local'); expect(mockRouter.setTier).toHaveBeenCalledWith('local'); expect(mockAgent.setModelTier).toHaveBeenCalledWith('local'); }); it('uses configured compaction threshold in /context output', () => { const mockSession = { id: 'test', getHistory: () => [{ role: 'user', content: 'x'.repeat(400) }], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default'], setTier: vi.fn(() => true), getLabel: () => 'gpt-4o', getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), chat: vi.fn(), getClient: vi.fn(), }; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter(mockRouter), modelRouter: asRouter(mockRouter), systemPrompt: 'test', contextThresholdPct: 67, }); minimalTuiPrivates(tui).handleContextCommand(); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('compaction threshold: 67%')); } finally { logSpy.mockRestore(); } }); it('reuses configured provider credentials for /model ', () => { const prevOpenRouterKey = process.env.OPENROUTER_API_KEY; delete process.env.OPENROUTER_API_KEY; try { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default', 'local'], setTier: vi.fn(() => true), getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), setClient: vi.fn(), setTierStrict: vi.fn(), chat: vi.fn(), getClient: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter(mockRouter), modelRouter: asRouter(mockRouter), systemPrompt: 'test', modelProviderConfigs: { openrouter: { provider: 'openrouter', model: 'seed-model', api_key: 'test-key', endpoint: 'https://openrouter.ai/api/v1', }, }, }); minimalTuiPrivates(tui).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat'); expect(mockRouter.setClient).toHaveBeenCalledOnce(); expect(mockRouter.setTierStrict).toHaveBeenCalledWith('default', true); expect(mockRouter.setTier).toHaveBeenCalledWith('default'); } finally { if (prevOpenRouterKey) { process.env.OPENROUTER_API_KEY = prevOpenRouterKey; } else { delete process.env.OPENROUTER_API_KEY; } } }); it('switches active tier and syncs agent for /model ', () => { const prevOpenRouterKey = process.env.OPENROUTER_API_KEY; delete process.env.OPENROUTER_API_KEY; try { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockRouter: TuiRouterStub = { getTier: () => 'fast' as const, getAvailableTiers: () => ['default', 'fast', 'local'], setTier: vi.fn(() => true), getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), setClient: vi.fn(), setTierStrict: vi.fn(), chat: vi.fn(), getClient: vi.fn(), }; const mockAgent = { setModelTier: vi.fn(), getModelTier: vi.fn(() => 'fast'), process: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter(mockRouter), modelRouter: asRouter(mockRouter), agent: asAgent(mockAgent), systemPrompt: 'test', modelProviderConfigs: { openrouter: { provider: 'openrouter', model: 'seed-model', api_key: 'test-key', endpoint: 'https://openrouter.ai/api/v1', }, }, }); minimalTuiPrivates(tui).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat'); expect(mockRouter.setTier).toHaveBeenCalledWith('default'); expect(mockAgent.setModelTier).toHaveBeenCalledWith('default'); } finally { if (prevOpenRouterKey) { process.env.OPENROUTER_API_KEY = prevOpenRouterKey; } else { delete process.env.OPENROUTER_API_KEY; } } }); it('prints transfer result text when /transfer is invoked', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const onTransfer = vi.fn(() => 'Session transferred to Telegram (12345)'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', onTransfer, }); await minimalTuiPrivates(tui).handleCommand({ type: 'transfer', target: 'telegram' }); expect(onTransfer).toHaveBeenCalledWith('telegram'); expect(logSpy).toHaveBeenCalledWith('Session transferred to Telegram (12345)\n'); } finally { logSpy.mockRestore(); } }); it('uses agent.reset for /reset when an agent is configured', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockAgent = { reset: vi.fn(), setModelTier: vi.fn(), getModelTier: vi.fn(() => 'default'), process: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', agent: asAgent(mockAgent), }); await minimalTuiPrivates(tui).handleCommand({ type: 'reset' }); expect(mockAgent.reset).toHaveBeenCalledOnce(); expect(mockSession.clear).not.toHaveBeenCalled(); }); it('prints tools output when /tools is invoked', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const onTools = vi.fn(() => 'Available tools (2):\n- file.read\n- council.run'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', onTools, }); await minimalTuiPrivates(tui).handleCommand({ type: 'tools' }); expect(onTools).toHaveBeenCalledOnce(); expect(logSpy).toHaveBeenCalledWith('Available tools (2):\n- file.read\n- council.run\n'); } finally { logSpy.mockRestore(); } }); it('forwards /runtime command through runtime command callback', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const onRuntimeCommand = vi.fn(async () => 'Backend mode: config_default'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', onRuntimeCommand, }); await minimalTuiPrivates(tui).handleCommand({ type: 'runtime', input: 'status' }); expect(onRuntimeCommand).toHaveBeenCalledWith('status'); expect(logSpy).toHaveBeenCalledWith('Backend mode: config_default\n'); } finally { logSpy.mockRestore(); } }); it('prints guidance when runtime command service is unavailable', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', }); await minimalTuiPrivates(tui).handleCommand({ type: 'runtime', input: 'status' }); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Runtime backend mode command service is unavailable in this TUI session.')); } finally { logSpy.mockRestore(); } }); it('keeps /backend status local-only and does not invoke runtime command callback', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const onRuntimeCommand = vi.fn(async () => 'should not be called'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', onRuntimeCommand, }); await minimalTuiPrivates(tui).handleCommand({ type: 'backend', provider: 'status' }); expect(onRuntimeCommand).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Backend switching not available.')); } finally { logSpy.mockRestore(); } }); it('collects multiline input from /paste and sends as one message', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', }); const promptSpy = vi.fn() .mockResolvedValueOnce('first line') .mockResolvedValueOnce('second line') .mockResolvedValueOnce('.'); minimalTuiPrivates(tui).prompt = promptSpy; const handleMessageSpy = vi.fn(async () => {}); minimalTuiPrivates(tui).handleMessage = handleMessageSpy; minimalTuiPrivates(tui).running = true; minimalTuiPrivates(tui).rl = { once: vi.fn(), removeListener: vi.fn(), question: vi.fn(), write: vi.fn(), prompt: vi.fn(), }; await minimalTuiPrivates(tui).handleCommand({ type: 'multiline' }); expect(handleMessageSpy).toHaveBeenCalledWith('first line\nsecond line'); expect(promptSpy).toHaveBeenCalledTimes(3); } finally { logSpy.mockRestore(); } }); it('only renders tool activity when verbose mode is enabled', () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asModelClient({}), systemPrompt: 'test', }); minimalTuiPrivates(tui).handleToolEvent({ type: 'start', tool: 'shell.exec', args: { command: 'ls' } }); expect(logSpy).not.toHaveBeenCalled(); minimalTuiPrivates(tui).handleVerboseCommand(); minimalTuiPrivates(tui).handleToolEvent({ type: 'start', tool: 'shell.exec', args: { command: 'ls' } }); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Shell: Exec')); } finally { logSpy.mockRestore(); } }); }); describe('MinimalTui prompt cancellation', () => { it('omits leading newline when submitted prompt line was cleared', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); try { const mockAgent = { process: vi.fn(async () => 'ok'), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter({}), agent: asAgent(mockAgent), systemPrompt: 'test', }); const clearSpy = vi.fn(() => true); minimalTuiPrivates(tui).clearSubmittedPromptLine = clearSpy; await minimalTuiPrivates(tui).handleCommand({ type: 'message', content: 'hello' }); expect(clearSpy).toHaveBeenCalledOnce(); const userHeader = writeSpy.mock.calls .map(([chunk]) => String(chunk)) .find((chunk) => chunk.includes('You')); expect(userHeader).toBeDefined(); expect(userHeader?.startsWith('\n')).toBe(false); } finally { writeSpy.mockRestore(); logSpy.mockRestore(); } }); it('cancels an active prompt without closing the TUI', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter({}), systemPrompt: 'test', }); let onAnswer: ((answer: string) => void) | undefined; const write = vi.fn((_: string | null, key?: { ctrl?: boolean; name?: string }) => { if (key?.name === 'return') { onAnswer?.(''); } }); minimalTuiPrivates(tui).rl = { once: vi.fn(), removeListener: vi.fn(), question: vi.fn((_text: string, cb: (answer: string) => void) => { onAnswer = cb; }), write, prompt: vi.fn(), }; const promptPromise = minimalTuiPrivates(tui).prompt('Confirm? '); expect(minimalTuiPrivates(tui).activePromptCancel).toBeTypeOf('function'); minimalTuiPrivates(tui).activePromptCancel?.(); await expect(promptPromise).resolves.toBe(''); expect(write).toHaveBeenCalledWith(null, { ctrl: true, name: 'u' }); expect(write).toHaveBeenCalledWith(null, { name: 'return' }); expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull(); }); it('returns empty string when readline is already closed during question', async () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter({}), systemPrompt: 'test', }); const questionError = new Error('readline was closed'); (questionError as Error & { code?: string }).code = 'ERR_USE_AFTER_CLOSE'; minimalTuiPrivates(tui).rl = { once: vi.fn(), removeListener: vi.fn(), question: vi.fn(() => { throw questionError; }), write: vi.fn(), prompt: vi.fn(), }; await expect(minimalTuiPrivates(tui).prompt('Confirm? ')).resolves.toBe(''); expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull(); }); it('uses Esc to cancel active running operation', () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter({}), systemPrompt: 'test', }); const cancelRunningOperation = vi.fn(); minimalTuiPrivates(tui).activeOperationCancel = cancelRunningOperation; expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true); expect(cancelRunningOperation).toHaveBeenCalledOnce(); }); it('treats first Ctrl+C as clear-input and second as exit intent', () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const write = vi.fn(); const prompt = vi.fn(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter({}), systemPrompt: 'test', }); minimalTuiPrivates(tui).rl = { once: vi.fn(), removeListener: vi.fn(), question: vi.fn(), write, prompt, }; minimalTuiPrivates(tui).running = true; const first = minimalTuiPrivates(tui).handleCtrlCPress(1000); const second = minimalTuiPrivates(tui).handleCtrlCPress(2000); expect(first).toBe(false); expect(second).toBe(true); expect(write).toHaveBeenCalledWith(null, { ctrl: true, name: 'u' }); expect(prompt).toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Press Ctrl+C again to quit')); } finally { logSpy.mockRestore(); } }); it('exits immediately on Ctrl+C when a command is in flight', () => { const mockSession = { id: 'test', getHistory: () => [], addMessage: vi.fn(), clear: vi.fn(), replaceHistory: vi.fn(), }; const write = vi.fn(); const prompt = vi.fn(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const tui = new MinimalTui({ session: asSession(mockSession), modelClient: asRouter({}), systemPrompt: 'test', }); minimalTuiPrivates(tui).rl = { once: vi.fn(), removeListener: vi.fn(), question: vi.fn(), write, prompt, }; minimalTuiPrivates(tui).running = true; minimalTuiPrivates(tui).commandInFlight = true; const cancel = vi.fn(); minimalTuiPrivates(tui).activeOperationCancel = cancel; const shouldExit = minimalTuiPrivates(tui).handleCtrlCPress(1000); expect(shouldExit).toBe(true); expect(cancel).toHaveBeenCalledOnce(); expect(write).not.toHaveBeenCalled(); expect(prompt).not.toHaveBeenCalled(); expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Press Ctrl+C again to quit')); } finally { logSpy.mockRestore(); } }); });