Compare commits

...

3 Commits

Author SHA1 Message Date
fe52250d78 coder(providers): fix empty picker (loading-state) + config model overrides + current Claude models
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>
2026-05-29 12:37:01 +00:00
4035aa2b98 coder(providers): v2.3 provider-lifecycle phase 3 — generic ACP dispatch
ACP dispatch now spawns from the resolved registry's launch spec instead of a hardcoded per-name switch. acp-spawn.ts gains resolveLaunchSpec(resolved, installPath): launchCommand (config override / custom-ACP command) wins, else the kept resolveAcpSpawnArgs switch is the built-in fallback. acp-dispatch.ts spawns spec.binary/spec.args with env { ...process.env, ...spec.env }; dispatcher.ts loads the resolved def by task.agent and passes it through. Config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (opencode/goose/qwen) is byte-identical to pre-v2.3 — proven by a regression test (opencode->['acp'], goose->['acp'], qwen->['--acp'], binary=installPath ?? id, empty env -> plain process.env). Deliberate deviation from design's !installPath->null: the installPath ?? id fallback is preserved. setSessionMode/permission/streaming and the dispatcher poll/NOTIFY/running-guard untouched. 7 new acp-spawn.test.ts cases. No routes/UI (Phase 4+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:06:32 +00:00
35a0aba211 coder(providers): v2.3 provider-lifecycle phase 2 — snapshot lifecycle
provider-snapshot no longer returns null for uninstalled/disabled providers: it emits one entry per registered provider with a lifecycle status (loading|ready|unavailable|error), an enabled flag, and a two-tier probe. Tier-1 is a fast which-style check (command-availability.ts, execFile/no-shell); tier-2 (cold ACP probe) is skipped unless forced, last_probed_at is older than PROVIDER_PROBE_TTL_MS (24h), or DB models are empty — the snapshot-latency win. Cache miss returns status:'loading' synchronously while the build settles via the existing inflight promise. ProviderSnapshotStatus/Entry regain loading/unavailable + gain enabled/description?/fetchedAt? in both coder and web copies, guarded by a runtime parity test (provider-types-parity.test.ts; compile-time cross-project check was blocked by TS6307). Also tracks the data/coder-providers.json seed via a .gitignore exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. 13 snapshot tests (+6) + 6 parity tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:47:48 +00:00
17 changed files with 591 additions and 87 deletions

1
.gitignore vendored
View File

@@ -16,4 +16,5 @@ data/*
!data/AGENTS.md !data/AGENTS.md
!data/skills/ !data/skills/
!data/mcp.json !data/mcp.json
!data/coder-providers.json
codecontext/fork.tar.gz codecontext/fork.tar.gz

View File

@@ -2,6 +2,18 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 530s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29 ## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`. Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.

View File

@@ -26,6 +26,10 @@ const ConfigSchema = z.object({
// v2.3: config-backed provider overrides/custom-ACP entries merged over the // v2.3: config-backed provider overrides/custom-ACP entries merged over the
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts). // hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'), CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
// probed more recently than this. 24h default — stale model lists self-heal
// on the next snapshot; an explicit /refresh always re-probes.
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
// v2.0.5: cheaper model for titles, summaries, labeling. // v2.0.5: cheaper model for titles, summaries, labeling.
FAST_MODEL: z.string().optional(), FAST_MODEL: z.string().optional(),
// SSH access to the host for external agent dispatch (Phase 5) // SSH access to the host for external agent dispatch (Phase 5)

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { resolveLaunchSpec, resolveAcpSpawnArgs } from '../acp-spawn.js';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import type { CoderProvidersFile } from '../provider-config.js';
import { PROVIDERS } from '../provider-registry.js';
/** Resolved def for a provider id under the given config (default: no override). */
function builtin(name: string, providers: CoderProvidersFile['providers'] = {}) {
const def = buildResolvedRegistry(PROVIDERS, { providers }).get(name);
if (!def) throw new Error(`no resolved def for ${name}`);
return def;
}
describe('resolveLaunchSpec', () => {
// --- byte-identical built-in regression (the HARD CONSTRAINT) ---------------
// These argv values are the pre-v2.3 resolveAcpSpawnArgs switch outputs and
// MUST NOT change. spawn() is `spawn(spec.binary, spec.args, ...)`, so argv
// parity here is dispatch parity.
it('opencode (no override) → byte-identical argv ["acp"], binary = installPath', () => {
const spec = resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode');
expect(spec).not.toBeNull();
expect(spec!.args).toEqual(['acp']); // pre-v2.3 value
expect(spec!.binary).toBe('/usr/bin/opencode');
expect(spec!.env).toBeUndefined();
// cross-check against the switch source-of-truth
expect(spec!.args).toEqual(resolveAcpSpawnArgs('opencode'));
});
it('goose → ["acp"], qwen → ["--acp"] (byte-identical)', () => {
expect(resolveLaunchSpec(builtin('goose'), '/usr/bin/goose')!.args).toEqual(['acp']);
expect(resolveLaunchSpec(builtin('qwen'), '/usr/bin/qwen')!.args).toEqual(['--acp']);
});
it('built-in with null installPath falls back to the bare id (pre-v2.3 `installPath ?? agent`)', () => {
const spec = resolveLaunchSpec(builtin('opencode'), null);
expect(spec!.binary).toBe('opencode');
expect(spec!.args).toEqual(['acp']);
});
it('non-ACP / unknown provider → null (claude has no ACP argv)', () => {
expect(resolveLaunchSpec(builtin('claude'), '/usr/bin/claude')).toBeNull();
expect(resolveLaunchSpec(builtin('boocode'), null)).toBeNull();
});
// --- config-driven launch (the new capability) ------------------------------
it('custom ACP entry → configured command + env reach the spec', () => {
const def = builtin('amp-acp', {
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'], env: { AMP_KEY: 'x' } },
});
const spec = resolveLaunchSpec(def, '/usr/local/bin/amp-acp');
expect(spec).not.toBeNull();
expect(spec!.binary).toBe('amp-acp'); // command[0], not the resolved install path
expect(spec!.args).toEqual(['--acp']); // command.slice(1)
expect(spec!.env).toEqual({ AMP_KEY: 'x' });
});
it('built-in WITH a config command override uses the override, not the switch default', () => {
const def = builtin('opencode', { opencode: { command: ['opencode', 'acp', '--verbose'], env: { DEBUG: '1' } } });
const spec = resolveLaunchSpec(def, '/usr/bin/opencode');
expect(spec!.binary).toBe('opencode');
expect(spec!.args).toEqual(['acp', '--verbose']);
expect(spec!.env).toEqual({ DEBUG: '1' });
});
});
describe('acp-dispatch spawn wiring (documented pass-through)', () => {
// dispatchViaAcp spawns `spawn(spec.binary, spec.args, { env: { ...process.env, ...spec.env } })`.
// The env merge layers config env over process.env; for a built-in with no
// config env, spec.env is undefined → { ...process.env } (byte-identical).
it('built-in with no config env yields an undefined spec.env (→ plain process.env at spawn)', () => {
expect(resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode')!.env).toBeUndefined();
});
});

View File

@@ -61,6 +61,22 @@ describe('buildResolvedRegistry', () => {
warn.mockRestore(); warn.mockRestore();
}); });
it('carries config models + additionalModels onto built-in and custom defs', () => {
const reg = buildResolvedRegistry(PROVIDERS, {
providers: {
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
'amp-acp': {
extends: 'acp',
label: 'Amp',
command: ['amp-acp'],
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
},
},
});
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
});
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => { it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} }); const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
expect(reg.size).toBe(PROVIDERS.length); expect(reg.size).toBe(PROVIDERS.length);

View File

@@ -1,10 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { import {
mergeModels, mergeModels,
prefixLlamaSwapModels, prefixLlamaSwapModels,
clearProviderSnapshotCache, clearProviderSnapshotCache,
getProviderSnapshot, getProviderSnapshot,
} from '../provider-snapshot.js'; } from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
vi.mock('../acp-probe.js', () => ({ vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(), probeAcpProvider: vi.fn(),
@@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider); 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<{ function mockSql(agents: Array<{
name: string; name: string;
install_path: string | null; install_path: string | null;
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
models: Array<{ id: string; label: string }> | null; models: Array<{ id: string; label: string }> | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at?: string | null;
}>) { }>) {
return vi.fn((strings: TemplateStringsArray) => { return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join(''); const query = strings.join('');
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
const config = { const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test', LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as import('../config.js').Config; } as import('../config.js').Config;
describe('prefixLlamaSwapModels', () => { describe('prefixLlamaSwapModels', () => {
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
describe('getProviderSnapshot', () => { describe('getProviderSnapshot', () => {
beforeEach(() => { beforeEach(() => {
clearProviderSnapshotCache(); clearProviderSnapshotCache();
// Reset the resolved registry to built-ins-only (missing path → {} config).
loadProviderConfig('/nonexistent-coder-providers.json');
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
@@ -165,4 +180,178 @@ describe('getProviderSnapshot', () => {
expect(claude?.modes.length).toBeGreaterThan(0); expect(claude?.modes.length).toBeGreaterThan(0);
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true); 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);
});
}); });

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Parity guard between the two copies of the provider snapshot types:
* apps/coder/src/services/provider-types.ts (backend source of truth)
* apps/web/src/api/types.ts (web wire copy)
*
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
* assignability check was attempted first (a web-side file importing coder's
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
* project and rejects out-of-include files with TS6307 — so cross-project type
* import is structurally blocked. This runtime guard FAILS on any field
* add/remove/rename/loosen in either copy, including the nested model/mode/
* command types that ProviderSnapshotEntry references. Single-source-of-truth
* (shared workspace package) is deferred as a Tier-2 follow-up.
*/
const here = dirname(fileURLToPath(import.meta.url));
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
function extractBlock(src: string, name: string): string {
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
const block = iface?.[0] ?? alias?.[0];
if (!block) throw new Error(`type block '${name}' not found`);
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
// trim each line. Field add/remove/rename/loosen still changes a field line.
return block
.split('\n')
.map((l) => l.trim())
.filter(
(l) =>
l.length > 0 &&
!l.startsWith('//') &&
!l.startsWith('/*') &&
!l.startsWith('*'),
)
.join('\n');
}
describe('provider snapshot type parity (coder ↔ web)', () => {
// Includes the nested types ProviderSnapshotEntry references, so structural
// drift anywhere in the snapshot surface is caught.
const names = [
'ProviderSnapshotStatus',
'ProviderSnapshotEntry',
'ProviderModel',
'ProviderMode',
'ThinkingOption',
'AgentCommand',
];
for (const name of names) {
it(`${name} is identical in both copies`, () => {
expect(
extractBlock(webSrc, name),
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
).toBe(extractBlock(coderSrc, name));
});
}
});

View File

@@ -26,7 +26,8 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/server/ws-frames';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js'; import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveAcpSpawnArgs } from './acp-spawn.js'; import { resolveLaunchSpec } from './acp-spawn.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { createAcpNdJsonStream } from './acp-stream.js'; import { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js'; import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
@@ -59,6 +60,9 @@ export interface AcpDispatchOpts {
messageId?: string; messageId?: string;
broker?: Broker; broker?: Broker;
installPath?: string; installPath?: string;
/** v2.3 phase 3: resolved registry def for launch-spec resolution. The
* dispatcher loads this by task.agent; falls back to a registry lookup here. */
resolved?: ResolvedProviderDef;
signal?: AbortSignal; signal?: AbortSignal;
log: FastifyBaseLogger; log: FastifyBaseLogger;
} }
@@ -282,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
broker, broker,
} = opts; } = opts;
const args = resolveAcpSpawnArgs(agent); // v2.3 phase 3: launch from the resolved registry def (config override /
if (!args) { // custom-ACP command) with the built-in switch as the fallback. The dispatcher
// passes `resolved`; fall back to a registry lookup if it didn't.
const resolved = opts.resolved ?? getResolvedRegistry().get(agent);
const spec = resolved ? resolveLaunchSpec(resolved, installPath ?? null) : null;
if (!spec) {
return { return {
exitCode: 1, exitCode: 1,
output: `Agent '${agent}' does not support ACP.`, output: `Agent '${agent}' does not support ACP.`,
@@ -293,12 +301,11 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
}; };
} }
const binary = installPath ?? agent; log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning'); const child = spawn(spec.binary, spec.args, {
const child = spawn(binary, args, {
cwd: worktreePath, cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }, env: { ...process.env, ...spec.env },
}); });
const streamCtx = new AcpStreamContext( const streamCtx = new AcpStreamContext(

View File

@@ -1,5 +1,9 @@
import type { ResolvedProviderDef } from './provider-config-registry.js';
/** /**
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25). * Resolve ACP spawn argv per built-in provider (host-probe verified 2026-05-25).
* Source of truth for built-in default argv — resolveLaunchSpec wraps these; it
* does NOT replace them.
*/ */
export function resolveAcpSpawnArgs(agent: string): string[] | null { export function resolveAcpSpawnArgs(agent: string): string[] | null {
switch (agent) { switch (agent) {
@@ -13,6 +17,34 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
} }
} }
/**
* v2.3 phase 3: resolve the launch spec for an ACP dispatch (design.md §5.1).
* Consults the resolved registry's launchCommand (config override or custom-ACP
* entry) first; otherwise falls back to the built-in default argv above.
*
* Byte-identical to pre-v2.3 for built-ins with no override: binary is
* `installPath ?? id` and args come from resolveAcpSpawnArgs — exactly the
* `binary = installPath ?? agent` + `resolveAcpSpawnArgs(agent)` the dispatcher
* used before. (Deliberate deviation from design §5.1's `!installPath → null`:
* the old path spawned the bare agent name when install_path was missing, so we
* preserve the `?? id` fallback rather than fail.)
*/
export function resolveLaunchSpec(
resolved: ResolvedProviderDef,
installPath: string | null,
): { binary: string; args: string[]; env?: Record<string, string> } | null {
if (resolved.launchCommand) {
return {
binary: resolved.launchCommand[0],
args: resolved.launchCommand.slice(1),
env: resolved.env,
};
}
const args = resolveAcpSpawnArgs(resolved.id);
if (!args) return null;
return { binary: installPath ?? resolved.id, args, env: resolved.env };
}
export function resolveAcpProbeBinaries(agent: string): string[] { export function resolveAcpProbeBinaries(agent: string): string[] {
return [agent]; return [agent];
} }

View File

@@ -0,0 +1,22 @@
/**
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
*
* Uses execFile (NO shell) because the binary name can come from the provider
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
* `install_path`); this returns a boolean. Kept separate rather than over-
* refactored into one helper — different return contracts, two short call sites.
*/
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
const execFile = promisify(execFileCb);
export async function isCommandAvailable(binary: string): Promise<boolean> {
try {
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}

View File

@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js'; import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js'; import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js'; import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js'; import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js'; import { getManifestCommands } from './provider-commands.js';
@@ -340,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (supportsAcp) { if (supportsAcp) {
const result = await dispatchViaAcp({ const result = await dispatchViaAcp({
agent, agent,
resolved: getResolvedRegistry().get(agent),
task: task.input, task: task.input,
worktreePath, worktreePath,
installPath: installPath ?? undefined, installPath: installPath ?? undefined,

View File

@@ -22,6 +22,10 @@ export interface ResolvedProviderDef extends ProviderDef {
env: Record<string, string> | undefined; env: Record<string, string> | undefined;
configLabel?: string; configLabel?: string;
configDescription?: string; configDescription?: string;
/** Config `models` — REPLACES the discovered/static model list when present. */
configModels?: Array<{ id: string; label: string }>;
/** Config `additionalModels` — MERGED on top of the resolved model list. */
configAdditionalModels?: Array<{ id: string; label: string }>;
} }
/** /**
@@ -61,6 +65,8 @@ export function buildResolvedRegistry(
env: ov?.env, env: ov?.env,
configLabel: ov?.label, configLabel: ov?.label,
configDescription: ov?.description, configDescription: ov?.description,
configModels: ov?.models,
configAdditionalModels: ov?.additionalModels,
}); });
} }
@@ -87,6 +93,8 @@ export function buildResolvedRegistry(
env: ov.env, env: ov.env,
configLabel: ov.label, configLabel: ov.label,
configDescription: ov.description, configDescription: ov.description,
configModels: ov.models,
configAdditionalModels: ov.additionalModels,
}); });
} }

View File

@@ -41,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
label: 'Claude Code', label: 'Claude Code',
transport: 'pty', transport: 'pty',
modelSource: 'static', modelSource: 'static',
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
// exact version. Extend/replace per-install via data/coder-providers.json
// (models / additionalModels) without a code change.
staticModels: [ staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' }, { id: 'opus', label: 'Opus (latest)' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' }, { id: 'claude-opus-4-8', label: 'Opus 4.8' },
{ id: 'sonnet', label: 'Sonnet (latest)' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'haiku', label: 'Haiku (latest)' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
], ],
}, },
{ {

View File

@@ -5,7 +5,6 @@ import { homedir } from 'node:os';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
import { import {
getManifestDefaultModeId, getManifestDefaultModeId,
getManifestModes, getManifestModes,
@@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js'; import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js';
interface AgentRow { interface AgentRow {
name: string; name: string;
@@ -23,6 +24,7 @@ interface AgentRow {
models: ProviderModel[] | null; models: ProviderModel[] | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at: string | Date | null;
} }
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> { async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
@@ -68,110 +70,150 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
} }
async function buildProviderEntry( async function buildProviderEntry(
provider: ProviderDef, resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined, agentRow: AgentRow | undefined,
llamaModels: ProviderModel[], llamaModels: ProviderModel[],
cwd: string, cwd: string,
): Promise<ProviderSnapshotEntry | null> { ttlMs: number,
const isNative = provider.name === 'boocode'; force: boolean,
const installed = isNative || !!agentRow; ): Promise<ProviderSnapshotEntry> {
if (!installed) return null; const name = resolved.id;
const isNative = resolved.transport === 'native';
const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
let transport = provider.transport; // v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) { // MERGES on top. Applied to every ready/installed model list below.
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
out = mergeModels(out, resolved.configAdditionalModels);
}
return out;
};
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty'; transport = 'pty';
} }
const fallbackModes = getManifestModes(provider.name); // 1. Disabled → unavailable, no probe.
const defaultModeId = getManifestDefaultModeId(provider.name); if (!resolved.enabled) {
if (isNative) {
return { return {
name: provider.name, name, label, ...descr, transport, status: 'unavailable',
label: provider.label, enabled: false, installed: false, models: [], modes: fallbackModes,
transport, defaultModeId, commands: manifestCommands,
status: 'ready',
installed: true,
models: llamaModels,
modes: [],
defaultModeId: null,
commands: getManifestCommands(provider.name),
}; };
} }
// 2. Native boocode → always ready (llama-swap models).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands,
};
}
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
const fast =
agentRow?.install_path != null ||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
if (!fast) {
return {
name, label, ...descr, transport, status: 'unavailable',
enabled: true, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// Baseline model precedence (used by claude + non-probe fallbacks).
let models: ProviderModel[] = []; let models: ProviderModel[] = [];
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) { if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
models = llamaModels; models = llamaModels;
} else if (agentRow?.models?.length) { } else if (agentRow?.models?.length) {
models = agentRow.models; models = agentRow.models;
} else if (provider.staticModels) { } else if (resolved.staticModels) {
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label })); models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
} }
if (provider.name === 'claude') { // claude: static models + thinking options, no ACP probe (unchanged from v2.2).
models = attachClaudeThinking(models); if (name === 'claude') {
return { return {
name: provider.name, name, label, transport, status: 'ready', enabled: true, installed: true,
label: agentRow?.label ?? provider.label, models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
transport, commands: manifestCommands,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
}; };
} }
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) { const canProbeAcp =
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd); transport === 'acp' &&
if (probe.models.length > 0) { ((agentRow?.install_path != null && agentRow.supports_acp) ||
models = probe.models; (resolved.isCustomAcp && resolved.launchCommand != null));
} else if (provider.modelSource === 'llama-swap') {
models = llamaModels; if (canProbeAcp) {
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
const lastProbedMs =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
const runTier2 = force || stale || dbEmpty;
if (!runTier2) {
let skipModels = agentRow?.models ?? [];
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
skipModels = llamaModels;
}
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
} }
if (provider.name === 'qwen') { const probeTarget =
const settingsModels = await readQwenSettingsModels(); resolved.isCustomAcp && resolved.launchCommand
models = mergeModels(models, settingsModels); ? resolved.launchCommand[0]
} : agentRow!.install_path!;
const probe = await probeAcpProvider(name, probeTarget, cwd);
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') { let probeModels = probe.models.length > 0 ? probe.models : models;
const nativeModels = probe.models.length > 0 ? probe.models : models; if (name === 'qwen') {
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels)); probeModels = mergeModels(probeModels, await readQwenSettingsModels());
}
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
} }
return { return {
name: provider.name, name, label, transport,
label: agentRow.label ?? provider.label,
transport,
status: probe.ok ? 'ready' : 'error', status: probe.ok ? 'ready' : 'error',
installed: true, enabled: true, installed: true,
models, models: withConfigModels(probeModels),
modes: probe.modes.length > 0 ? probe.modes : fallbackModes, modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId, defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(getManifestCommands(provider.name), probe.commands), commands: mergeCommands(manifestCommands, probe.commands),
error: probe.error, ...(probe.error ? { error: probe.error } : {}),
fetchedAt: new Date().toISOString(),
}; };
} }
// PTY-only providers (qwen fallback when ACP unavailable) // PTY-only fallback (e.g. qwen without ACP) — installed + ready.
if (provider.name === 'qwen') { if (name === 'qwen' && models.length === 0) {
if (models.length === 0) {
models = await readQwenSettingsModels(); models = await readQwenSettingsModels();
} }
}
return { return {
name: provider.name, name, label, transport, status: 'ready', enabled: true, installed: true,
label: agentRow?.label ?? provider.label, models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
}; };
} }
@@ -200,16 +242,16 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => { const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config); const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>` const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents
`; `;
const agentMap = new Map(agents.map((a) => [a.name, a])); const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
const built = await Promise.all( const entries = await Promise.all(
PROVIDERS.map((provider) => [...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd), buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
), ),
); );
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
snapshotCache.set(cacheKey, { at: Date.now(), entries }); snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries; return entries;
@@ -219,6 +261,13 @@ export async function getProviderSnapshot(
snapshotInflight.delete(cacheKey); snapshotInflight.delete(cacheKey);
}); });
snapshotInflight.set(cacheKey, promise); snapshotInflight.set(cacheKey, promise);
// Await the build (force or cache-miss) and return terminal entries. The sync
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
// poll that resolves it: without that poll, a single fetch lands on
// installed:false `loading` entries, which AgentComposerBar filters out
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
// once available_agents.models is warm.
return promise; return promise;
} }

View File

@@ -23,24 +23,31 @@ export interface ProviderModel {
defaultThinkingOptionId?: string; defaultThinkingOptionId?: string;
} }
export type ProviderSnapshotStatus = 'ready' | 'error'; // v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
export interface AgentCommand { export interface AgentCommand {
name: string; name: string;
description?: string; description?: string;
} }
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
export interface ProviderSnapshotEntry { export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
description?: string;
transport: string; transport: string;
status: ProviderSnapshotStatus; status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[]; modes: ProviderMode[];
defaultModeId: string | null; defaultModeId: string | null;
commands: AgentCommand[]; commands: AgentCommand[];
error?: string; error?: string;
fetchedAt?: string;
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {

View File

@@ -232,19 +232,25 @@ export interface ThinkingOption {
isDefault?: boolean; isDefault?: boolean;
} }
export type ProviderSnapshotStatus = 'ready' | 'error'; // v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
export interface ProviderSnapshotEntry { export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
description?: string;
transport: string; transport: string;
status: ProviderSnapshotStatus; status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[]; modes: ProviderMode[];
defaultModeId: string | null; defaultModeId: string | null;
commands: AgentCommand[]; commands: AgentCommand[];
error?: string; error?: string;
fetchedAt?: string;
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {

View File

@@ -0,0 +1,3 @@
{
"providers": {}
}