Compare commits
4 Commits
v2.5.9-age
...
v2.5.12-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| e83d9b7d5b | |||
| f302969c71 | |||
| 2d997ecb6c | |||
| dc3859975d |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.5.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.
|
||||||
|
|
||||||
|
## v2.5.10-opencode-live-commands — 2026-05-29
|
||||||
|
|
||||||
|
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
|
||||||
|
|
||||||
## v2.5.9-agent-slash-commands — 2026-05-29
|
## v2.5.9-agent-slash-commands — 2026-05-29
|
||||||
|
|
||||||
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
|
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
|
||||||
|
|||||||
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 type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { 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 {
|
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
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);
|
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();
|
clearProviderSnapshotCache();
|
||||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ CREATE OR REPLACE VIEW human_inbox AS
|
|||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||||
|
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
|
||||||
|
-- an agent's live command set survives the tier-2 probe skip and shows without a
|
||||||
|
-- dispatch.
|
||||||
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
-- v2.2.0: Paseo-style session config on tasks.
|
-- v2.2.0: Paseo-style session config on tasks.
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||||
|
|||||||
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,
|
prefixLlamaSwapModels,
|
||||||
clearProviderSnapshotCache,
|
clearProviderSnapshotCache,
|
||||||
getProviderSnapshot,
|
getProviderSnapshot,
|
||||||
|
peekSnapshotEntry,
|
||||||
} from '../provider-snapshot.js';
|
} from '../provider-snapshot.js';
|
||||||
import { loadProviderConfig } from '../provider-config-registry.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);
|
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 () => {
|
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||||
loadConfigFixture({});
|
loadConfigFixture({});
|
||||||
mockProbe.mockResolvedValue({
|
mockProbe.mockResolvedValue({
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export async function probeAcpProvider(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||||
|
// available_commands_update is an async session notification opencode sends
|
||||||
|
// shortly AFTER newSession resolves — reading probedCommands synchronously
|
||||||
|
// here races it and captures nothing. Wait briefly for the first batch, then
|
||||||
|
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
|
||||||
|
const deadline = Date.now() + 3_000;
|
||||||
|
while (probedCommands.length === 0 && Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
}
|
||||||
|
if (probedCommands.length > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
const result = parseSessionResponse(session, agent);
|
const result = parseSessionResponse(session, agent);
|
||||||
result.commands = probedCommands;
|
result.commands = probedCommands;
|
||||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||||
|
|||||||
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
|
||||||
|
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
|
||||||
|
*
|
||||||
|
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
|
||||||
|
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
|
||||||
|
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
|
||||||
|
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { AgentCommand } from './provider-types.js';
|
||||||
|
|
||||||
|
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
|
||||||
|
function frontmatterField(content: string, field: string): string | undefined {
|
||||||
|
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (!block?.[1]) return undefined;
|
||||||
|
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
||||||
|
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCommandDir(dir: string): AgentCommand[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: AgentCommand[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.endsWith('.md')) continue;
|
||||||
|
let description: string | undefined;
|
||||||
|
try {
|
||||||
|
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
|
||||||
|
} catch {
|
||||||
|
/* unreadable — still list the command by name */
|
||||||
|
}
|
||||||
|
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSkillDir(dir: string): AgentCommand[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: AgentCommand[] = [];
|
||||||
|
for (const sub of entries) {
|
||||||
|
const skillMd = join(dir, sub, 'SKILL.md');
|
||||||
|
if (!existsSync(skillMd)) continue;
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = readFileSync(skillMd, 'utf8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
name: frontmatterField(content, 'name') ?? sub,
|
||||||
|
kind: 'skill',
|
||||||
|
...(() => {
|
||||||
|
const d = frontmatterField(content, 'description');
|
||||||
|
return d ? { description: d } : {};
|
||||||
|
})(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverClaudeCommands(): AgentCommand[] {
|
||||||
|
const root = join(homedir(), '.claude');
|
||||||
|
const out: AgentCommand[] = [];
|
||||||
|
|
||||||
|
// User custom commands.
|
||||||
|
out.push(...readCommandDir(join(root, 'commands')));
|
||||||
|
|
||||||
|
// Enabled plugins (user-scope installs).
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
|
||||||
|
enabledPlugins?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
const installed = JSON.parse(
|
||||||
|
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
|
||||||
|
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
|
||||||
|
|
||||||
|
const enabled = settings.enabledPlugins ?? {};
|
||||||
|
const plugins = installed.plugins ?? {};
|
||||||
|
for (const [key, on] of Object.entries(enabled)) {
|
||||||
|
if (!on) continue;
|
||||||
|
const installs = plugins[key] ?? [];
|
||||||
|
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
|
||||||
|
if (!installPath || !existsSync(installPath)) continue;
|
||||||
|
out.push(...readSkillDir(join(installPath, 'skills')));
|
||||||
|
out.push(...readCommandDir(join(installPath, 'commands')));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* missing/unreadable plugin config → user commands only */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe by name (first wins).
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
|
||||||
|
}
|
||||||
@@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({
|
|||||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
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. */
|
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
||||||
export function load(path: string): CoderProvidersFile {
|
export function load(path: string): CoderProvidersFile {
|
||||||
let raw: string;
|
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');
|
||||||
|
}
|
||||||
@@ -11,17 +11,19 @@ import {
|
|||||||
PROVIDER_MANIFEST,
|
PROVIDER_MANIFEST,
|
||||||
} from './provider-manifest.js';
|
} from './provider-manifest.js';
|
||||||
import { probeAcpProvider } from './acp-probe.js';
|
import { probeAcpProvider } from './acp-probe.js';
|
||||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
|
||||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
import { isCommandAvailable } from './command-availability.js';
|
import { isCommandAvailable } from './command-availability.js';
|
||||||
|
import { discoverClaudeCommands } from './claude-command-discovery.js';
|
||||||
|
|
||||||
interface AgentRow {
|
interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
install_path: string | null;
|
install_path: string | null;
|
||||||
supports_acp: boolean;
|
supports_acp: boolean;
|
||||||
models: ProviderModel[] | null;
|
models: ProviderModel[] | null;
|
||||||
|
commands: AgentCommand[] | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
transport: string | null;
|
transport: string | null;
|
||||||
last_probed_at: string | Date | null;
|
last_probed_at: string | Date | null;
|
||||||
@@ -82,6 +84,9 @@ async function buildProviderEntry(
|
|||||||
const fallbackModes = getManifestModes(name);
|
const fallbackModes = getManifestModes(name);
|
||||||
const defaultModeId = getManifestDefaultModeId(name);
|
const defaultModeId = getManifestDefaultModeId(name);
|
||||||
const manifestCommands = getManifestCommands(name);
|
const manifestCommands = getManifestCommands(name);
|
||||||
|
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
|
||||||
|
// the agent's discovered commands show even when the tier-2 probe is skipped.
|
||||||
|
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
|
||||||
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
||||||
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
||||||
|
|
||||||
@@ -145,10 +150,12 @@ async function buildProviderEntry(
|
|||||||
|
|
||||||
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||||
if (name === 'claude') {
|
if (name === 'claude') {
|
||||||
|
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
|
||||||
|
// skills from disk live (the snapshot cache rate-limits the fs reads).
|
||||||
return {
|
return {
|
||||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
||||||
commands: manifestCommands,
|
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +182,7 @@ async function buildProviderEntry(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +220,7 @@ async function buildProviderEntry(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +249,7 @@ export async function getProviderSnapshot(
|
|||||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||||
const llamaModels = await fetchLlamaSwapModels(config);
|
const llamaModels = await fetchLlamaSwapModels(config);
|
||||||
const agents = await sql<AgentRow[]>`
|
const agents = await sql<AgentRow[]>`
|
||||||
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents
|
SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
|
||||||
`;
|
`;
|
||||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||||
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||||
@@ -276,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
|
|||||||
snapshotInflight.clear();
|
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. */
|
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||||
export async function persistProbedModels(
|
export async function persistProbedModels(
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -284,16 +301,34 @@ export async function persistProbedModels(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
if (entry.name === 'boocode') continue;
|
||||||
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
let persisted = false;
|
||||||
await sql`
|
if (entry.models.length > 0) {
|
||||||
UPDATE available_agents
|
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||||
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
await sql`
|
||||||
WHERE name = ${entry.name}
|
UPDATE available_agents
|
||||||
`;
|
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||||
count++;
|
WHERE name = ${entry.name}
|
||||||
|
`;
|
||||||
|
persisted = true;
|
||||||
|
}
|
||||||
|
// Persist captured ACP commands so they survive the tier-2 probe skip and
|
||||||
|
// show without a dispatch. Only when non-empty — never clobber a prior set.
|
||||||
|
if (entry.commands.length > 0) {
|
||||||
|
const flatCommands = entry.commands.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
...(c.description ? { description: c.description } : {}),
|
||||||
|
}));
|
||||||
|
await sql`
|
||||||
|
UPDATE available_agents
|
||||||
|
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
|
||||||
|
WHERE name = ${entry.name}
|
||||||
|
`;
|
||||||
|
persisted = true;
|
||||||
|
}
|
||||||
|
if (persisted) count++;
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'erro
|
|||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||||
|
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||||
|
kind?: 'command' | 'skill';
|
||||||
}
|
}
|
||||||
|
|
||||||
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type {
|
|||||||
AskUserAnswer,
|
AskUserAnswer,
|
||||||
ToolCostStat,
|
ToolCostStat,
|
||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
|
CoderProvidersFile,
|
||||||
|
ProviderConfigPatch,
|
||||||
CoderSendMessageBody,
|
CoderSendMessageBody,
|
||||||
CoderSendMessageResponse,
|
CoderSendMessageResponse,
|
||||||
CoderMessageWire,
|
CoderMessageWire,
|
||||||
@@ -310,8 +312,23 @@ export const api = {
|
|||||||
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||||
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
||||||
},
|
},
|
||||||
refreshProviders: () =>
|
// v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
|
||||||
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
|
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) =>
|
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
||||||
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -253,6 +253,31 @@ export interface ProviderSnapshotEntry {
|
|||||||
fetchedAt?: string;
|
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 {
|
export interface AgentSessionConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -273,6 +298,9 @@ export interface PermissionPrompt {
|
|||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||||
|
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||||
|
kind?: 'command' | 'skill';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoderSendMessageBody {
|
export interface CoderSendMessageBody {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { CSSProperties, RefObject } from 'react';
|
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export interface SlashCommandItem {
|
|||||||
export interface SlashCommandGroup {
|
export interface SlashCommandGroup {
|
||||||
label: string;
|
label: string;
|
||||||
items: SlashCommandItem[];
|
items: SlashCommandItem[];
|
||||||
|
icon?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,7 +51,7 @@ export function SlashCommandPicker({
|
|||||||
() =>
|
() =>
|
||||||
groups
|
groups
|
||||||
? groups
|
? groups
|
||||||
.map((g) => ({ label: g.label, items: filterByPrefix(g.items, query) }))
|
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
|
||||||
.filter((g) => g.items.length > 0)
|
.filter((g) => g.items.length > 0)
|
||||||
: null,
|
: null,
|
||||||
[groups, query],
|
[groups, query],
|
||||||
@@ -203,7 +204,8 @@ export function SlashCommandPicker({
|
|||||||
{filteredGroups
|
{filteredGroups
|
||||||
? filteredGroups.map((g) => (
|
? filteredGroups.map((g) => (
|
||||||
<div key={g.label}>
|
<div key={g.label}>
|
||||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 flex items-center gap-1.5">
|
||||||
|
{g.icon}
|
||||||
{g.label}
|
{g.label}
|
||||||
</div>
|
</div>
|
||||||
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Code, Check, X, RefreshCw } from 'lucide-react';
|
import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
|
||||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||||
import { PermissionCard } from '@/components/PermissionCard';
|
import { PermissionCard } from '@/components/PermissionCard';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
@@ -525,12 +526,31 @@ export function CoderPane({
|
|||||||
[skills],
|
[skills],
|
||||||
);
|
);
|
||||||
const slashGroups = useMemo(() => {
|
const slashGroups = useMemo(() => {
|
||||||
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = [];
|
const groups: SlashCommandGroup[] = [];
|
||||||
if (agentCommands.length > 0) {
|
// Split the active agent's set: native/CLI commands vs plugin skills, each
|
||||||
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands });
|
// with its own icon. BooCoder skills always come last.
|
||||||
|
const agentCmds = agentCommands.filter((c) => c.kind !== 'skill');
|
||||||
|
const agentSkills = agentCommands.filter((c) => c.kind === 'skill');
|
||||||
|
if (agentCmds.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: `${agentConfig.provider} commands`,
|
||||||
|
items: agentCmds,
|
||||||
|
icon: <Terminal className="size-3 shrink-0" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (agentSkills.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: `${agentConfig.provider} skills`,
|
||||||
|
items: agentSkills,
|
||||||
|
icon: <Puzzle className="size-3 shrink-0" />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (skillItems.length > 0) {
|
if (skillItems.length > 0) {
|
||||||
groups.push({ label: 'Skills', items: skillItems });
|
groups.push({
|
||||||
|
label: 'BooCoder skills',
|
||||||
|
items: skillItems,
|
||||||
|
icon: <Sparkles className="size-3 shrink-0" />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [agentCommands, skillItems, agentConfig.provider]);
|
}, [agentCommands, skillItems, agentConfig.provider]);
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function parseSlashInput(text: string): { cmdName: string; args: string }
|
|||||||
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
|
export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
|
||||||
const byName = new Map<string, SlashCommandItem>();
|
const byName = new Map<string, T>();
|
||||||
for (const list of lists) {
|
for (const list of lists) {
|
||||||
for (const cmd of list) {
|
for (const cmd of list) {
|
||||||
byName.set(cmd.name, cmd);
|
byName.set(cmd.name, cmd);
|
||||||
|
|||||||
Reference in New Issue
Block a user