import { describe, it, expect, vi, beforeEach } from 'vitest'; import { writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mergeModels, prefixLlamaSwapModels, clearProviderSnapshotCache, getProviderSnapshot, } from '../provider-snapshot.js'; import { loadProviderConfig } from '../provider-config-registry.js'; vi.mock('../acp-probe.js', () => ({ probeAcpProvider: vi.fn(), })); import { probeAcpProvider } from '../acp-probe.js'; const mockProbe = vi.mocked(probeAcpProvider); /** Write a temp coder-providers.json and point the resolved registry at it. */ function loadConfigFixture(providers: Record): void { const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`); writeFileSync(path, JSON.stringify({ providers }), 'utf8'); loadProviderConfig(path); } function mockSql(agents: Array<{ name: string; install_path: string | null; supports_acp: boolean; models: Array<{ id: string; label: string }> | null; label: string | null; transport: string | null; last_probed_at?: string | null; }>) { return vi.fn((strings: TemplateStringsArray) => { const query = strings.join(''); if (query.includes('FROM available_agents')) { return Promise.resolve(agents); } if (query.includes('UPDATE available_agents')) { return Promise.resolve([]); } return Promise.resolve([]); }) as unknown as import('../db.js').Sql; } const config = { LLAMA_SWAP_URL: 'http://llama-swap.test', PROVIDER_PROBE_TTL_MS: 86_400_000, } as import('../config.js').Config; describe('prefixLlamaSwapModels', () => { it('prefixes bare ids', () => { expect(prefixLlamaSwapModels([{ id: 'qwen3', label: 'qwen3' }])).toEqual([ { id: 'llama-swap/qwen3', label: 'qwen3' }, ]); }); it('leaves already-prefixed ids unchanged', () => { expect(prefixLlamaSwapModels([{ id: 'llama-swap/qwen3', label: 'qwen3' }])).toEqual([ { id: 'llama-swap/qwen3', label: 'qwen3' }, ]); }); }); describe('mergeModels', () => { it('dedupes by id preserving first occurrence', () => { const merged = mergeModels( [{ id: 'a', label: 'A' }], [{ id: 'a', label: 'A2' }, { id: 'b', label: 'B' }], ); expect(merged).toEqual([ { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, ]); }); }); describe('getProviderSnapshot', () => { beforeEach(() => { clearProviderSnapshotCache(); // Reset the resolved registry to built-ins-only (missing path → {} config). loadProviderConfig('/nonexistent-coder-providers.json'); vi.restoreAllMocks(); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'local-model' }, { id: 'llama-swap/existing' }], }), }), ); }); it('merges opencode ACP models with prefixed llama-swap models', async () => { mockProbe.mockResolvedValue({ ok: true, models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }], modes: [{ id: 'build', label: 'Build' }], defaultModeId: 'build', commands: [{ name: 'custom', description: 'From ACP probe' }], }); const sql = mockSql([ { name: 'opencode', install_path: '/usr/bin/opencode', supports_acp: true, models: null, label: 'OpenCode', transport: 'acp', }, ]); const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); const opencode = entries.find((e) => e.name === 'opencode'); expect(opencode?.models.map((m) => m.id)).toEqual([ 'opencode/big-pickle', 'llama-swap/local-model', 'llama-swap/existing', ]); expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true); expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true); }); it('combines qwen-shaped probe and settings model lists via mergeModels', () => { const merged = mergeModels( [{ id: 'qwen-probed', label: 'Qwen Probed' }], [{ id: 'from-settings', label: 'from-settings' }], ); expect(merged.map((m) => m.id)).toEqual(['qwen-probed', 'from-settings']); }); it('returns cached entries on second call within TTL', async () => { mockProbe.mockResolvedValue({ ok: true, models: [{ id: 'm1', label: 'M1' }], modes: [], defaultModeId: null, commands: [], }); const sql = mockSql([ { name: 'goose', install_path: '/usr/bin/goose', supports_acp: true, models: null, label: 'Goose', transport: 'acp', }, ]); await getProviderSnapshot(sql, config, '/tmp/cwd', true); await getProviderSnapshot(sql, config, '/tmp/cwd', false); expect(mockProbe).toHaveBeenCalledTimes(1); }); it('attaches claude thinking options', async () => { const sql = mockSql([ { name: 'claude', install_path: '/usr/bin/claude', supports_acp: false, models: [{ id: 'claude-sonnet', label: 'Sonnet' }], label: 'Claude Code', transport: 'pty', }, ]); const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); const claude = entries.find((e) => e.name === 'claude'); expect(claude?.models[0]?.thinkingOptions?.length).toBeGreaterThan(0); expect(claude?.modes.length).toBeGreaterThan(0); expect(claude?.commands.some((c) => c.name === 'help')).toBe(true); }); it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => { loadConfigFixture({ goose: { enabled: false } }); mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] }); const sql = mockSql([ { name: 'goose', install_path: '/usr/bin/goose', supports_acp: true, models: [{ id: 'g1', label: 'G1' }], label: 'Goose', transport: 'acp', last_probed_at: new Date().toISOString(), }, ]); const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); const goose = entries.find((e) => e.name === 'goose'); expect(goose?.status).toBe('unavailable'); expect(goose?.enabled).toBe(false); expect(goose?.installed).toBe(false); expect(mockProbe).not.toHaveBeenCalled(); }); it('uninstalled provider → unavailable + enabled:true + installed:false', async () => { loadConfigFixture({}); mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] }); const sql = mockSql([]); // nothing probed/installed const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); const opencode = entries.find((e) => e.name === 'opencode'); expect(opencode?.status).toBe('unavailable'); expect(opencode?.enabled).toBe(true); expect(opencode?.installed).toBe(false); expect(mockProbe).not.toHaveBeenCalled(); }); it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => { loadConfigFixture({}); // If this were wrongly called, cached-goose would be replaced and the // not.toHaveBeenCalled assertion would fail. mockProbe.mockResolvedValue({ ok: true, models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }], modes: [], defaultModeId: null, commands: [], }); const sql = mockSql([ { name: 'goose', install_path: '/usr/bin/goose', supports_acp: true, models: [{ id: 'cached-goose', label: 'Cached Goose' }], label: 'Goose', transport: 'acp', last_probed_at: new Date().toISOString(), // fresh }, ]); // force=false → cache-miss returns loading; second call joins the build / cache. await getProviderSnapshot(sql, config, '/tmp/cwd', false); const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false); const goose = entries.find((e) => e.name === 'goose'); expect(goose?.status).toBe('ready'); expect(goose?.installed).toBe(true); expect(goose?.models.map((m) => m.id)).toContain('cached-goose'); expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR'); expect(mockProbe).not.toHaveBeenCalled(); }); it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => { loadConfigFixture({}); mockProbe.mockResolvedValue({ ok: true, models: [{ id: 'fresh-probe', label: 'Fresh' }], modes: [], defaultModeId: null, commands: [], }); const sql = mockSql([ { name: 'goose', install_path: '/usr/bin/goose', supports_acp: true, models: [{ id: 'cached-goose', label: 'Cached' }], label: 'Goose', transport: 'acp', last_probed_at: new Date().toISOString(), // fresh, but force overrides }, ]); await getProviderSnapshot(sql, config, '/tmp/cwd', true); expect(mockProbe).toHaveBeenCalled(); }); it('native boocode → ready, enabled, installed', async () => { loadConfigFixture({}); const sql = mockSql([]); const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); const boocode = entries.find((e) => e.name === 'boocode'); expect(boocode?.status).toBe('ready'); expect(boocode?.enabled).toBe(true); expect(boocode?.installed).toBe(true); }); it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => { loadConfigFixture({ claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }], additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }], }, }); const sql = mockSql([ { name: 'claude', install_path: '/usr/bin/claude', supports_acp: false, models: [{ id: 'old-static', label: 'Old' }], label: 'Claude Code', transport: 'pty', last_probed_at: new Date().toISOString(), }, ]); const entries = await getProviderSnapshot(sql, config, '/tmp/project', true); const claude = entries.find((e) => e.name === 'claude'); const ids = claude!.models.map((m) => m.id); expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list expect(ids).toContain('sonnet'); // additionalModels merged on top expect(ids).not.toContain('old-static'); // replaced, not appended // thinking options still attach to the config-provided models expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0); }); it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => { loadConfigFixture({}); mockProbe.mockResolvedValue({ ok: true, models: [{ id: 'm1', label: 'M1' }], modes: [], defaultModeId: null, commands: [], }); const sql = mockSql([ { name: 'goose', install_path: '/usr/bin/goose', supports_acp: true, models: null, label: 'Goose', transport: 'acp', last_probed_at: null, }, ]); await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate const probeCallsAfterFirst = mockProbe.mock.calls.length; await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read const probeCallsAfterSecond = mockProbe.mock.calls.length; // Success criterion: second snapshot is served from cache with no ACP spawns. expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0); }); });