Compare commits
4 Commits
v2.5.4-pro
...
v2.5.8-mob
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf86ecb92 | |||
| fe52250d78 | |||
| 4035aa2b98 | |||
| 35a0aba211 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,4 +16,5 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
!data/coder-providers.json
|
||||
codecontext/fork.tar.gz
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
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.8-mobile-composer-row — 2026-05-29
|
||||
|
||||
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
|
||||
|
||||
## 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 5–30s 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
|
||||
|
||||
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): 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`.
|
||||
|
||||
@@ -26,6 +26,10 @@ const ConfigSchema = z.object({
|
||||
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
|
||||
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
|
||||
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.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
// SSH access to the host for external agent dispatch (Phase 5)
|
||||
|
||||
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,22 @@ describe('buildResolvedRegistry', () => {
|
||||
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', () => {
|
||||
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||
expect(reg.size).toBe(PROVIDERS.length);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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(),
|
||||
@@ -14,6 +18,13 @@ 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;
|
||||
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
|
||||
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('');
|
||||
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
|
||||
|
||||
const config = {
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||
} as import('../config.js').Config;
|
||||
|
||||
describe('prefixLlamaSwapModels', () => {
|
||||
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
|
||||
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',
|
||||
@@ -165,4 +180,178 @@ describe('getProviderSnapshot', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -26,7 +26,8 @@ import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import { spawn } from 'node:child_process';
|
||||
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 { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
@@ -59,6 +60,9 @@ export interface AcpDispatchOpts {
|
||||
messageId?: string;
|
||||
broker?: Broker;
|
||||
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;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
@@ -282,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
||||
broker,
|
||||
} = opts;
|
||||
|
||||
const args = resolveAcpSpawnArgs(agent);
|
||||
if (!args) {
|
||||
// v2.3 phase 3: launch from the resolved registry def (config override /
|
||||
// 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 {
|
||||
exitCode: 1,
|
||||
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, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||
const child = spawn(binary, args, {
|
||||
log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||
const child = spawn(spec.binary, spec.args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, ...spec.env },
|
||||
});
|
||||
|
||||
const streamCtx = new AcpStreamContext(
|
||||
|
||||
@@ -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 {
|
||||
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[] {
|
||||
return [agent];
|
||||
}
|
||||
|
||||
22
apps/coder/src/services/command-availability.ts
Normal file
22
apps/coder/src/services/command-availability.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||
import { dispatchViaPty } from './pty-dispatch.js';
|
||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||
import { getManifestCommands } from './provider-commands.js';
|
||||
@@ -340,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
if (supportsAcp) {
|
||||
const result = await dispatchViaAcp({
|
||||
agent,
|
||||
resolved: getResolvedRegistry().get(agent),
|
||||
task: task.input,
|
||||
worktreePath,
|
||||
installPath: installPath ?? undefined,
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface ResolvedProviderDef extends ProviderDef {
|
||||
env: Record<string, string> | undefined;
|
||||
configLabel?: 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,
|
||||
configLabel: ov?.label,
|
||||
configDescription: ov?.description,
|
||||
configModels: ov?.models,
|
||||
configAdditionalModels: ov?.additionalModels,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +93,8 @@ export function buildResolvedRegistry(
|
||||
env: ov.env,
|
||||
configLabel: ov.label,
|
||||
configDescription: ov.description,
|
||||
configModels: ov.models,
|
||||
configAdditionalModels: ov.additionalModels,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
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: [
|
||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
{ id: 'opus', label: 'Opus (latest)' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ import { homedir } from 'node:os';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
||||
import {
|
||||
getManifestDefaultModeId,
|
||||
getManifestModes,
|
||||
@@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js';
|
||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import { isCommandAvailable } from './command-availability.js';
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
@@ -23,6 +24,7 @@ interface AgentRow {
|
||||
models: ProviderModel[] | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
last_probed_at: string | Date | null;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
@@ -68,110 +70,150 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
||||
}
|
||||
|
||||
async function buildProviderEntry(
|
||||
provider: ProviderDef,
|
||||
resolved: ResolvedProviderDef,
|
||||
agentRow: AgentRow | undefined,
|
||||
llamaModels: ProviderModel[],
|
||||
cwd: string,
|
||||
): Promise<ProviderSnapshotEntry | null> {
|
||||
const isNative = provider.name === 'boocode';
|
||||
const installed = isNative || !!agentRow;
|
||||
if (!installed) return null;
|
||||
ttlMs: number,
|
||||
force: boolean,
|
||||
): Promise<ProviderSnapshotEntry> {
|
||||
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;
|
||||
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
||||
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
|
||||
// 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';
|
||||
}
|
||||
|
||||
const fallbackModes = getManifestModes(provider.name);
|
||||
const defaultModeId = getManifestDefaultModeId(provider.name);
|
||||
|
||||
if (isNative) {
|
||||
// 1. Disabled → unavailable, no probe.
|
||||
if (!resolved.enabled) {
|
||||
return {
|
||||
name: provider.name,
|
||||
label: provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models: llamaModels,
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, ...descr, transport, status: 'unavailable',
|
||||
enabled: false, installed: false, models: [], modes: fallbackModes,
|
||||
defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// 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[] = [];
|
||||
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
||||
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
|
||||
models = llamaModels;
|
||||
} else if (agentRow?.models?.length) {
|
||||
models = agentRow.models;
|
||||
} else if (provider.staticModels) {
|
||||
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
} else if (resolved.staticModels) {
|
||||
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
}
|
||||
|
||||
if (provider.name === 'claude') {
|
||||
models = attachClaudeThinking(models);
|
||||
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||
if (name === 'claude') {
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
||||
commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
||||
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
||||
if (probe.models.length > 0) {
|
||||
models = probe.models;
|
||||
} else if (provider.modelSource === 'llama-swap') {
|
||||
models = llamaModels;
|
||||
const canProbeAcp =
|
||||
transport === 'acp' &&
|
||||
((agentRow?.install_path != null && agentRow.supports_acp) ||
|
||||
(resolved.isCustomAcp && resolved.launchCommand != null));
|
||||
|
||||
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 settingsModels = await readQwenSettingsModels();
|
||||
models = mergeModels(models, settingsModels);
|
||||
}
|
||||
const probeTarget =
|
||||
resolved.isCustomAcp && resolved.launchCommand
|
||||
? resolved.launchCommand[0]
|
||||
: agentRow!.install_path!;
|
||||
const probe = await probeAcpProvider(name, probeTarget, cwd);
|
||||
|
||||
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
||||
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
||||
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||
let probeModels = probe.models.length > 0 ? probe.models : models;
|
||||
if (name === 'qwen') {
|
||||
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 {
|
||||
name: provider.name,
|
||||
label: agentRow.label ?? provider.label,
|
||||
transport,
|
||||
name, label, transport,
|
||||
status: probe.ok ? 'ready' : 'error',
|
||||
installed: true,
|
||||
models,
|
||||
enabled: true, installed: true,
|
||||
models: withConfigModels(probeModels),
|
||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
||||
error: probe.error,
|
||||
commands: mergeCommands(manifestCommands, probe.commands),
|
||||
...(probe.error ? { error: probe.error } : {}),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// PTY-only providers (qwen fallback when ACP unavailable)
|
||||
if (provider.name === 'qwen') {
|
||||
if (models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
|
||||
if (name === 'qwen' && models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,16 +242,16 @@ export async function getProviderSnapshot(
|
||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
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 ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||
|
||||
const built = await Promise.all(
|
||||
PROVIDERS.map((provider) =>
|
||||
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
||||
const entries = await Promise.all(
|
||||
[...getResolvedRegistry().values()].map((resolved) =>
|
||||
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 });
|
||||
return entries;
|
||||
@@ -219,6 +261,13 @@ export async function getProviderSnapshot(
|
||||
snapshotInflight.delete(cacheKey);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,24 +23,31 @@ export interface ProviderModel {
|
||||
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 {
|
||||
name: 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 {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
|
||||
@@ -232,19 +232,25 @@ export interface ThinkingOption {
|
||||
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 {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
|
||||
@@ -92,9 +92,11 @@ interface PickerProps {
|
||||
options: Array<{ id: string; label: string }>;
|
||||
onPick: (id: string) => void;
|
||||
icon?: React.ReactNode;
|
||||
/** Mobile: render icon + chevron only (no value label) to save row width. */
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
|
||||
const { isMobile } = useViewport();
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||
@@ -129,7 +131,7 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
||||
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate max-w-[120px]">{currentLabel}</span>
|
||||
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||
@@ -290,6 +292,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
iconOnly
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Model"
|
||||
@@ -308,22 +311,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
/>
|
||||
)}
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
className={cn('inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40', connected === undefined && 'ml-auto')}
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
3
data/coder-providers.json
Normal file
3
data/coder-providers.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"providers": {}
|
||||
}
|
||||
Reference in New Issue
Block a user