799 lines
24 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|