import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type { ResolvedCLIConfig } from '../src/providers/cli/presets'; import type { CLIRunResult } from '../src/providers/cli/process-runner'; // ESM-compatible mock setup const mockRunCLIProcess = jest.fn<(options: unknown) => Promise>(); jest.unstable_mockModule('../src/providers/cli/process-runner', () => ({ runCLIProcess: mockRunCLIProcess, })); jest.unstable_mockModule('../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), }, })); // Dynamic import after mocking const { CLIDuckProvider } = await import('../src/providers/cli/cli-provider'); function makeConfig(overrides: Partial = {}): ResolvedCLIConfig { return { cli_command: 'test-cli', prompt_delivery: 'flag', prompt_flag: '-p', output_format: 'text', cli_args: [], process_timeout: 30000, nickname: 'Test CLI', cli_type: 'custom', ...overrides, }; } describe('CLIDuckProvider', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('chat()', () => { it('should spawn CLI with correct flag-based prompt delivery', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'CLI response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig()); const result = await provider.chat({ messages: [{ role: 'user', content: 'Hello CLI', timestamp: new Date() }], }); expect(mockRunCLIProcess).toHaveBeenCalledWith( expect.objectContaining({ command: 'test-cli', args: expect.arrayContaining(['-p', 'Hello CLI']), }) ); expect(result.content).toBe('CLI response'); expect(result.finishReason).toBe('stop'); }); it('should not include prompt in args when flag delivery has no prompt_flag', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ prompt_delivery: 'flag', prompt_flag: undefined, })); await provider.chat({ messages: [{ role: 'user', content: 'my prompt', timestamp: new Date() }], }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; // Prompt should NOT appear in args since there's no flag to deliver it with expect(callArgs.args).not.toContain('my prompt'); }); it('should use positional prompt delivery', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ prompt_delivery: 'positional', })); await provider.chat({ messages: [{ role: 'user', content: 'my prompt', timestamp: new Date() }], }); expect(mockRunCLIProcess).toHaveBeenCalledWith( expect.objectContaining({ args: expect.arrayContaining(['my prompt']), }) ); }); it('should use stdin prompt delivery', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ prompt_delivery: 'stdin', })); await provider.chat({ messages: [{ role: 'user', content: 'stdin prompt', timestamp: new Date() }], }); expect(mockRunCLIProcess).toHaveBeenCalledWith( expect.objectContaining({ stdin: 'stdin prompt', }) ); }); it('should parse JSON output correctly', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: JSON.stringify({ result: 'json response' }), stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ output_format: 'json', response_json_path: '$.result', })); const result = await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(result.content).toBe('json response'); }); it('should parse JSONL output correctly', async () => { const stdout = [ JSON.stringify({ type: 'progress', message: 'working' }), JSON.stringify({ type: 'result', content: 'final answer' }), ].join('\n'); mockRunCLIProcess.mockResolvedValue({ stdout, stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ output_format: 'jsonl', })); const result = await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(result.content).toBe('final answer'); }); it('should surface CLI process errors as readable messages', async () => { mockRunCLIProcess.mockRejectedValue(new Error('CLI process "test-cli" exited with code 1: something went wrong')); const provider = new CLIDuckProvider('test', makeConfig()); await expect( provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }) ).rejects.toThrow(/Test CLI couldn't respond/); }); it('should handle non-Error thrown objects', async () => { mockRunCLIProcess.mockRejectedValue('string error'); const provider = new CLIDuckProvider('test', makeConfig()); await expect( provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }) ).rejects.toThrow(/Test CLI couldn't respond: string error/); }); it('should return empty string when messages array is empty', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig()); await provider.chat({ messages: [], }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; // The -p flag should have an empty string as prompt expect(callArgs.args).toContain('-p'); expect(callArgs.args).toContain(''); }); it('should include configured CLI args', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_args: ['--verbose', '--no-color'], })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(mockRunCLIProcess).toHaveBeenCalledWith( expect.objectContaining({ args: expect.arrayContaining(['--verbose', '--no-color']), }) ); }); it('should pass timeout and working directory', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ process_timeout: 60000, working_directory: '/custom/dir', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(mockRunCLIProcess).toHaveBeenCalledWith( expect.objectContaining({ timeout: 60000, cwd: '/custom/dir', }) ); }); it('should not duplicate system prompt for claude type with stdin delivery', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'claude', prompt_delivery: 'stdin', })); await provider.chat({ messages: [{ role: 'user', content: 'hello', timestamp: new Date() }], systemPrompt: 'Be helpful', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[]; stdin?: string }; // System prompt should go via --system-prompt flag expect(callArgs.args).toContain('--system-prompt'); expect(callArgs.args).toContain('Be helpful'); // stdin should NOT contain the system prompt (only the user prompt) expect(callArgs.stdin).toBe('hello'); }); it('should remove duplicate --system-prompt from cli_args for claude type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'claude', cli_args: ['--system-prompt', 'old prompt', '--verbose'], })); await provider.chat({ messages: [{ role: 'user', content: 'hello', timestamp: new Date() }], systemPrompt: 'New prompt', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; // Should have the new system prompt, not the old one expect(callArgs.args).toContain('New prompt'); expect(callArgs.args).not.toContain('old prompt'); // Other args preserved expect(callArgs.args).toContain('--verbose'); // Only one --system-prompt flag const spFlags = callArgs.args.filter(a => a === '--system-prompt'); expect(spFlags).toHaveLength(1); }); it('should prepend system prompt to stdin for non-claude type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'custom', prompt_delivery: 'stdin', })); await provider.chat({ messages: [{ role: 'user', content: 'hello', timestamp: new Date() }], systemPrompt: 'Be helpful', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { stdin?: string }; expect(callArgs.stdin).toBe('Be helpful\n\nhello'); }); it('should not add model flag for custom cli_type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'custom', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'some-model', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; // Custom cli_type has no model flag mapping, so model should not appear in args expect(callArgs.args).not.toContain('--model'); expect(callArgs.args).not.toContain('-m'); expect(callArgs.args).not.toContain('some-model'); }); it('should prepend system prompt to prompt for flag delivery non-claude type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'custom', prompt_delivery: 'flag', prompt_flag: '-p', })); await provider.chat({ messages: [{ role: 'user', content: 'hello', timestamp: new Date() }], systemPrompt: 'Be brief', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; // System prompt should be prepended to the prompt value expect(callArgs.args).toContain('-p'); const flagIdx = callArgs.args.indexOf('-p'); expect(callArgs.args[flagIdx + 1]).toBe('Be brief\n\nhello'); }); it('should use last message content when no user message exists', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig()); await provider.chat({ messages: [{ role: 'assistant', content: 'assistant msg', timestamp: new Date() }], }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; expect(callArgs.args).toContain('assistant msg'); }); it('should add --model flag for claude cli_type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'claude', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'claude-sonnet-4-20250514', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; expect(callArgs.args).toContain('--model'); expect(callArgs.args).toContain('claude-sonnet-4-20250514'); }); it('should reflect model override in response', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ default_model: 'default-model', })); const result = await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'override-model', }); expect(result.model).toBe('override-model'); }); it('should use default model in response when no override', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ default_model: 'my-default', })); const result = await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(result.model).toBe('my-default'); }); it('should use "cli" as model fallback when nothing configured', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig()); const result = await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(result.model).toBe('cli'); }); it('should add --model flag for codex cli_type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'codex', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'codex-mini', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; expect(callArgs.args).toContain('--model'); expect(callArgs.args).toContain('codex-mini'); }); it('should add --model flag for gemini cli_type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'gemini', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'gemini-pro', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; expect(callArgs.args).toContain('--model'); expect(callArgs.args).toContain('gemini-pro'); }); it('should add -m flag for grok cli_type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'grok', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'grok-2', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; expect(callArgs.args).toContain('-m'); expect(callArgs.args).toContain('grok-2'); }); it('should remove duplicate model flag from cli_args when model override is given', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); // Grok preset includes '-m grok-code-fast-1' in cli_args const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'grok', cli_args: ['-m', 'grok-code-fast-1', '--verbose'], })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'grok-2', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; // Should have the override model, not the default expect(callArgs.args).toContain('grok-2'); expect(callArgs.args).not.toContain('grok-code-fast-1'); // Other args should be preserved expect(callArgs.args).toContain('--verbose'); // Only one -m flag const mFlags = callArgs.args.filter(a => a === '-m'); expect(mFlags).toHaveLength(1); }); it('should add --model flag for aider cli_type', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'response', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ cli_type: 'aider', })); await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], model: 'gpt-4', }); const callArgs = mockRunCLIProcess.mock.calls[0][0] as { args: string[] }; expect(callArgs.args).toContain('--model'); expect(callArgs.args).toContain('gpt-4'); }); it('should return usage stats from JSON output', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: JSON.stringify({ result: 'hello', usage: { prompt_tokens: 10, completion_tokens: 20 }, }), stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig({ output_format: 'json', response_json_path: '$.result', usage_json_path: '$.usage', })); const result = await provider.chat({ messages: [{ role: 'user', content: 'test', timestamp: new Date() }], }); expect(result.usage).toEqual({ promptTokens: 10, completionTokens: 20, totalTokens: 30, }); }); }); describe('healthCheck()', () => { it('should return true on successful execution', async () => { mockRunCLIProcess.mockResolvedValue({ stdout: 'hello', stderr: '', exitCode: 0, }); const provider = new CLIDuckProvider('test', makeConfig()); expect(await provider.healthCheck()).toBe(true); }); it('should return false on error', async () => { mockRunCLIProcess.mockRejectedValue(new Error('command not found')); const provider = new CLIDuckProvider('test', makeConfig()); expect(await provider.healthCheck()).toBe(false); }); }); describe('listModels()', () => { it('should return configured models', async () => { const provider = new CLIDuckProvider('test', makeConfig({ models: ['model-a', 'model-b'], })); const models = await provider.listModels(); expect(models).toHaveLength(2); expect(models[0].id).toBe('model-a'); expect(models[1].id).toBe('model-b'); }); it('should return default model when no models configured', async () => { const provider = new CLIDuckProvider('test', makeConfig({ default_model: 'my-model', })); const models = await provider.listModels(); expect(models).toHaveLength(1); expect(models[0].id).toBe('my-model'); }); it('should return "default" when nothing is configured', async () => { const provider = new CLIDuckProvider('test', makeConfig()); const models = await provider.listModels(); expect(models).toHaveLength(1); expect(models[0].id).toBe('default'); }); }); describe('getInfo()', () => { it('should return type: cli with correct fields', () => { const provider = new CLIDuckProvider('test', makeConfig({ default_model: 'v1', cli_type: 'claude', })); const info = provider.getInfo(); expect(info.type).toBe('cli'); expect(info.name).toBe('test'); expect(info.nickname).toBe('Test CLI'); expect(info.model).toBe('v1'); expect(info.cliCommand).toBe('test-cli'); expect(info.cliType).toBe('claude'); }); it('should not include HTTP-specific fields', () => { const provider = new CLIDuckProvider('test', makeConfig()); const info = provider.getInfo(); expect(info.baseURL).toBeUndefined(); expect(info.hasApiKey).toBeUndefined(); }); }); });