diff --git a/docs/plans/state.json b/docs/plans/state.json index d8ebe16..8ba6887 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3684,6 +3684,17 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/daemon/routing.test.ts + pnpm typecheck passing" + }, + "external-backend-runner-test-coverage": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added unit coverage for external CLI backend execution contract (argument inference, `{prompt}` substitution, empty-output failure path, and backend constructor defaults) across codex/claude_code/opencode/gemini adapters.", + "files_modified": [ + "src/backends/external.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/backends/external.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/backends/external.test.ts b/src/backends/external.test.ts new file mode 100644 index 0000000..f46897c --- /dev/null +++ b/src/backends/external.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { execFile } from 'child_process'; + +import { + ExternalCliBackend, + ClaudeCodeBackend, + OpenCodeBackend, + CodexBackend, + GeminiBackend, +} from './external.js'; + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +const mockExecFile = vi.mocked(execFile); + +describe('ExternalCliBackend', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses inferred -p args for codex backend', async () => { + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, 'ok', ''); + } + return {} as never; + }); + + const backend = new CodexBackend('/usr/bin/codex', ['run']); + const result = await backend.process({ prompt: 'hello', history: [] }); + + expect(result).toBe('ok'); + expect(mockExecFile).toHaveBeenCalledWith( + '/usr/bin/codex', + ['run', '-p', 'USER: hello'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('uses inferred --print args for claude_code backend', async () => { + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, 'ok', ''); + } + return {} as never; + }); + + const backend = new ClaudeCodeBackend('/usr/bin/claude', []); + await backend.process({ prompt: 'hello', history: [] }); + + expect(mockExecFile).toHaveBeenCalledWith( + '/usr/bin/claude', + ['--print', 'USER: hello'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('supports {prompt} substitution in configured args', async () => { + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, 'ok', ''); + } + return {} as never; + }); + + const backend = new ExternalCliBackend({ + name: 'opencode', + command: 'opencode', + args: ['--message', '{prompt}'], + }); + await backend.process({ + prompt: 'new question', + history: [{ role: 'assistant', content: 'previous reply' }], + }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'opencode', + ['--message', 'ASSISTANT: previous reply\n\nUSER: new question'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('throws when backend returns no output', async () => { + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, ' ', ''); + } + return {} as never; + }); + + const backend = new GeminiBackend('/usr/bin/gemini', []); + await expect(backend.process({ prompt: 'hello', history: [] })) + .rejects.toThrow('returned no output'); + }); + + it('constructs default commands for opencode and gemini backends', () => { + const opencode = new OpenCodeBackend(); + const gemini = new GeminiBackend(); + expect(opencode.name).toBe('opencode'); + expect(gemini.name).toBe('gemini'); + }); +});