Fix: getProviderSnapshot returned synchronous installed:false 'loading' entries on a cache miss (v2.5.5/Phase 2), which AgentComposerBar filters out — with the Phase 5 client poll not yet built, a single fetch stranded on 'loading' and the picker showed no providers. It now awaits the build and returns terminal entries; the sync loading-return is deferred until Phase 5. Builds stay fast via the tier-2 cold-probe skip. Feature: wire the v2.3 config schema's models/additionalModels — buildResolvedRegistry carries them onto ResolvedProviderDef (models replace, additionalModels merge) and provider-snapshot applies them to every ready model list, so /data/coder-providers.json can edit any provider's models with no code change. Claude staticModels bumped from the stale 2-entry list to opus/sonnet/haiku latest-aliases + pinned claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5-20251001 (passed verbatim to claude --model). +2 tests (109 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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<string, unknown>): 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);
|
|
});
|
|
});
|