Compare commits
6 Commits
2d997ecb6c
...
v2.5.13-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| e92c51578d | |||
| 6d03690a65 | |||
| 21384cce5b | |||
| 920f8b75a6 | |||
| e83d9b7d5b | |||
| f302969c71 |
78
BOOCODER.md
78
BOOCODER.md
@@ -37,3 +37,81 @@ Every file modification queues in `pending_changes` before touching disk. The us
|
||||
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||
|
||||
## Provider lifecycle (v2.3)
|
||||
|
||||
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
|
||||
|
||||
### Config file: `data/coder-providers.json`
|
||||
|
||||
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **tracked in git** via a `.gitignore` exception (the rest of `data/*` is ignored). A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"goose": { "enabled": false },
|
||||
"amp-acp": {
|
||||
"extends": "acp",
|
||||
"label": "Amp",
|
||||
"description": "ACP wrapper for Amp",
|
||||
"command": ["amp-acp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-provider override fields (all optional):
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
|
||||
| `label` | Display name (required for custom) |
|
||||
| `description` | Sub-label shown in the picker / settings |
|
||||
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
|
||||
| `env` | Extra env vars merged into the spawn |
|
||||
| `enabled` | Default `true`; `false` hides it from the composer |
|
||||
| `order` | UI sort key |
|
||||
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
|
||||
|
||||
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
|
||||
|
||||
### Refresh contract
|
||||
|
||||
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
|
||||
|
||||
### Enable / disable
|
||||
|
||||
Two ways:
|
||||
- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
|
||||
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
|
||||
|
||||
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
|
||||
|
||||
### Adding a custom ACP provider
|
||||
|
||||
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
|
||||
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
|
||||
|
||||
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
|
||||
|
||||
### Known limitation — subset refresh
|
||||
|
||||
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
|
||||
|
||||
### Deploy + smoke
|
||||
|
||||
Two deploy targets:
|
||||
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Web UI (container):** `docker compose up --build -d boocode`
|
||||
|
||||
Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
|
||||
|
||||
Smoke (via Tailscale):
|
||||
|
||||
```bash
|
||||
curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider
|
||||
curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy
|
||||
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
|
||||
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
|
||||
```
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
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.13-provider-lifecycle-phase5 — 2026-05-29
|
||||
|
||||
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
|
||||
|
||||
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
|
||||
|
||||
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||
|
||||
## v2.5.11-claude-skill-discovery — 2026-05-29
|
||||
|
||||
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
|
||||
|
||||
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerProviderRoutes } from '../providers.js';
|
||||
import { load } from '../../services/provider-config.js';
|
||||
import { loadProviderConfig } from '../../services/provider-config-registry.js';
|
||||
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
|
||||
import type { Config } from '../../config.js';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/** Minimal sql stub: available_agents reads return []. */
|
||||
function mockSql(): Sql {
|
||||
return vi.fn((strings: TemplateStringsArray) => {
|
||||
const q = strings.join('');
|
||||
if (q.includes('available_agents')) return Promise.resolve([]);
|
||||
return Promise.resolve([]);
|
||||
}) as unknown as Sql;
|
||||
}
|
||||
|
||||
let tmpCounter = 0;
|
||||
function freshPath(): string {
|
||||
tmpCounter += 1;
|
||||
return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
|
||||
}
|
||||
|
||||
function buildApp(providersPath: string): FastifyInstance {
|
||||
const app = Fastify();
|
||||
// Mirror index.ts: tolerate empty JSON bodies.
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) return done(null, {});
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
const config = {
|
||||
CODER_PROVIDERS_PATH: providersPath,
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||
} as unknown as Config;
|
||||
registerProviderRoutes(app, mockSql(), config);
|
||||
return app;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'content-type': 'application/json' };
|
||||
const createdPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
clearProviderSnapshotCache();
|
||||
loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const p of createdPaths.splice(0)) {
|
||||
try {
|
||||
rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/providers/config', () => {
|
||||
it('returns the current config file (built-ins-only when missing)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
const app = buildApp(path);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ providers: {} });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('reflects an existing file', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
|
||||
const app = buildApp(path);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||
expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/providers/config', () => {
|
||||
it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
// File written + merged (goose untouched, opencode added).
|
||||
const onDisk = load(path);
|
||||
expect(onDisk.providers).toEqual({
|
||||
goose: { label: 'Goose' },
|
||||
opencode: { enabled: false },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('null value deletes the override', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: null } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(load(path).providers).toEqual({ opencode: { enabled: false } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
const before = JSON.stringify({ providers: { goose: { enabled: true } } });
|
||||
writeFileSync(path, before);
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
// File must be byte-for-byte unchanged — nothing written on a 422.
|
||||
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
|
||||
const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/providers/refresh', () => {
|
||||
it('no body → refreshes all registered providers', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().refreshed).toBeGreaterThan(0);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('subset body → refreshed count reflects only the requested providers', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/providers/refresh',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: ['boocode'] }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ refreshed: 1 });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/providers/:id/diagnostic', () => {
|
||||
it('known provider → 200 JSON { diagnostic }', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.json().diagnostic).toContain('provider: boocode');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('unknown provider → 404', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,29 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
|
||||
import {
|
||||
getProviderSnapshot,
|
||||
clearProviderSnapshotCache,
|
||||
peekSnapshotEntry,
|
||||
} from '../services/provider-snapshot.js';
|
||||
import {
|
||||
load,
|
||||
save,
|
||||
CoderProvidersFileSchema,
|
||||
ProviderConfigPatchSchema,
|
||||
mergeProviderConfigPatch,
|
||||
} from '../services/provider-config.js';
|
||||
import {
|
||||
reloadProviderConfig,
|
||||
getResolvedRegistry,
|
||||
} from '../services/provider-config-registry.js';
|
||||
import {
|
||||
getProviderDiagnostic,
|
||||
type DiagnosticAgentRow,
|
||||
} from '../services/provider-diagnostic.js';
|
||||
|
||||
const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() });
|
||||
|
||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
||||
@@ -9,9 +31,97 @@ export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: C
|
||||
return getProviderSnapshot(sql, config, cwd);
|
||||
});
|
||||
|
||||
app.post('/api/providers/refresh', async (_req, _reply) => {
|
||||
// 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry).
|
||||
app.get('/api/providers/config', async (_req, _reply) => {
|
||||
return load(config.CODER_PROVIDERS_PATH);
|
||||
});
|
||||
|
||||
// 4.2 — patch the config file (design.md §6.2). Strict order is the whole
|
||||
// correctness story: validate → save → reload → clear. A malformed body or an
|
||||
// invalid merged result returns 422 and NEVER writes; a save failure returns
|
||||
// 500 and leaves in-memory state untouched (no file/registry divergence).
|
||||
app.patch('/api/providers/config', async (req, reply) => {
|
||||
// 1. Validate the PATCH body shape (malformed → 422, never reaches merge).
|
||||
const parsed = ProviderConfigPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(422).send({
|
||||
error: 'invalid provider config patch',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Shallow per-id merge over the current file (null deletes; object replaces).
|
||||
const current = load(config.CODER_PROVIDERS_PATH);
|
||||
const merged = mergeProviderConfigPatch(current, parsed.data);
|
||||
|
||||
// 3. Validate the merged result — refuse to write a config that won't load.
|
||||
const validated = CoderProvidersFileSchema.safeParse(merged);
|
||||
if (!validated.success) {
|
||||
return reply.code(422).send({
|
||||
error: 'merged provider config is invalid',
|
||||
issues: validated.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on
|
||||
// disk and the in-memory resolved registry can never diverge.
|
||||
try {
|
||||
save(config.CODER_PROVIDERS_PATH, validated.data);
|
||||
} catch (err) {
|
||||
req.log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH },
|
||||
'provider-config: save failed — in-memory state untouched',
|
||||
);
|
||||
return reply.code(500).send({ error: 'failed to write provider config' });
|
||||
}
|
||||
|
||||
// 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop
|
||||
// the snapshot cache so the next /snapshot reflects the change.
|
||||
reloadProviderConfig();
|
||||
clearProviderSnapshotCache();
|
||||
|
||||
// 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers
|
||||
// so the client can update without a follow-up GET).
|
||||
return { ok: true, providers: validated.data.providers };
|
||||
});
|
||||
|
||||
// 4.3 — force a cold probe. Optional { providers?: string[] } narrows the
|
||||
// reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is
|
||||
// the only existing re-probe primitive (per-provider force would be a
|
||||
// snapshot-internal change, out of Phase 4 scope), so the probe runs for all
|
||||
// installed providers; the `refreshed` count reflects the requested subset.
|
||||
app.post('/api/providers/refresh', async (req, reply) => {
|
||||
const parsed = RefreshBodySchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() });
|
||||
}
|
||||
const subset = parsed.data.providers;
|
||||
clearProviderSnapshotCache();
|
||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
||||
return { refreshed: entries.length };
|
||||
const refreshed =
|
||||
subset && subset.length > 0
|
||||
? entries.filter((e) => subset.includes(e.name)).length
|
||||
: entries.length;
|
||||
return { refreshed };
|
||||
});
|
||||
|
||||
// 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`).
|
||||
// Read-only: reports cached state (resolved def + available_agents row + warm
|
||||
// snapshot cache for the last probe error) plus a `which` PATH check. No probe
|
||||
// spawn. The report itself is a plaintext block (§8); the route wraps it as JSON.
|
||||
app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const resolved = getResolvedRegistry().get(id);
|
||||
if (!resolved) {
|
||||
return reply.code(404).send({ error: `unknown provider '${id}'` });
|
||||
}
|
||||
const rows = await sql<DiagnosticAgentRow[]>`
|
||||
SELECT name, install_path, supports_acp, models, last_probed_at
|
||||
FROM available_agents WHERE name = ${id}
|
||||
`;
|
||||
const report = await getProviderDiagnostic(resolved, rows[0], {
|
||||
cachedEntry: peekSnapshotEntry(id),
|
||||
});
|
||||
return { diagnostic: report };
|
||||
});
|
||||
}
|
||||
|
||||
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mergeProviderConfigPatch,
|
||||
ProviderConfigPatchSchema,
|
||||
CoderProvidersFileSchema,
|
||||
type CoderProvidersFile,
|
||||
} from '../provider-config.js';
|
||||
|
||||
describe('ProviderConfigPatchSchema', () => {
|
||||
it('accepts a per-provider override patch', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a null value (delete-the-override sentinel)', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults providers to {} on an empty body', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.data.providers).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects a malformed override (wrong field type)', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a non-object providers map', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeProviderConfigPatch', () => {
|
||||
const current: CoderProvidersFile = {
|
||||
providers: {
|
||||
goose: { enabled: true, label: 'Goose' },
|
||||
opencode: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
it('replaces an existing override object wholesale (not deep-merge)', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
|
||||
// Whole override replaced — the prior `label` is gone, only `enabled` remains.
|
||||
expect(merged.providers.goose).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('adds a brand-new override id', () => {
|
||||
const merged = mergeProviderConfigPatch(current, {
|
||||
providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } },
|
||||
});
|
||||
expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] });
|
||||
});
|
||||
|
||||
it('deletes an override when the value is null', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: { goose: null } });
|
||||
expect(merged.providers.goose).toBeUndefined();
|
||||
expect(Object.keys(merged.providers)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('leaves ids absent from the patch untouched', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
|
||||
expect(merged.providers.opencode).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('does not mutate the input config', () => {
|
||||
const snapshot = JSON.parse(JSON.stringify(current));
|
||||
mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } });
|
||||
expect(current).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('empty patch returns an equivalent config', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: {} });
|
||||
expect(merged).toEqual(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoderProvidersFileSchema (validate-before-save guard)', () => {
|
||||
it('accepts a clean merged config', () => {
|
||||
const merged = mergeProviderConfigPatch(
|
||||
{ providers: {} },
|
||||
{ providers: { goose: { enabled: false } } },
|
||||
);
|
||||
expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a config carrying an invalid override (never written)', () => {
|
||||
// A merged object that somehow holds a bad override must fail validation
|
||||
// so the PATCH route returns 422 and never calls save().
|
||||
const invalid = { providers: { goose: { enabled: 'nope' } } };
|
||||
expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js';
|
||||
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||
import { PROVIDERS } from '../provider-registry.js';
|
||||
import type { ProviderSnapshotEntry } from '../provider-types.js';
|
||||
|
||||
const registry = buildResolvedRegistry(PROVIDERS, {
|
||||
providers: {
|
||||
goose: { enabled: false },
|
||||
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] },
|
||||
},
|
||||
});
|
||||
|
||||
const alwaysAvailable = () => Promise.resolve(true);
|
||||
const neverAvailable = () => Promise.resolve(false);
|
||||
|
||||
describe('getProviderDiagnostic', () => {
|
||||
it('reports a disabled built-in (enabled:false, no install)', async () => {
|
||||
const report = await getProviderDiagnostic(registry.get('goose')!, undefined, {
|
||||
checkAvailable: neverAvailable,
|
||||
});
|
||||
expect(report).toContain('provider: goose');
|
||||
expect(report).toContain('enabled: false');
|
||||
expect(report).toContain('installed: false');
|
||||
expect(report).toMatch(/command_available:\s*false/);
|
||||
});
|
||||
|
||||
it('reports an installed built-in with its install_path, last_probed_at, model count', async () => {
|
||||
const agentRow: DiagnosticAgentRow = {
|
||||
name: 'opencode',
|
||||
install_path: '/usr/bin/opencode',
|
||||
supports_acp: true,
|
||||
models: [
|
||||
{ id: 'm1', label: 'M1' },
|
||||
{ id: 'm2', label: 'M2' },
|
||||
],
|
||||
last_probed_at: '2026-05-29T12:00:00.000Z',
|
||||
};
|
||||
const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, {
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toContain('install_path: /usr/bin/opencode');
|
||||
expect(report).toContain('2026-05-29T12:00:00.000Z');
|
||||
expect(report).toContain('installed: true');
|
||||
expect(report).toMatch(/models_in_db:\s*2/);
|
||||
expect(report).toMatch(/command_available:\s*true/);
|
||||
});
|
||||
|
||||
it('reports a custom ACP launch command + its binary', async () => {
|
||||
const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, {
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toContain('provider: amp-acp');
|
||||
expect(report).toContain('amp-acp --acp');
|
||||
expect(report).toContain('customAcp: true');
|
||||
});
|
||||
|
||||
it('surfaces the last probe error from a cached snapshot entry', async () => {
|
||||
const cachedEntry: ProviderSnapshotEntry = {
|
||||
name: 'opencode',
|
||||
label: 'OpenCode',
|
||||
transport: 'acp',
|
||||
status: 'error',
|
||||
enabled: true,
|
||||
installed: true,
|
||||
models: [],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
error: 'ACP initialize timed out',
|
||||
};
|
||||
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
|
||||
cachedEntry,
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toContain('ACP initialize timed out');
|
||||
});
|
||||
|
||||
it('reports no error when none is cached', async () => {
|
||||
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toMatch(/last_probe_error:\s*\(none/);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
prefixLlamaSwapModels,
|
||||
clearProviderSnapshotCache,
|
||||
getProviderSnapshot,
|
||||
peekSnapshotEntry,
|
||||
} from '../provider-snapshot.js';
|
||||
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||
|
||||
@@ -324,6 +325,18 @@ describe('getProviderSnapshot', () => {
|
||||
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
|
||||
loadConfigFixture({});
|
||||
// Cold cache → undefined (no build triggered).
|
||||
expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();
|
||||
|
||||
const sql = mockSql([]);
|
||||
await getProviderSnapshot(sql, config, '/tmp/peek', true);
|
||||
|
||||
expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
|
||||
expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({
|
||||
|
||||
@@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({
|
||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
||||
|
||||
/**
|
||||
* PATCH body schema (design.md §6.2). A partial providers map where each value
|
||||
* is either a full override object (REPLACES that id's override) or `null`
|
||||
* (DELETES the override → revert to the built-in default). Ids absent from the
|
||||
* patch are left untouched. The route validates the body against this first
|
||||
* (malformed → 422) so a bad shape can never reach the merge/save step.
|
||||
*/
|
||||
export const ProviderConfigPatchSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
|
||||
});
|
||||
|
||||
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
|
||||
|
||||
/**
|
||||
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
|
||||
* `patch.providers` REPLACES that id's override object wholesale (NOT a deep
|
||||
* field merge); a `null` value DELETES the override. Returns a new object —
|
||||
* never mutates `current`. The result is a plain CoderProvidersFile (no nulls),
|
||||
* which the route re-validates against CoderProvidersFileSchema before save.
|
||||
*/
|
||||
export function mergeProviderConfigPatch(
|
||||
current: CoderProvidersFile,
|
||||
patch: ProviderConfigPatch,
|
||||
): CoderProvidersFile {
|
||||
const providers: Record<string, ProviderOverride> = { ...current.providers };
|
||||
for (const [id, override] of Object.entries(patch.providers)) {
|
||||
if (override === null) {
|
||||
delete providers[id];
|
||||
} else {
|
||||
providers[id] = override;
|
||||
}
|
||||
}
|
||||
return { providers };
|
||||
}
|
||||
|
||||
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
||||
export function load(path: string): CoderProvidersFile {
|
||||
let raw: string;
|
||||
|
||||
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report.
|
||||
*
|
||||
* Read-only by default: reports CACHED state (resolved registry def + the
|
||||
* available_agents row + the warm snapshot-cache entry) plus a `which`-style
|
||||
* PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists
|
||||
* the live initialize probe as optional, and the route defaults to cached state.
|
||||
*
|
||||
* A template string is the whole formatter (no Paseo diagnostic-utils port).
|
||||
*/
|
||||
import type { ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js';
|
||||
import { isCommandAvailable } from './command-availability.js';
|
||||
|
||||
/** The subset of an `available_agents` row the diagnostic reads. */
|
||||
export interface DiagnosticAgentRow {
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
supports_acp?: boolean;
|
||||
models?: ProviderModel[] | null;
|
||||
last_probed_at?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DiagnosticOpts {
|
||||
/** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */
|
||||
cachedEntry?: ProviderSnapshotEntry;
|
||||
/** Injectable PATH check (defaults to the real `which`); stubbed in tests. */
|
||||
checkAvailable?: (binary: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Resolve the binary the dispatcher would launch (for the PATH check + report). */
|
||||
function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string {
|
||||
return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id;
|
||||
}
|
||||
|
||||
export async function getProviderDiagnostic(
|
||||
resolved: ResolvedProviderDef,
|
||||
agentRow: DiagnosticAgentRow | undefined,
|
||||
opts: DiagnosticOpts = {},
|
||||
): Promise<string> {
|
||||
const checkAvailable = opts.checkAvailable ?? isCommandAvailable;
|
||||
const installed = agentRow?.install_path != null;
|
||||
const binary = resolveBinary(resolved, agentRow);
|
||||
// boocode is native (no binary to launch) — short-circuit the PATH check.
|
||||
const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary);
|
||||
const lastProbedAt =
|
||||
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)';
|
||||
const modelCount = agentRow?.models?.length ?? 0;
|
||||
const launchCommand = resolved.launchCommand
|
||||
? resolved.launchCommand.join(' ')
|
||||
: '(built-in default, resolved at dispatch)';
|
||||
const lastError = opts.cachedEntry?.error ?? '(none recorded)';
|
||||
|
||||
return [
|
||||
`provider: ${resolved.id}`,
|
||||
`label: ${resolved.configLabel ?? resolved.label}`,
|
||||
`transport: ${resolved.transport}`,
|
||||
`enabled: ${resolved.enabled}`,
|
||||
`builtin: ${resolved.isBuiltin}`,
|
||||
`customAcp: ${resolved.isCustomAcp}`,
|
||||
`installed: ${installed}`,
|
||||
`install_path: ${agentRow?.install_path ?? '(none)'}`,
|
||||
`binary: ${binary}`,
|
||||
`command_available: ${commandAvailable}`,
|
||||
`launch_command: ${launchCommand}`,
|
||||
`supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`,
|
||||
`last_probed_at: ${lastProbedAt}`,
|
||||
`models_in_db: ${modelCount}`,
|
||||
`last_probe_error: ${lastError}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -283,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
|
||||
snapshotInflight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only peek into the warm snapshot cache for one provider (no build, no
|
||||
* probe). Used by the diagnostic route to report the last computed probe error
|
||||
* without spawning anything. Returns undefined on a cold cache / unknown name.
|
||||
*/
|
||||
export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined {
|
||||
const resolvedCwd = cwd?.trim() || homedir();
|
||||
return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name);
|
||||
}
|
||||
|
||||
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||
export async function persistProbedModels(
|
||||
sql: Sql,
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
AskUserAnswer,
|
||||
ToolCostStat,
|
||||
ProviderSnapshotEntry,
|
||||
CoderProvidersFile,
|
||||
ProviderConfigPatch,
|
||||
CoderSendMessageBody,
|
||||
CoderSendMessageResponse,
|
||||
CoderMessageWire,
|
||||
@@ -310,8 +312,23 @@ export const api = {
|
||||
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
||||
},
|
||||
refreshProviders: () =>
|
||||
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
|
||||
// v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
|
||||
refreshProviders: (providers?: string[]) =>
|
||||
request<{ refreshed: number }>('/api/coder/providers/refresh', {
|
||||
method: 'POST',
|
||||
...(providers && providers.length > 0 ? { body: JSON.stringify({ providers }) } : {}),
|
||||
}),
|
||||
// v2.3 Phase 4: read/patch the provider config file. PATCH returns the new
|
||||
// config; a `null` value in the patch deletes that id's override.
|
||||
getProvidersConfig: () => request<CoderProvidersFile>('/api/coder/providers/config'),
|
||||
patchProvidersConfig: (patch: ProviderConfigPatch) =>
|
||||
request<{ ok: true } & CoderProvidersFile>('/api/coder/providers/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
// v2.3 Phase 4: per-provider diagnostic — JSON { diagnostic: string } (§6.4).
|
||||
getProviderDiagnostic: (id: string) =>
|
||||
request<{ diagnostic: string }>(`/api/coder/providers/${encodeURIComponent(id)}/diagnostic`),
|
||||
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
||||
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -253,6 +253,31 @@ export interface ProviderSnapshotEntry {
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred
|
||||
// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts
|
||||
// (web can't cross-import the coder package — TS6307 on the composite project).
|
||||
export interface ProviderOverride {
|
||||
extends?: 'acp';
|
||||
label?: string;
|
||||
description?: string;
|
||||
command?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
order?: number;
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
additionalModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface CoderProvidersFile {
|
||||
providers: Record<string, ProviderOverride>;
|
||||
}
|
||||
|
||||
// PATCH body: a partial providers map. A `null` value deletes that id's
|
||||
// override (revert to built-in default); an object replaces it wholesale.
|
||||
export interface ProviderConfigPatch {
|
||||
providers: Record<string, ProviderOverride | null>;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
@@ -176,8 +176,12 @@ interface Props {
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
||||
// hidden here and managed in Settings → Providers. Native boocode is always
|
||||
// enabled+ready, so it always appears.
|
||||
const entries = useMemo(
|
||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
|
||||
[allEntries],
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
onChange(resolveConfig(entry, prefs));
|
||||
}, [entries, onChange, value.provider]);
|
||||
|
||||
// If the active provider is disabled in the settings drawer it drops out of
|
||||
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
|
||||
// strands on an unselectable provider with empty model/mode pickers.
|
||||
useEffect(() => {
|
||||
if (!entries?.length) return;
|
||||
if (entries.some((e) => e.name === value.provider)) return;
|
||||
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
|
||||
if (!fallback) return;
|
||||
onChange(resolveConfig(fallback, loadPrefs()));
|
||||
}, [entries, value.provider, onChange]);
|
||||
|
||||
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
|
||||
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
|
||||
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
|
||||
const pollsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||
if (!anyLoading) {
|
||||
pollsRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (pollsRef.current >= 10) return;
|
||||
const t = setTimeout(() => {
|
||||
pollsRef.current += 1;
|
||||
void refreshProviderSnapshot(projectPath);
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [allEntries, projectPath]);
|
||||
|
||||
const currentEntry = useMemo(
|
||||
() => entries?.find((e) => e.name === value.provider),
|
||||
[entries, value.provider],
|
||||
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={providerIcon(value.provider)}
|
||||
icon={
|
||||
currentEntry?.status === 'loading'
|
||||
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||
: providerIcon(value.provider)
|
||||
}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
|
||||
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ExternalLink, Search } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
ACP_PROVIDER_CATALOG,
|
||||
buildAcpProviderConfigPatch,
|
||||
type AcpCatalogEntry,
|
||||
} from '@/data/acp-provider-catalog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Fired after a successful add so the parent can refetch the snapshot. */
|
||||
onAdded: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 Phase 5 (design.md §7.3). Search the curated ACP catalog and register a
|
||||
* provider: PATCH /api/providers/config with its custom-ACP override, then
|
||||
* refresh that one provider. Adding only edits config — it does NOT install the
|
||||
* binary, so the provider shows "Not installed" until the CLI is on PATH.
|
||||
*/
|
||||
export function AddProviderModal({ open, onOpenChange, onAdded }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return ACP_PROVIDER_CATALOG;
|
||||
return ACP_PROVIDER_CATALOG.filter(
|
||||
(e) =>
|
||||
e.id.toLowerCase().includes(q) ||
|
||||
e.label.toLowerCase().includes(q) ||
|
||||
e.description.toLowerCase().includes(q),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
async function add(entry: AcpCatalogEntry): Promise<void> {
|
||||
setBusyId(entry.id);
|
||||
setError(null);
|
||||
try {
|
||||
await api.coder.patchProvidersConfig(buildAcpProviderConfigPatch(entry));
|
||||
await api.coder.refreshProviders([entry.id]);
|
||||
onAdded(entry.id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
// 422 from PATCH (invalid override) surfaces here as ApiError.message.
|
||||
setError(err instanceof Error ? err.message : 'failed to add provider');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add ACP provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Registers the provider in your coder config. It is not installed — install the CLI
|
||||
yourself; until it's on PATH it shows as “Not installed”.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col min-h-0 gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search providers…"
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 rounded-md border overflow-y-auto overscroll-contain divide-y">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">No matching providers.</div>
|
||||
)}
|
||||
{filtered.map((e) => (
|
||||
<div key={e.id} className="px-3 py-2.5 space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{e.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{e.description}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busyId !== null}
|
||||
onClick={() => void add(e)}
|
||||
>
|
||||
{busyId === e.id ? 'Adding…' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
$ {e.command.join(' ')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={e.installUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Install {e.label} <ExternalLink className="size-3" />
|
||||
</a>
|
||||
{e.installCmd && (
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
{e.installCmd}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive shrink-0">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busyId !== null}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AddProviderModal } from './AddProviderModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Map a snapshot entry to a status badge (design.md §7.1 labels). */
|
||||
function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } {
|
||||
if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' };
|
||||
if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' };
|
||||
if (e.status === 'ready')
|
||||
return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' };
|
||||
if (e.status === 'error')
|
||||
return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' };
|
||||
if (!e.installed)
|
||||
return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' };
|
||||
return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 — provider management as a Settings tab section (design.md §7.1). Lists
|
||||
* ALL registered providers (including the disabled/unavailable ones the composer
|
||||
* picker hides). Per row: label + model count, status badge, per-id refresh,
|
||||
* diagnostic, and an enable/disable toggle. Native boocode is always-on.
|
||||
*
|
||||
* Uses the home-cwd snapshot (no project arg) — provider management is global,
|
||||
* not per-project (design.md §4.5).
|
||||
*/
|
||||
export function ProvidersSettings() {
|
||||
const allEntries = useProviderSnapshot();
|
||||
const [config, setConfig] = useState<CoderProvidersFile | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [diagId, setDiagId] = useState<string | null>(null);
|
||||
const [diagText, setDiagText] = useState<string | null>(null);
|
||||
|
||||
// The raw config is needed to preserve a provider's FULL override when
|
||||
// toggling: the PATCH replaces an id's override wholesale, so a bare
|
||||
// { enabled } would wipe a custom ACP provider's command/label.
|
||||
useEffect(() => {
|
||||
api.coder
|
||||
.getProvidersConfig()
|
||||
.then(setConfig)
|
||||
.catch(() => setConfig({ providers: {} }));
|
||||
}, []);
|
||||
|
||||
// While any entry is loading, refetch until terminal (capped, no WS frame).
|
||||
const pollsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||
if (!anyLoading) {
|
||||
pollsRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (pollsRef.current >= 10) return;
|
||||
const t = setTimeout(() => {
|
||||
pollsRef.current += 1;
|
||||
void refreshProviderSnapshot();
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [allEntries]);
|
||||
|
||||
async function toggle(e: ProviderSnapshotEntry): Promise<void> {
|
||||
setBusyId(e.name);
|
||||
setError(null);
|
||||
try {
|
||||
const existing: ProviderOverride = config?.providers[e.name] ?? {};
|
||||
const resp = await api.coder.patchProvidersConfig({
|
||||
providers: { [e.name]: { ...existing, enabled: !e.enabled } },
|
||||
});
|
||||
setConfig({ providers: resp.providers });
|
||||
await refreshProviderSnapshot();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to update provider');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOne(id: string): Promise<void> {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await api.coder.refreshProviders([id]);
|
||||
await refreshProviderSnapshot();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to refresh');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDiagnostic(id: string): Promise<void> {
|
||||
if (diagId === id) {
|
||||
setDiagId(null);
|
||||
setDiagText(null);
|
||||
return;
|
||||
}
|
||||
setDiagId(id);
|
||||
setDiagText('Loading…');
|
||||
try {
|
||||
const { diagnostic } = await api.coder.getProviderDiagnostic(id);
|
||||
setDiagText(diagnostic);
|
||||
} catch (err) {
|
||||
setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic');
|
||||
}
|
||||
}
|
||||
|
||||
const entries = allEntries ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are
|
||||
hidden from the composer picker but managed here.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)} className="shrink-0">
|
||||
<Plus className="size-3.5" /> Add provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border divide-y">
|
||||
{allEntries === null && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
{entries.map((e) => {
|
||||
const badge = statusBadge(e);
|
||||
const isNative = e.transport === 'native';
|
||||
const busy = busyId === e.name;
|
||||
return (
|
||||
<div key={e.name} className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{e.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{e.models.length} model{e.models.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||||
badge.cls,
|
||||
)}
|
||||
>
|
||||
{e.status === 'loading' && <Loader2 className="size-3 mr-1 animate-spin" />}
|
||||
{badge.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshOne(e.name)}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label={`Refresh ${e.label}`}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDiagnostic(e.name)}
|
||||
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Diagnostic for ${e.label}`}
|
||||
title="Diagnostic"
|
||||
>
|
||||
<Stethoscope className="size-3.5" />
|
||||
</button>
|
||||
{isNative ? (
|
||||
<span className="text-[11px] text-muted-foreground w-14 text-center">
|
||||
Always on
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={e.enabled}
|
||||
disabled={busy}
|
||||
onClick={() => void toggle(e)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40',
|
||||
e.enabled ? 'bg-primary' : 'bg-muted-foreground/30',
|
||||
)}
|
||||
aria-label={`${e.enabled ? 'Disable' : 'Enable'} ${e.label}`}
|
||||
title={e.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-4 rounded-full bg-background transition-transform',
|
||||
e.enabled ? 'translate-x-4' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{diagId === e.name && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted/50 p-2 text-[11px] font-mono whitespace-pre-wrap">
|
||||
{diagText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<AddProviderModal
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
onAdded={() => void refreshProviderSnapshot()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { ModelPicker } from '@/components/ModelPicker';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Section = 'session' | 'project' | 'theme';
|
||||
type Section = 'session' | 'project' | 'theme' | 'providers';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
@@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{(['session', 'project', 'theme'] as const).map((s) => (
|
||||
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -116,6 +117,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
||||
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||
{activeSection === 'theme' && <ThemePicker />}
|
||||
{activeSection === 'providers' && <ProvidersSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ProviderConfigPatch } from '@/api/types';
|
||||
|
||||
/**
|
||||
* v2.3 Phase 5 (design.md §7.3) — a SMALL curated catalog of ACP coding agents
|
||||
* the user might register. We deliberately do NOT port Paseo's 30+ entry list.
|
||||
*
|
||||
* Non-goal: we never install anything. Each entry is a manual-install hint
|
||||
* (`installUrl` / `installCmd`) plus the config `command` that gets written into
|
||||
* `/data/coder-providers.json`. The user installs the CLI themselves; until the
|
||||
* binary is on PATH the provider shows as "Not installed". Commands are
|
||||
* editable after adding — versions are aliased/untrimmed on purpose; pin on your
|
||||
* own host once verified.
|
||||
*/
|
||||
export interface AcpCatalogEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Config command written verbatim into providers[id].command: [binary, ...args]. */
|
||||
command: [string, ...string[]];
|
||||
/** Where to install the CLI manually — we LINK, never install. */
|
||||
installUrl: string;
|
||||
/** Optional suggested install command, shown as a copyable hint. */
|
||||
installCmd?: string;
|
||||
}
|
||||
|
||||
export const ACP_PROVIDER_CATALOG: AcpCatalogEntry[] = [
|
||||
{
|
||||
id: 'amp-acp',
|
||||
label: 'Amp',
|
||||
description: 'Sourcegraph Amp — agentic coding CLI with an ACP bridge.',
|
||||
command: ['amp-acp'],
|
||||
installUrl: 'https://ampcode.com/',
|
||||
installCmd: 'npm i -g @sourcegraph/amp',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Gemini CLI',
|
||||
description: 'Google Gemini CLI in ACP mode (--experimental-acp).',
|
||||
command: ['gemini', '--experimental-acp'],
|
||||
installUrl: 'https://github.com/google-gemini/gemini-cli',
|
||||
installCmd: 'npm i -g @google/gemini-cli',
|
||||
},
|
||||
{
|
||||
id: 'cline',
|
||||
label: 'Cline',
|
||||
description: 'Cline coding agent over ACP (run via npx).',
|
||||
command: ['npx', '-y', 'cline', '--acp'],
|
||||
installUrl: 'https://cline.bot/',
|
||||
},
|
||||
{
|
||||
id: 'claude-code-acp',
|
||||
label: 'Claude Code (ACP)',
|
||||
description: "Zed's ACP adapter for Claude Code — distinct from the built-in PTY claude provider.",
|
||||
command: ['npx', '-y', '@zed-industries/claude-code-acp'],
|
||||
installUrl: 'https://github.com/zed-industries/claude-code-acp',
|
||||
},
|
||||
{
|
||||
id: 'pi-acp',
|
||||
label: 'Pi',
|
||||
description: 'Example custom ACP entry — build the binary from source, then edit the command.',
|
||||
command: ['pi-acp'],
|
||||
installUrl: 'https://agentclientprotocol.com/',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the PATCH body that registers a catalog entry: a single-id partial
|
||||
* providers map with the custom-ACP override (extends:'acp' + label + command),
|
||||
* enabled. Sent to PATCH /api/providers/config (then refreshProviders([id])).
|
||||
*/
|
||||
export function buildAcpProviderConfigPatch(entry: AcpCatalogEntry): ProviderConfigPatch {
|
||||
return {
|
||||
providers: {
|
||||
[entry.id]: {
|
||||
extends: 'acp',
|
||||
label: entry.label,
|
||||
description: entry.description,
|
||||
command: entry.command,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||
// SettingsPane component renders Session/Project sections from the
|
||||
// surrounding session/project.
|
||||
function settingsPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
function settingsPane(id: string = generateId()): WorkspacePane {
|
||||
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||
@@ -135,7 +135,7 @@ export interface UseWorkspacePanesResult {
|
||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||
toggleSettingsPane: () => void;
|
||||
toggleSettingsPane: () => string | null;
|
||||
removePane: (idx: number) => void;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
@@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
return success ? newPaneId : null;
|
||||
}, [seedPaneChat]);
|
||||
|
||||
const toggleSettingsPane = useCallback(() => {
|
||||
// Returns the new settings pane id when one is OPENED (so mobile callers can
|
||||
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
|
||||
// Id generated outside the updater so a strict-mode double-invoke agrees.
|
||||
const toggleSettingsPane = useCallback((): string | null => {
|
||||
const newPaneId = generateId();
|
||||
let openedId: string | null = null;
|
||||
setPanes((prev) => {
|
||||
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
||||
if (existingIdx < 0) {
|
||||
const next = [...prev, settingsPane()];
|
||||
const next = [...prev, settingsPane(newPaneId)];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
openedId = newPaneId;
|
||||
return next;
|
||||
}
|
||||
openedId = null;
|
||||
if (prev.length <= 1) {
|
||||
setActivePaneIdx(0);
|
||||
return [emptyPane()];
|
||||
@@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
return openedId;
|
||||
}, []);
|
||||
|
||||
const removePane = useCallback((idx: number) => {
|
||||
|
||||
@@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// v2.3: opening the settings pane on mobile must push ?pane= atomically, or
|
||||
// the URL-sync effect below snaps activePaneIdx back to the chat pane and the
|
||||
// settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane
|
||||
// returns the new pane id when it opens (null when it closes → drop ?pane= so
|
||||
// the effect falls back to pane 0). Desktop has no URL pane state — no-op.
|
||||
const toggleSettingsAndSync = useCallback(() => {
|
||||
const openedId = panesHook.toggleSettingsPane();
|
||||
if (!isMobile) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (openedId) params.set('pane', openedId);
|
||||
else params.delete('pane');
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
}, [panesHook, isMobile, navigate, location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'session_renamed' && event.session_id === sessionId) {
|
||||
@@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
// Sidebar Settings button broadcasts this when a session is mounted;
|
||||
// toggleSettingsPane opens on first click, closes on second.
|
||||
if (event.type === 'open_settings_pane') {
|
||||
panesHook.toggleSettingsPane();
|
||||
toggleSettingsAndSync();
|
||||
}
|
||||
});
|
||||
}, [sessionId, editingName, navigate, project, panesHook]);
|
||||
}, [sessionId, editingName, navigate, project, toggleSettingsAndSync]);
|
||||
|
||||
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
||||
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
|
||||
|
||||
Last updated: 2026-05-26
|
||||
Last updated: 2026-05-29
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ Last updated: 2026-05-26
|
||||
| Item | Category | User impact | Effort | Risk if left alone |
|
||||
|------|----------|-------------|--------|-------------------|
|
||||
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
|
||||
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 5–30s on cache miss | Medium (v2.3 batch) | Slow provider picker; repeated ACP spawns on every snapshot rebuild |
|
||||
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 5–30s on cache miss | ✅ Shipped (v2.3, Phase 2) | Resolved — `PROVIDER_PROBE_TTL_MS` TTL gate live |
|
||||
| Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web |
|
||||
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
|
||||
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
|
||||
@@ -111,7 +111,7 @@ There is also **no frontend** calling task cancel today (`grep` across `apps/web
|
||||
|
||||
## 2. Skip ACP cold probe when DB models are fresh
|
||||
|
||||
**Status:** Planned — [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). **Not shipped** (no `v2.3` tag; all tasks unchecked).
|
||||
**Status:** ✅ **ADDRESSED** in v2.3 (phases 1–5: `v2.5.4-provider-lifecycle-phase1` … `v2.5.12-provider-lifecycle-phase4`, plus the phase-5 settings UI + picker filter). The `PROVIDER_PROBE_TTL_MS` (default 24h) gate on `available_agents.last_probed_at` is live — the tier-2 cold ACP probe runs only on `force` (`POST /api/providers/refresh`), TTL staleness, or empty DB models; otherwise the snapshot serves cached models. See [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). The original (v2.2) behavior below is kept for history.
|
||||
|
||||
### Current behavior (v2.2)
|
||||
|
||||
@@ -140,12 +140,21 @@ See [`design.md`](../openspec/changes/v2-3-provider-lifecycle/design.md):
|
||||
|
||||
v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag.
|
||||
|
||||
### Acceptance criteria (when v2.3 ships)
|
||||
### Acceptance criteria — met
|
||||
|
||||
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in tests)
|
||||
- Disabled provider visible in settings, absent from composer
|
||||
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in `provider-snapshot.test.ts`)
|
||||
- Disabled provider visible in settings (Providers tab), absent from composer
|
||||
- Explicit refresh repopulates models; warm open is sub-second
|
||||
|
||||
### Still deferred (Tier-2 follow-ups, not shipped in v2.3)
|
||||
|
||||
These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open:
|
||||
|
||||
- **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1).
|
||||
- **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2).
|
||||
- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below).
|
||||
- **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11).
|
||||
|
||||
---
|
||||
|
||||
## 3. Unified `packages/types` for provider snapshot JSON
|
||||
|
||||
Reference in New Issue
Block a user