Files
flynn/src/frontends/tui/minimal.test.ts
T
2026-02-24 13:26:40 -08:00

799 lines
24 KiB
TypeScript

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<ModelRouter, 'getTier' | 'getAvailableTiers' | 'setTier' | 'getLabel'> &
Partial<ModelRouter> &
Partial<ModelClient> & {
getLocalProviderName: () => string | undefined;
setLocalClient: ReturnType<typeof vi.fn>;
};
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<void>;
handleModelCommand: (tier: string, providerModel?: string) => void;
handleContextCommand: () => void;
handleVerboseCommand: () => void;
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleMessage: (content: string) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
clearSubmittedPromptLine: () => boolean;
prompt: (text: string) => Promise<string>;
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<void>;
handleModelCommand: (tier: string, providerModel?: string) => void;
handleContextCommand: () => void;
handleVerboseCommand: () => void;
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleMessage: (content: string) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
clearSubmittedPromptLine: () => boolean;
prompt: (text: string) => Promise<string>;
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<string, ModelConfig> = {
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 <tier> <provider/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 <tier> <provider/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();
}
});
});