Compare commits
5 Commits
v2.5.4-pro
...
v2.5.9-age
| Author | SHA1 | Date | |
|---|---|---|---|
| 23a33e893a | |||
| 8bf86ecb92 | |||
| fe52250d78 | |||
| 4035aa2b98 | |||
| 35a0aba211 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,4 +16,5 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
|
!data/coder-providers.json
|
||||||
codecontext/fork.tar.gz
|
codecontext/fork.tar.gz
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
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.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.
|
||||||
|
|
||||||
|
## v2.5.8-mobile-composer-row — 2026-05-29
|
||||||
|
|
||||||
|
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
|
||||||
|
|
||||||
|
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
|
||||||
|
|
||||||
|
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||||
|
|
||||||
|
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
|
||||||
|
|
||||||
|
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 5–30s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
|
||||||
|
|
||||||
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
|
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
|
||||||
|
|
||||||
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
|
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const ConfigSchema = z.object({
|
|||||||
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
|
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
|
||||||
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
|
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
|
||||||
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
|
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
|
||||||
|
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
|
||||||
|
// probed more recently than this. 24h default — stale model lists self-heal
|
||||||
|
// on the next snapshot; an explicit /refresh always re-probes.
|
||||||
|
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
|
||||||
// v2.0.5: cheaper model for titles, summaries, labeling.
|
// v2.0.5: cheaper model for titles, summaries, labeling.
|
||||||
FAST_MODEL: z.string().optional(),
|
FAST_MODEL: z.string().optional(),
|
||||||
// SSH access to the host for external agent dispatch (Phase 5)
|
// SSH access to the host for external agent dispatch (Phase 5)
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
|
|||||||
pane_id: z.string().min(1).max(200),
|
pane_id: z.string().min(1).max(200),
|
||||||
skill_name: z.string().min(1),
|
skill_name: z.string().min(1),
|
||||||
user_message: z.string().max(64_000).nullable().optional(),
|
user_message: z.string().max(64_000).nullable().optional(),
|
||||||
|
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
|
||||||
|
// its body is injected into a dispatched task instead of native inference.
|
||||||
|
provider: z.string().max(100).optional(),
|
||||||
|
model: z.string().max(200).optional(),
|
||||||
|
mode_id: z.string().max(200).optional(),
|
||||||
|
thinking_option_id: z.string().max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface InferenceApi {
|
interface InferenceApi {
|
||||||
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const { pane_id, skill_name } = parsed.data;
|
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
|
||||||
const sessionRows = await sql<{ id: string }[]>`
|
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) {
|
if (sessionRows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
|
|||||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
|
||||||
|
// stays server-side (like the native path's tool message) and is injected
|
||||||
|
// into a dispatched task; the agent receives the skill instructions + the
|
||||||
|
// user's text. Mirrors the messages-route external-provider dispatch.
|
||||||
|
if (provider && provider !== 'boocode') {
|
||||||
|
const [userMsg] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
|
||||||
|
|
||||||
|
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||||
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||||
|
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||||
|
RETURNING id, state
|
||||||
|
`;
|
||||||
|
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
reply.code(202);
|
||||||
|
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||||
|
}
|
||||||
|
|
||||||
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||||
sessionId,
|
sessionId,
|
||||||
chatId,
|
chatId,
|
||||||
|
|||||||
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveLaunchSpec, resolveAcpSpawnArgs } from '../acp-spawn.js';
|
||||||
|
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||||
|
import type { CoderProvidersFile } from '../provider-config.js';
|
||||||
|
import { PROVIDERS } from '../provider-registry.js';
|
||||||
|
|
||||||
|
/** Resolved def for a provider id under the given config (default: no override). */
|
||||||
|
function builtin(name: string, providers: CoderProvidersFile['providers'] = {}) {
|
||||||
|
const def = buildResolvedRegistry(PROVIDERS, { providers }).get(name);
|
||||||
|
if (!def) throw new Error(`no resolved def for ${name}`);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveLaunchSpec', () => {
|
||||||
|
// --- byte-identical built-in regression (the HARD CONSTRAINT) ---------------
|
||||||
|
// These argv values are the pre-v2.3 resolveAcpSpawnArgs switch outputs and
|
||||||
|
// MUST NOT change. spawn() is `spawn(spec.binary, spec.args, ...)`, so argv
|
||||||
|
// parity here is dispatch parity.
|
||||||
|
it('opencode (no override) → byte-identical argv ["acp"], binary = installPath', () => {
|
||||||
|
const spec = resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode');
|
||||||
|
expect(spec).not.toBeNull();
|
||||||
|
expect(spec!.args).toEqual(['acp']); // pre-v2.3 value
|
||||||
|
expect(spec!.binary).toBe('/usr/bin/opencode');
|
||||||
|
expect(spec!.env).toBeUndefined();
|
||||||
|
// cross-check against the switch source-of-truth
|
||||||
|
expect(spec!.args).toEqual(resolveAcpSpawnArgs('opencode'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goose → ["acp"], qwen → ["--acp"] (byte-identical)', () => {
|
||||||
|
expect(resolveLaunchSpec(builtin('goose'), '/usr/bin/goose')!.args).toEqual(['acp']);
|
||||||
|
expect(resolveLaunchSpec(builtin('qwen'), '/usr/bin/qwen')!.args).toEqual(['--acp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built-in with null installPath falls back to the bare id (pre-v2.3 `installPath ?? agent`)', () => {
|
||||||
|
const spec = resolveLaunchSpec(builtin('opencode'), null);
|
||||||
|
expect(spec!.binary).toBe('opencode');
|
||||||
|
expect(spec!.args).toEqual(['acp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-ACP / unknown provider → null (claude has no ACP argv)', () => {
|
||||||
|
expect(resolveLaunchSpec(builtin('claude'), '/usr/bin/claude')).toBeNull();
|
||||||
|
expect(resolveLaunchSpec(builtin('boocode'), null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- config-driven launch (the new capability) ------------------------------
|
||||||
|
it('custom ACP entry → configured command + env reach the spec', () => {
|
||||||
|
const def = builtin('amp-acp', {
|
||||||
|
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'], env: { AMP_KEY: 'x' } },
|
||||||
|
});
|
||||||
|
const spec = resolveLaunchSpec(def, '/usr/local/bin/amp-acp');
|
||||||
|
expect(spec).not.toBeNull();
|
||||||
|
expect(spec!.binary).toBe('amp-acp'); // command[0], not the resolved install path
|
||||||
|
expect(spec!.args).toEqual(['--acp']); // command.slice(1)
|
||||||
|
expect(spec!.env).toEqual({ AMP_KEY: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built-in WITH a config command override uses the override, not the switch default', () => {
|
||||||
|
const def = builtin('opencode', { opencode: { command: ['opencode', 'acp', '--verbose'], env: { DEBUG: '1' } } });
|
||||||
|
const spec = resolveLaunchSpec(def, '/usr/bin/opencode');
|
||||||
|
expect(spec!.binary).toBe('opencode');
|
||||||
|
expect(spec!.args).toEqual(['acp', '--verbose']);
|
||||||
|
expect(spec!.env).toEqual({ DEBUG: '1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acp-dispatch spawn wiring (documented pass-through)', () => {
|
||||||
|
// dispatchViaAcp spawns `spawn(spec.binary, spec.args, { env: { ...process.env, ...spec.env } })`.
|
||||||
|
// The env merge layers config env over process.env; for a built-in with no
|
||||||
|
// config env, spec.env is undefined → { ...process.env } (byte-identical).
|
||||||
|
it('built-in with no config env yields an undefined spec.env (→ plain process.env at spawn)', () => {
|
||||||
|
expect(resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode')!.env).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,6 +61,22 @@ describe('buildResolvedRegistry', () => {
|
|||||||
warn.mockRestore();
|
warn.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries config models + additionalModels onto built-in and custom defs', () => {
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, {
|
||||||
|
providers: {
|
||||||
|
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
|
||||||
|
'amp-acp': {
|
||||||
|
extends: 'acp',
|
||||||
|
label: 'Amp',
|
||||||
|
command: ['amp-acp'],
|
||||||
|
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
|
||||||
|
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
|
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
|
||||||
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||||
expect(reg.size).toBe(PROVIDERS.length);
|
expect(reg.size).toBe(PROVIDERS.length);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
mergeModels,
|
mergeModels,
|
||||||
prefixLlamaSwapModels,
|
prefixLlamaSwapModels,
|
||||||
clearProviderSnapshotCache,
|
clearProviderSnapshotCache,
|
||||||
getProviderSnapshot,
|
getProviderSnapshot,
|
||||||
} from '../provider-snapshot.js';
|
} from '../provider-snapshot.js';
|
||||||
|
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||||
|
|
||||||
vi.mock('../acp-probe.js', () => ({
|
vi.mock('../acp-probe.js', () => ({
|
||||||
probeAcpProvider: vi.fn(),
|
probeAcpProvider: vi.fn(),
|
||||||
@@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js';
|
|||||||
|
|
||||||
const mockProbe = vi.mocked(probeAcpProvider);
|
const mockProbe = vi.mocked(probeAcpProvider);
|
||||||
|
|
||||||
|
/** Write a temp coder-providers.json and point the resolved registry at it. */
|
||||||
|
function loadConfigFixture(providers: Record<string, unknown>): void {
|
||||||
|
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
|
||||||
|
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
|
||||||
|
loadProviderConfig(path);
|
||||||
|
}
|
||||||
|
|
||||||
function mockSql(agents: Array<{
|
function mockSql(agents: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
install_path: string | null;
|
install_path: string | null;
|
||||||
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
|
|||||||
models: Array<{ id: string; label: string }> | null;
|
models: Array<{ id: string; label: string }> | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
transport: string | null;
|
transport: string | null;
|
||||||
|
last_probed_at?: string | null;
|
||||||
}>) {
|
}>) {
|
||||||
return vi.fn((strings: TemplateStringsArray) => {
|
return vi.fn((strings: TemplateStringsArray) => {
|
||||||
const query = strings.join('');
|
const query = strings.join('');
|
||||||
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||||
|
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||||
} as import('../config.js').Config;
|
} as import('../config.js').Config;
|
||||||
|
|
||||||
describe('prefixLlamaSwapModels', () => {
|
describe('prefixLlamaSwapModels', () => {
|
||||||
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
|
|||||||
describe('getProviderSnapshot', () => {
|
describe('getProviderSnapshot', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearProviderSnapshotCache();
|
clearProviderSnapshotCache();
|
||||||
|
// Reset the resolved registry to built-ins-only (missing path → {} config).
|
||||||
|
loadProviderConfig('/nonexistent-coder-providers.json');
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
@@ -165,4 +180,178 @@ describe('getProviderSnapshot', () => {
|
|||||||
expect(claude?.modes.length).toBeGreaterThan(0);
|
expect(claude?.modes.length).toBeGreaterThan(0);
|
||||||
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
|
||||||
|
loadConfigFixture({ goose: { enabled: false } });
|
||||||
|
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [{ id: 'g1', label: 'G1' }],
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const goose = entries.find((e) => e.name === 'goose');
|
||||||
|
|
||||||
|
expect(goose?.status).toBe('unavailable');
|
||||||
|
expect(goose?.enabled).toBe(false);
|
||||||
|
expect(goose?.installed).toBe(false);
|
||||||
|
expect(mockProbe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||||
|
|
||||||
|
const sql = mockSql([]); // nothing probed/installed
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const opencode = entries.find((e) => e.name === 'opencode');
|
||||||
|
|
||||||
|
expect(opencode?.status).toBe('unavailable');
|
||||||
|
expect(opencode?.enabled).toBe(true);
|
||||||
|
expect(opencode?.installed).toBe(false);
|
||||||
|
expect(mockProbe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
// If this were wrongly called, cached-goose would be replaced and the
|
||||||
|
// not.toHaveBeenCalled assertion would fail.
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: new Date().toISOString(), // fresh
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// force=false → cache-miss returns loading; second call joins the build / cache.
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||||
|
const goose = entries.find((e) => e.name === 'goose');
|
||||||
|
|
||||||
|
expect(goose?.status).toBe('ready');
|
||||||
|
expect(goose?.installed).toBe(true);
|
||||||
|
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
|
||||||
|
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
|
||||||
|
expect(mockProbe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'fresh-probe', label: 'Fresh' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [{ id: 'cached-goose', label: 'Cached' }],
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: new Date().toISOString(), // fresh, but force overrides
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
|
||||||
|
expect(mockProbe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('native boocode → ready, enabled, installed', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
const sql = mockSql([]);
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const boocode = entries.find((e) => e.name === 'boocode');
|
||||||
|
|
||||||
|
expect(boocode?.status).toBe('ready');
|
||||||
|
expect(boocode?.enabled).toBe(true);
|
||||||
|
expect(boocode?.installed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
|
||||||
|
loadConfigFixture({
|
||||||
|
claude: {
|
||||||
|
models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
|
||||||
|
additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'claude',
|
||||||
|
install_path: '/usr/bin/claude',
|
||||||
|
supports_acp: false,
|
||||||
|
models: [{ id: 'old-static', label: 'Old' }],
|
||||||
|
label: 'Claude Code',
|
||||||
|
transport: 'pty',
|
||||||
|
last_probed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const claude = entries.find((e) => e.name === 'claude');
|
||||||
|
const ids = claude!.models.map((m) => m.id);
|
||||||
|
|
||||||
|
expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
|
||||||
|
expect(ids).toContain('sonnet'); // additionalModels merged on top
|
||||||
|
expect(ids).not.toContain('old-static'); // replaced, not appended
|
||||||
|
// thinking options still attach to the config-provided models
|
||||||
|
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'm1', label: 'M1' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: null,
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
|
||||||
|
const probeCallsAfterFirst = mockProbe.mock.calls.length;
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
|
||||||
|
const probeCallsAfterSecond = mockProbe.mock.calls.length;
|
||||||
|
|
||||||
|
// Success criterion: second snapshot is served from cache with no ACP spawns.
|
||||||
|
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parity guard between the two copies of the provider snapshot types:
|
||||||
|
* apps/coder/src/services/provider-types.ts (backend source of truth)
|
||||||
|
* apps/web/src/api/types.ts (web wire copy)
|
||||||
|
*
|
||||||
|
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
|
||||||
|
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
|
||||||
|
* assignability check was attempted first (a web-side file importing coder's
|
||||||
|
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
|
||||||
|
* project and rejects out-of-include files with TS6307 — so cross-project type
|
||||||
|
* import is structurally blocked. This runtime guard FAILS on any field
|
||||||
|
* add/remove/rename/loosen in either copy, including the nested model/mode/
|
||||||
|
* command types that ProviderSnapshotEntry references. Single-source-of-truth
|
||||||
|
* (shared workspace package) is deferred as a Tier-2 follow-up.
|
||||||
|
*/
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
|
||||||
|
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
|
||||||
|
|
||||||
|
function extractBlock(src: string, name: string): string {
|
||||||
|
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
|
||||||
|
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
|
||||||
|
const block = iface?.[0] ?? alias?.[0];
|
||||||
|
if (!block) throw new Error(`type block '${name}' not found`);
|
||||||
|
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
|
||||||
|
// trim each line. Field add/remove/rename/loosen still changes a field line.
|
||||||
|
return block
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
l.length > 0 &&
|
||||||
|
!l.startsWith('//') &&
|
||||||
|
!l.startsWith('/*') &&
|
||||||
|
!l.startsWith('*'),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('provider snapshot type parity (coder ↔ web)', () => {
|
||||||
|
// Includes the nested types ProviderSnapshotEntry references, so structural
|
||||||
|
// drift anywhere in the snapshot surface is caught.
|
||||||
|
const names = [
|
||||||
|
'ProviderSnapshotStatus',
|
||||||
|
'ProviderSnapshotEntry',
|
||||||
|
'ProviderModel',
|
||||||
|
'ProviderMode',
|
||||||
|
'ThinkingOption',
|
||||||
|
'AgentCommand',
|
||||||
|
];
|
||||||
|
for (const name of names) {
|
||||||
|
it(`${name} is identical in both copies`, () => {
|
||||||
|
expect(
|
||||||
|
extractBlock(webSrc, name),
|
||||||
|
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
|
||||||
|
).toBe(extractBlock(coderSrc, name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -26,7 +26,8 @@ import type { Broker } from '@boocode/server/broker';
|
|||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
import { resolveLaunchSpec } from './acp-spawn.js';
|
||||||
|
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||||
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||||
@@ -59,6 +60,9 @@ export interface AcpDispatchOpts {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
broker?: Broker;
|
broker?: Broker;
|
||||||
installPath?: string;
|
installPath?: string;
|
||||||
|
/** v2.3 phase 3: resolved registry def for launch-spec resolution. The
|
||||||
|
* dispatcher loads this by task.agent; falls back to a registry lookup here. */
|
||||||
|
resolved?: ResolvedProviderDef;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
log: FastifyBaseLogger;
|
log: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
@@ -282,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
|||||||
broker,
|
broker,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const args = resolveAcpSpawnArgs(agent);
|
// v2.3 phase 3: launch from the resolved registry def (config override /
|
||||||
if (!args) {
|
// custom-ACP command) with the built-in switch as the fallback. The dispatcher
|
||||||
|
// passes `resolved`; fall back to a registry lookup if it didn't.
|
||||||
|
const resolved = opts.resolved ?? getResolvedRegistry().get(agent);
|
||||||
|
const spec = resolved ? resolveLaunchSpec(resolved, installPath ?? null) : null;
|
||||||
|
if (!spec) {
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
output: `Agent '${agent}' does not support ACP.`,
|
output: `Agent '${agent}' does not support ACP.`,
|
||||||
@@ -293,12 +301,11 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const binary = installPath ?? agent;
|
log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||||
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
const child = spawn(spec.binary, spec.args, {
|
||||||
const child = spawn(binary, args, {
|
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env },
|
env: { ...process.env, ...spec.env },
|
||||||
});
|
});
|
||||||
|
|
||||||
const streamCtx = new AcpStreamContext(
|
const streamCtx = new AcpStreamContext(
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import type { ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
|
* Resolve ACP spawn argv per built-in provider (host-probe verified 2026-05-25).
|
||||||
|
* Source of truth for built-in default argv — resolveLaunchSpec wraps these; it
|
||||||
|
* does NOT replace them.
|
||||||
*/
|
*/
|
||||||
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||||
switch (agent) {
|
switch (agent) {
|
||||||
@@ -13,6 +17,34 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2.3 phase 3: resolve the launch spec for an ACP dispatch (design.md §5.1).
|
||||||
|
* Consults the resolved registry's launchCommand (config override or custom-ACP
|
||||||
|
* entry) first; otherwise falls back to the built-in default argv above.
|
||||||
|
*
|
||||||
|
* Byte-identical to pre-v2.3 for built-ins with no override: binary is
|
||||||
|
* `installPath ?? id` and args come from resolveAcpSpawnArgs — exactly the
|
||||||
|
* `binary = installPath ?? agent` + `resolveAcpSpawnArgs(agent)` the dispatcher
|
||||||
|
* used before. (Deliberate deviation from design §5.1's `!installPath → null`:
|
||||||
|
* the old path spawned the bare agent name when install_path was missing, so we
|
||||||
|
* preserve the `?? id` fallback rather than fail.)
|
||||||
|
*/
|
||||||
|
export function resolveLaunchSpec(
|
||||||
|
resolved: ResolvedProviderDef,
|
||||||
|
installPath: string | null,
|
||||||
|
): { binary: string; args: string[]; env?: Record<string, string> } | null {
|
||||||
|
if (resolved.launchCommand) {
|
||||||
|
return {
|
||||||
|
binary: resolved.launchCommand[0],
|
||||||
|
args: resolved.launchCommand.slice(1),
|
||||||
|
env: resolved.env,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const args = resolveAcpSpawnArgs(resolved.id);
|
||||||
|
if (!args) return null;
|
||||||
|
return { binary: installPath ?? resolved.id, args, env: resolved.env };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAcpProbeBinaries(agent: string): string[] {
|
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||||
return [agent];
|
return [agent];
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/coder/src/services/command-availability.ts
Normal file
22
apps/coder/src/services/command-availability.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
|
||||||
|
*
|
||||||
|
* Uses execFile (NO shell) because the binary name can come from the provider
|
||||||
|
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
|
||||||
|
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
|
||||||
|
* `install_path`); this returns a boolean. Kept separate rather than over-
|
||||||
|
* refactored into one helper — different return contracts, two short call sites.
|
||||||
|
*/
|
||||||
|
import { execFile as execFileCb } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
|
export async function isCommandAvailable(binary: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
|
||||||
|
return stdout.trim().length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
|
|||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
|
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||||
import { dispatchViaPty } from './pty-dispatch.js';
|
import { dispatchViaPty } from './pty-dispatch.js';
|
||||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||||
import { getManifestCommands } from './provider-commands.js';
|
import { getManifestCommands } from './provider-commands.js';
|
||||||
@@ -340,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
if (supportsAcp) {
|
if (supportsAcp) {
|
||||||
const result = await dispatchViaAcp({
|
const result = await dispatchViaAcp({
|
||||||
agent,
|
agent,
|
||||||
|
resolved: getResolvedRegistry().get(agent),
|
||||||
task: task.input,
|
task: task.input,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
installPath: installPath ?? undefined,
|
installPath: installPath ?? undefined,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface ResolvedProviderDef extends ProviderDef {
|
|||||||
env: Record<string, string> | undefined;
|
env: Record<string, string> | undefined;
|
||||||
configLabel?: string;
|
configLabel?: string;
|
||||||
configDescription?: string;
|
configDescription?: string;
|
||||||
|
/** Config `models` — REPLACES the discovered/static model list when present. */
|
||||||
|
configModels?: Array<{ id: string; label: string }>;
|
||||||
|
/** Config `additionalModels` — MERGED on top of the resolved model list. */
|
||||||
|
configAdditionalModels?: Array<{ id: string; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +65,8 @@ export function buildResolvedRegistry(
|
|||||||
env: ov?.env,
|
env: ov?.env,
|
||||||
configLabel: ov?.label,
|
configLabel: ov?.label,
|
||||||
configDescription: ov?.description,
|
configDescription: ov?.description,
|
||||||
|
configModels: ov?.models,
|
||||||
|
configAdditionalModels: ov?.additionalModels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +93,8 @@ export function buildResolvedRegistry(
|
|||||||
env: ov.env,
|
env: ov.env,
|
||||||
configLabel: ov.label,
|
configLabel: ov.label,
|
||||||
configDescription: ov.description,
|
configDescription: ov.description,
|
||||||
|
configModels: ov.models,
|
||||||
|
configAdditionalModels: ov.additionalModels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
transport: 'pty',
|
transport: 'pty',
|
||||||
modelSource: 'static',
|
modelSource: 'static',
|
||||||
|
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
|
||||||
|
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
|
||||||
|
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
|
||||||
|
// exact version. Extend/replace per-install via data/coder-providers.json
|
||||||
|
// (models / additionalModels) without a code change.
|
||||||
staticModels: [
|
staticModels: [
|
||||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
{ id: 'opus', label: 'Opus (latest)' },
|
||||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
|
||||||
|
{ id: 'sonnet', label: 'Sonnet (latest)' },
|
||||||
|
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
|
||||||
|
{ id: 'haiku', label: 'Haiku (latest)' },
|
||||||
|
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { homedir } from 'node:os';
|
|||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
|
||||||
import {
|
import {
|
||||||
getManifestDefaultModeId,
|
getManifestDefaultModeId,
|
||||||
getManifestModes,
|
getManifestModes,
|
||||||
@@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js';
|
|||||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
|
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
|
import { isCommandAvailable } from './command-availability.js';
|
||||||
|
|
||||||
interface AgentRow {
|
interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +24,7 @@ interface AgentRow {
|
|||||||
models: ProviderModel[] | null;
|
models: ProviderModel[] | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
transport: string | null;
|
transport: string | null;
|
||||||
|
last_probed_at: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||||
@@ -68,110 +70,150 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildProviderEntry(
|
async function buildProviderEntry(
|
||||||
provider: ProviderDef,
|
resolved: ResolvedProviderDef,
|
||||||
agentRow: AgentRow | undefined,
|
agentRow: AgentRow | undefined,
|
||||||
llamaModels: ProviderModel[],
|
llamaModels: ProviderModel[],
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<ProviderSnapshotEntry | null> {
|
ttlMs: number,
|
||||||
const isNative = provider.name === 'boocode';
|
force: boolean,
|
||||||
const installed = isNative || !!agentRow;
|
): Promise<ProviderSnapshotEntry> {
|
||||||
if (!installed) return null;
|
const name = resolved.id;
|
||||||
|
const isNative = resolved.transport === 'native';
|
||||||
|
const fallbackModes = getManifestModes(name);
|
||||||
|
const defaultModeId = getManifestDefaultModeId(name);
|
||||||
|
const manifestCommands = getManifestCommands(name);
|
||||||
|
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
||||||
|
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
||||||
|
|
||||||
let transport = provider.transport;
|
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
|
||||||
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
// MERGES on top. Applied to every ready/installed model list below.
|
||||||
|
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
|
||||||
|
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
|
||||||
|
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
|
||||||
|
out = mergeModels(out, resolved.configAdditionalModels);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
|
||||||
|
let transport = resolved.transport;
|
||||||
|
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
|
||||||
transport = 'pty';
|
transport = 'pty';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackModes = getManifestModes(provider.name);
|
// 1. Disabled → unavailable, no probe.
|
||||||
const defaultModeId = getManifestDefaultModeId(provider.name);
|
if (!resolved.enabled) {
|
||||||
|
|
||||||
if (isNative) {
|
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, ...descr, transport, status: 'unavailable',
|
||||||
label: provider.label,
|
enabled: false, installed: false, models: [], modes: fallbackModes,
|
||||||
transport,
|
defaultModeId, commands: manifestCommands,
|
||||||
status: 'ready',
|
|
||||||
installed: true,
|
|
||||||
models: llamaModels,
|
|
||||||
modes: [],
|
|
||||||
defaultModeId: null,
|
|
||||||
commands: getManifestCommands(provider.name),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Native boocode → always ready (llama-swap models).
|
||||||
|
if (isNative) {
|
||||||
|
return {
|
||||||
|
name, label: resolved.label, transport, status: 'ready',
|
||||||
|
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
|
||||||
|
defaultModeId: null, commands: manifestCommands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
|
||||||
|
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
|
||||||
|
const fast =
|
||||||
|
agentRow?.install_path != null ||
|
||||||
|
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
|
||||||
|
|
||||||
|
if (!fast) {
|
||||||
|
return {
|
||||||
|
name, label, ...descr, transport, status: 'unavailable',
|
||||||
|
enabled: true, installed: false, models: [], modes: fallbackModes,
|
||||||
|
defaultModeId, commands: manifestCommands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline model precedence (used by claude + non-probe fallbacks).
|
||||||
let models: ProviderModel[] = [];
|
let models: ProviderModel[] = [];
|
||||||
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
|
||||||
models = llamaModels;
|
models = llamaModels;
|
||||||
} else if (agentRow?.models?.length) {
|
} else if (agentRow?.models?.length) {
|
||||||
models = agentRow.models;
|
models = agentRow.models;
|
||||||
} else if (provider.staticModels) {
|
} else if (resolved.staticModels) {
|
||||||
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.name === 'claude') {
|
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||||
models = attachClaudeThinking(models);
|
if (name === 'claude') {
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
label: agentRow?.label ?? provider.label,
|
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
||||||
transport,
|
commands: manifestCommands,
|
||||||
status: 'ready',
|
|
||||||
installed: true,
|
|
||||||
models,
|
|
||||||
modes: fallbackModes,
|
|
||||||
defaultModeId,
|
|
||||||
commands: getManifestCommands(provider.name),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
const canProbeAcp =
|
||||||
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
transport === 'acp' &&
|
||||||
if (probe.models.length > 0) {
|
((agentRow?.install_path != null && agentRow.supports_acp) ||
|
||||||
models = probe.models;
|
(resolved.isCustomAcp && resolved.launchCommand != null));
|
||||||
} else if (provider.modelSource === 'llama-swap') {
|
|
||||||
models = llamaModels;
|
if (canProbeAcp) {
|
||||||
|
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
|
||||||
|
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
|
||||||
|
const lastProbedMs =
|
||||||
|
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
|
||||||
|
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
|
||||||
|
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
|
||||||
|
const runTier2 = force || stale || dbEmpty;
|
||||||
|
|
||||||
|
if (!runTier2) {
|
||||||
|
let skipModels = agentRow?.models ?? [];
|
||||||
|
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||||
|
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
|
||||||
|
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
|
||||||
|
skipModels = llamaModels;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
|
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.name === 'qwen') {
|
const probeTarget =
|
||||||
const settingsModels = await readQwenSettingsModels();
|
resolved.isCustomAcp && resolved.launchCommand
|
||||||
models = mergeModels(models, settingsModels);
|
? resolved.launchCommand[0]
|
||||||
}
|
: agentRow!.install_path!;
|
||||||
|
const probe = await probeAcpProvider(name, probeTarget, cwd);
|
||||||
|
|
||||||
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
let probeModels = probe.models.length > 0 ? probe.models : models;
|
||||||
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
if (name === 'qwen') {
|
||||||
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
|
||||||
|
}
|
||||||
|
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||||
|
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
|
||||||
|
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, transport,
|
||||||
label: agentRow.label ?? provider.label,
|
|
||||||
transport,
|
|
||||||
status: probe.ok ? 'ready' : 'error',
|
status: probe.ok ? 'ready' : 'error',
|
||||||
installed: true,
|
enabled: true, installed: true,
|
||||||
models,
|
models: withConfigModels(probeModels),
|
||||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||||
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
commands: mergeCommands(manifestCommands, probe.commands),
|
||||||
error: probe.error,
|
...(probe.error ? { error: probe.error } : {}),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// PTY-only providers (qwen fallback when ACP unavailable)
|
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
|
||||||
if (provider.name === 'qwen') {
|
if (name === 'qwen' && models.length === 0) {
|
||||||
if (models.length === 0) {
|
models = await readQwenSettingsModels();
|
||||||
models = await readQwenSettingsModels();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
label: agentRow?.label ?? provider.label,
|
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||||
transport,
|
|
||||||
status: 'ready',
|
|
||||||
installed: true,
|
|
||||||
models,
|
|
||||||
modes: fallbackModes,
|
|
||||||
defaultModeId,
|
|
||||||
commands: getManifestCommands(provider.name),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,16 +242,16 @@ export async function getProviderSnapshot(
|
|||||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||||
const llamaModels = await fetchLlamaSwapModels(config);
|
const llamaModels = await fetchLlamaSwapModels(config);
|
||||||
const agents = await sql<AgentRow[]>`
|
const agents = await sql<AgentRow[]>`
|
||||||
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents
|
||||||
`;
|
`;
|
||||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||||
|
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||||
|
|
||||||
const built = await Promise.all(
|
const entries = await Promise.all(
|
||||||
PROVIDERS.map((provider) =>
|
[...getResolvedRegistry().values()].map((resolved) =>
|
||||||
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
|
||||||
|
|
||||||
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
||||||
return entries;
|
return entries;
|
||||||
@@ -219,6 +261,13 @@ export async function getProviderSnapshot(
|
|||||||
snapshotInflight.delete(cacheKey);
|
snapshotInflight.delete(cacheKey);
|
||||||
});
|
});
|
||||||
snapshotInflight.set(cacheKey, promise);
|
snapshotInflight.set(cacheKey, promise);
|
||||||
|
|
||||||
|
// Await the build (force or cache-miss) and return terminal entries. The sync
|
||||||
|
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
|
||||||
|
// poll that resolves it: without that poll, a single fetch lands on
|
||||||
|
// installed:false `loading` entries, which AgentComposerBar filters out
|
||||||
|
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
|
||||||
|
// once available_agents.models is warm.
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,24 +23,31 @@ export interface ProviderModel {
|
|||||||
defaultThinkingOptionId?: string;
|
defaultThinkingOptionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
|
||||||
|
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
|
||||||
|
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||||
|
|
||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||||
|
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
|
||||||
export interface ProviderSnapshotEntry {
|
export interface ProviderSnapshotEntry {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
transport: string;
|
transport: string;
|
||||||
status: ProviderSnapshotStatus;
|
status: ProviderSnapshotStatus;
|
||||||
|
enabled: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
models: ProviderModel[];
|
models: ProviderModel[];
|
||||||
modes: ProviderMode[];
|
modes: ProviderMode[];
|
||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
commands: AgentCommand[];
|
commands: AgentCommand[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
|
|||||||
@@ -332,18 +332,32 @@ export const api = {
|
|||||||
request<CoderMessageWire[]>(
|
request<CoderMessageWire[]>(
|
||||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||||
),
|
),
|
||||||
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
|
skillInvoke: (
|
||||||
|
sessionId: string,
|
||||||
|
paneId: string,
|
||||||
|
skillName: string,
|
||||||
|
userMessage: string | null,
|
||||||
|
// v2.5.9: when the active provider is external, the skill runs under that
|
||||||
|
// agent (body injected into a dispatched task) → response carries task_id.
|
||||||
|
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
|
||||||
|
) =>
|
||||||
request<{
|
request<{
|
||||||
user_message_id: string;
|
user_message_id: string;
|
||||||
assistant_message_id: string;
|
assistant_message_id?: string;
|
||||||
synth_assistant_id: string;
|
synth_assistant_id?: string;
|
||||||
tool_message_id: string;
|
tool_message_id?: string;
|
||||||
|
task_id?: string;
|
||||||
|
dispatched?: boolean;
|
||||||
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pane_id: paneId,
|
pane_id: paneId,
|
||||||
skill_name: skillName,
|
skill_name: skillName,
|
||||||
user_message: userMessage,
|
user_message: userMessage,
|
||||||
|
...(config?.provider ? { provider: config.provider } : {}),
|
||||||
|
...(config?.model ? { model: config.model } : {}),
|
||||||
|
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
|
||||||
|
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
// Queue a new-file create from the RightRail browser → BooCoder
|
// Queue a new-file create from the RightRail browser → BooCoder
|
||||||
|
|||||||
@@ -232,19 +232,25 @@ export interface ThinkingOption {
|
|||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
|
||||||
|
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||||
|
|
||||||
|
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
|
||||||
|
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
|
||||||
export interface ProviderSnapshotEntry {
|
export interface ProviderSnapshotEntry {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
transport: string;
|
transport: string;
|
||||||
status: ProviderSnapshotStatus;
|
status: ProviderSnapshotStatus;
|
||||||
|
enabled: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
models: ProviderModel[];
|
models: ProviderModel[];
|
||||||
modes: ProviderMode[];
|
modes: ProviderMode[];
|
||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
commands: AgentCommand[];
|
commands: AgentCommand[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ interface PickerProps {
|
|||||||
options: Array<{ id: string; label: string }>;
|
options: Array<{ id: string; label: string }>;
|
||||||
onPick: (id: string) => void;
|
onPick: (id: string) => void;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
/** Mobile: render icon + chevron only (no value label) to save row width. */
|
||||||
|
iconOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
|
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||||
@@ -129,7 +131,7 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
|||||||
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate max-w-[120px]">{currentLabel}</span>
|
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
||||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||||
@@ -290,6 +292,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
options={modeOptions}
|
options={modeOptions}
|
||||||
onPick={(modeId) => persist({ ...value, modeId })}
|
onPick={(modeId) => persist({ ...value, modeId })}
|
||||||
icon={<Shield className="size-3 shrink-0" />}
|
icon={<Shield className="size-3 shrink-0" />}
|
||||||
|
iconOnly
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Model"
|
label="Model"
|
||||||
@@ -308,22 +311,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
icon={<Brain className="size-3 shrink-0" />}
|
icon={<Brain className="size-3 shrink-0" />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{connected !== undefined && (
|
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||||
<span
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
|
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||||
title={connected ? 'Connected' : 'Disconnected'}
|
{connected !== undefined && (
|
||||||
/>
|
<span
|
||||||
)}
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||||
<button
|
title={connected ? 'Connected' : 'Disconnected'}
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleRefresh()}
|
)}
|
||||||
disabled={refreshing}
|
<button
|
||||||
className={cn('inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40', connected === undefined && 'ml-auto')}
|
type="button"
|
||||||
aria-label="Refresh provider list"
|
onClick={() => void handleRefresh()}
|
||||||
title="Refresh providers"
|
disabled={refreshing}
|
||||||
>
|
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
aria-label="Refresh provider list"
|
||||||
</button>
|
title="Refresh providers"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { DropOverlay } from '@/components/DropOverlay';
|
|||||||
import { AgentPicker } from '@/components/AgentPicker';
|
import { AgentPicker } from '@/components/AgentPicker';
|
||||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||||
import { ContextBar } from '@/components/ContextBar';
|
import { ContextBar } from '@/components/ContextBar';
|
||||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Message } from '@/api/types';
|
import type { Message } from '@/api/types';
|
||||||
@@ -56,6 +56,13 @@ interface Props {
|
|||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
// disables slash-command dispatch (input is sent as literal text).
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||||
|
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
|
||||||
|
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
|
||||||
|
// groups are shown instead of the BooChat skills. Invocation routing still
|
||||||
|
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
|
||||||
|
// through and are sent to the agent as literal text. Omitted → BooChat skills
|
||||||
|
// (flat, unchanged — parity).
|
||||||
|
slashGroups?: SlashCommandGroup[];
|
||||||
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||||
// registers in chatInputsRegistry so the terminal floating menu can list
|
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||||
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||||
@@ -71,7 +78,7 @@ interface Props {
|
|||||||
modelContextLimit?: number | null;
|
modelContextLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -100,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
for (const s of skills) m.set(s.name, true);
|
for (const s of skills) m.set(s.name, true);
|
||||||
return m;
|
return m;
|
||||||
}, [skills]);
|
}, [skills]);
|
||||||
|
// Flat display source for the hint (and the picker's no-groups fallback):
|
||||||
|
// caller-provided groups flattened, else the BooChat skills.
|
||||||
|
const slashItems = useMemo(
|
||||||
|
() =>
|
||||||
|
slashGroups
|
||||||
|
? slashGroups.flatMap((g) => g.items)
|
||||||
|
: skills.map((s) => ({ name: s.name, description: s.description })),
|
||||||
|
[slashGroups, skills],
|
||||||
|
);
|
||||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
@@ -561,8 +577,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{skills.length > 0 && (
|
{slashItems.length > 0 && (
|
||||||
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} />
|
<AgentCommandsHint commands={slashItems} />
|
||||||
)}
|
)}
|
||||||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
||||||
inlines ContextBar in the same row so the bar lives next to the
|
inlines ContextBar in the same row so the bar lives next to the
|
||||||
@@ -661,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
{slashState && (
|
{slashState && (
|
||||||
<SlashCommandPicker
|
<SlashCommandPicker
|
||||||
query={slashState.query}
|
query={slashState.query}
|
||||||
items={skills}
|
items={slashItems}
|
||||||
|
groups={slashGroups}
|
||||||
inputRef={textareaRef}
|
inputRef={textareaRef}
|
||||||
onSelect={handleSlashSelect}
|
onSelect={handleSlashSelect}
|
||||||
onClose={() => setSlashState(null)}
|
onClose={() => setSlashState(null)}
|
||||||
emptyLabel="No skills available"
|
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,6 +8,10 @@ interface Props {
|
|||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
|
// Slash-command (skill) send from the landing page. The parent creates the
|
||||||
|
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
|
||||||
|
// the skill — same transition the text send uses. See useSessionChats.
|
||||||
|
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||||
createChat: () => Promise<{ id: string }>;
|
createChat: () => Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ export function SessionLandingPage({
|
|||||||
agentId,
|
agentId,
|
||||||
onAgentChange,
|
onAgentChange,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSkillInvoke,
|
||||||
createChat,
|
createChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
@@ -45,14 +49,13 @@ export function SessionLandingPage({
|
|||||||
}
|
}
|
||||||
}, [ensureChat, onSend]);
|
}, [ensureChat, onSend]);
|
||||||
|
|
||||||
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
// Route to the parent, which creates the chat, assigns it to the pane (so the
|
||||||
try {
|
// pane transitions to ChatPane and subscribes to the stream), then invokes the
|
||||||
const cid = await ensureChat();
|
// skill — mirroring the text-send transition. Doing the skill invoke locally
|
||||||
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
// (without the pane assignment) left the landing pane stuck/blank.
|
||||||
} catch (err) {
|
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
|
||||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
}
|
}, [onSkillInvoke]);
|
||||||
}, [ensureChat]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
|||||||
@@ -8,9 +8,18 @@ export interface SlashCommandItem {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandGroup {
|
||||||
|
label: string;
|
||||||
|
items: SlashCommandItem[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
query: string;
|
query: string;
|
||||||
items: SlashCommandItem[];
|
items: SlashCommandItem[];
|
||||||
|
// Optional segmented rendering. When provided, items are shown under labeled
|
||||||
|
// group headers (in order). `items` is ignored. BooChat passes only `items`
|
||||||
|
// (flat) so its menu is unchanged — grouping is opt-in.
|
||||||
|
groups?: SlashCommandGroup[];
|
||||||
inputRef: RefObject<HTMLElement | null>;
|
inputRef: RefObject<HTMLElement | null>;
|
||||||
onSelect: (name: string) => void;
|
onSelect: (name: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -28,6 +37,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
|
|||||||
export function SlashCommandPicker({
|
export function SlashCommandPicker({
|
||||||
query,
|
query,
|
||||||
items,
|
items,
|
||||||
|
groups,
|
||||||
inputRef,
|
inputRef,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -35,7 +45,21 @@ export function SlashCommandPicker({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
|
// When grouped, filter each group and drop empties; otherwise the flat list.
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
groups
|
||||||
|
? groups
|
||||||
|
.map((g) => ({ label: g.label, items: filterByPrefix(g.items, query) }))
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
: null,
|
||||||
|
[groups, query],
|
||||||
|
);
|
||||||
|
// Flat list drives keyboard nav + Enter selection across all groups.
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
|
||||||
|
[filteredGroups, items, query],
|
||||||
|
);
|
||||||
|
|
||||||
const [rect, setRect] = useState<DOMRect | null>(
|
const [rect, setRect] = useState<DOMRect | null>(
|
||||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||||
@@ -130,6 +154,36 @@ export function SlashCommandPicker({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [rect, vvTick]);
|
}, [rect, vvTick]);
|
||||||
|
|
||||||
|
const renderItem = (item: SlashCommandItem, i: number) => (
|
||||||
|
<div
|
||||||
|
key={`${i}-${item.name}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightIndex}
|
||||||
|
data-highlighted={i === highlightIndex}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||||
|
i === highlightIndex && 'bg-muted',
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
onClick={() => onSelect(item.name)}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||||
|
{item.description && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground overflow-hidden"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let runningIndex = -1;
|
||||||
const popover = filtered.length === 0 ? (
|
const popover = filtered.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
@@ -146,34 +200,16 @@ export function SlashCommandPicker({
|
|||||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{filtered.map((item, i) => (
|
{filteredGroups
|
||||||
<div
|
? filteredGroups.map((g) => (
|
||||||
key={item.name}
|
<div key={g.label}>
|
||||||
role="option"
|
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||||
aria-selected={i === highlightIndex}
|
{g.label}
|
||||||
data-highlighted={i === highlightIndex}
|
</div>
|
||||||
className={cn(
|
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
||||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
|
||||||
i === highlightIndex && 'bg-muted',
|
|
||||||
)}
|
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
|
||||||
onClick={() => onSelect(item.name)}
|
|
||||||
>
|
|
||||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
|
||||||
{item.description && (
|
|
||||||
<div
|
|
||||||
className="text-xs text-muted-foreground overflow-hidden"
|
|
||||||
style={{
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.description}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))
|
||||||
</div>
|
: filtered.map((item, i) => renderItem(item, i))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function Workspace({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
renameChat,
|
renameChat,
|
||||||
handleLandingSend,
|
handleLandingSend,
|
||||||
|
handleLandingSkill,
|
||||||
} = chatsHook;
|
} = chatsHook;
|
||||||
|
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
@@ -387,6 +388,7 @@ export function Workspace({
|
|||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -510,6 +510,31 @@ export function CoderPane({
|
|||||||
[displayedCommands],
|
[displayedCommands],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v2.5.9: segmented slash menu — the active agent's commands first, then
|
||||||
|
// BooCoder skills. boocode has no separate "commands" group (it IS native),
|
||||||
|
// so it shows only Skills. Empty groups are dropped.
|
||||||
|
const agentCommands = useMemo(
|
||||||
|
() =>
|
||||||
|
agentConfig.provider === 'boocode'
|
||||||
|
? []
|
||||||
|
: mergeCommandsByName(providerCommands, liveTaskCommands),
|
||||||
|
[agentConfig.provider, providerCommands, liveTaskCommands],
|
||||||
|
);
|
||||||
|
const skillItems = useMemo(
|
||||||
|
() => skills.map((s) => ({ name: s.name, description: s.description })),
|
||||||
|
[skills],
|
||||||
|
);
|
||||||
|
const slashGroups = useMemo(() => {
|
||||||
|
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = [];
|
||||||
|
if (agentCommands.length > 0) {
|
||||||
|
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands });
|
||||||
|
}
|
||||||
|
if (skillItems.length > 0) {
|
||||||
|
groups.push({ label: 'Skills', items: skillItems });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [agentCommands, skillItems, agentConfig.provider]);
|
||||||
|
|
||||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
onPermissionRequested: (prompt) => {
|
onPermissionRequested: (prompt) => {
|
||||||
@@ -736,19 +761,35 @@ export function CoderPane({
|
|||||||
|
|
||||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
|
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
||||||
setSending(true);
|
// through to a literal send in ChatInput. Skills run under the active
|
||||||
setPermissionPrompt(null);
|
// provider: boocode → native inference; external → body injected into a task.
|
||||||
setLiveTaskCommands([]);
|
if (!skillsByName.has(skillName)) return;
|
||||||
try {
|
setSending(true);
|
||||||
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
|
setPermissionPrompt(null);
|
||||||
} catch (err) {
|
setLiveTaskCommands([]);
|
||||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
try {
|
||||||
} finally {
|
const data = await api.coder.skillInvoke(
|
||||||
setSending(false);
|
sessionId,
|
||||||
}
|
paneId,
|
||||||
|
skillName,
|
||||||
|
userMessage.length > 0 ? userMessage : null,
|
||||||
|
agentConfig.provider !== 'boocode'
|
||||||
|
? {
|
||||||
|
provider: agentConfig.provider,
|
||||||
|
model: agentConfig.model || undefined,
|
||||||
|
mode_id: agentConfig.modeId ?? undefined,
|
||||||
|
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
if (data.task_id) setActiveTaskId(data.task_id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
|
}, [chatId, sessionId, paneId, agentConfig, skillsByName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<div className="flex flex-col h-full bg-background">
|
||||||
@@ -810,6 +851,7 @@ export function CoderPane({
|
|||||||
projectId={projectPath ?? ''}
|
projectId={projectPath ?? ''}
|
||||||
onSend={handleChatInputSend}
|
onSend={handleChatInputSend}
|
||||||
onSlashCommand={handleChatInputSlash}
|
onSlashCommand={handleChatInputSlash}
|
||||||
|
slashGroups={slashGroups}
|
||||||
chatId={chatId ?? undefined}
|
chatId={chatId ?? undefined}
|
||||||
chatLabel="BooCode"
|
chatLabel="BooCode"
|
||||||
messages={messages as unknown as import('@/api/types').Message[]}
|
messages={messages as unknown as import('@/api/types').Message[]}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
|
|||||||
deleteChat: (chatId: string) => Promise<void>;
|
deleteChat: (chatId: string) => Promise<void>;
|
||||||
renameChat: (chatId: string, name: string) => Promise<void>;
|
renameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
||||||
|
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionChats(
|
export function useSessionChats(
|
||||||
@@ -166,6 +167,25 @@ export function useSessionChats(
|
|||||||
}
|
}
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// Slash-command equivalent of handleLandingSend: the initial (landing) chat
|
||||||
|
// must create the chat AND assign it to the pane (openChatInPane) before
|
||||||
|
// invoking the skill, so the pane transitions to ChatPane and subscribes to
|
||||||
|
// the chat's stream. Skipping the assignment left the pane stuck on the
|
||||||
|
// landing page while the skill ran invisibly (and could blank the pane).
|
||||||
|
const handleLandingSkill = useCallback(
|
||||||
|
async (paneIdx: number, skillName: string, userMessage: string | null) => {
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.create(sessionId);
|
||||||
|
setChats((prev) => (prev.some((c) => c.id === chat.id) ? prev : [chat, ...prev]));
|
||||||
|
openChatInPaneRef.current(paneIdx, chat.id);
|
||||||
|
await api.chats.skillInvoke(chat.id, skillName, userMessage);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chats,
|
chats,
|
||||||
setChats,
|
setChats,
|
||||||
@@ -175,5 +195,6 @@ export function useSessionChats(
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
renameChat,
|
renameChat,
|
||||||
handleLandingSend,
|
handleLandingSend,
|
||||||
|
handleLandingSkill,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
3
data/coder-providers.json
Normal file
3
data/coder-providers.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"providers": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user