Files
msd-core/tests/vscode-lm-tools.test.cjs
Tom Boucher a0fafedfa0 feat(#2103): drive VS Code through the Embeddable Orchestration System (ADR-1239)
VS Code is a net-new EoS runtime that — unlike every prior migration — is NOT
CLI-installed (Marketplace/VSIX extension). It has zero runtime==='vscode'
branches in bin/install.js and stays that way (regression-guarded); it is driven
entirely through the negotiated imperative Host-Integration adapter.

Registry + validator (the hard part):
- capabilities/vscode/capability.json (role:runtime): full hostIntegration block
  (imperative / palette / active vscode.lm model / engine hook bus /
  sandboxed-storage / mcp transport / sandboxed-web runtime; dispatch nested,
  maxDepth 5 per VS Code's documented subagent depth).
- capability-validator.cjs extended so a role:runtime capability can legitimately
  declare "extension-distributed, no config directory": new configHome.kind:'none'
  + installSurface:'none' (+ GATE-A pairing + the parity maps), with localConfigDir
  and configHome.name made conditional on kind!=='none'. All 18 runtimes still
  validate; getDirName returns a distinct sentinel (not '.claude') for a no-config
  runtime.
- The add-a-registry-runtime tax: NON_INSTALLABLE_RUNTIMES exemption in the
  runtime-flags drift guard, vscode added to global-config-home SPECIAL_CASED,
  EXPECTED_PROFILES.vscode='ide', and the config-adapter/derivation/pin-count
  guards updated. No golden-install fixture, model-catalog, or CONFIGURATION rows
  (vscode never enters allRuntimes).

Dispatch + extension surface:
- Fixed vscode/extension.js's createHub()-no-args bug (every dispatch was
  UnknownCommand, masked by a vacuous reachability test) — now reuses the shared
  dispatchGsdCommand subprocess-shim (Node/desktop); the reachability test is
  tightened to assert real dispatch.
- Promoted the #1933 host binding to a shipped vscode/host-binding.js; activate()
  now composes the model/hookBus/stateIO seams through it. Corrected the model
  seam to VS Code's real API (vscode.lm.selectChatModels() -> model.sendRequest();
  vscode.lm.sendRequest does not exist) so the binding actually composes on real
  desktop VS Code instead of throwing.
- New vscode/browser.js Web Extension entry with ZERO Node APIs (the engine's
  config/capability loading is Node-bound, so the web entry registers the surface
  and directs full dispatch to the native MCP server — honestly documented).
- UPGRADE 1: GSD skills as native Language Model Tools (contributes.languageModelTools
  + vscode.lm.registerTool), invoke() dispatching through the hub.
- UPGRADE 2: native subagent dispatch wired onto #runSubagent /
  chat.subagents.allowInvocationsFromSubagents (fail-soft on API availability,
  maxDepth 5 enforced).
- vscode/package.json: browser entry, engines.vscode ^1.105, chatParticipants +
  languageModelTools contributions; fixed a stale activationPoints->activationEvents
  manifest key. Added "vscode" to the package files array.

Docs (## vscode matrix section) + changeset (Added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:24:42 -04:00

156 lines
7.1 KiB
JavaScript

'use strict';
/**
* VS Code Language Model Tools test — #2103 UPGRADE 1.
*
* Proves the GSD extension's Language Model Tools are keystone-WIRED:
* 1. contributes.languageModelTools is present in package.json and its
* `name` entries match the runtime registration names (extension.js's
* LM_TOOLS / browser.js's LM_TOOL_NAMES) exactly — a mismatch here would
* mean VS Code rejects the tool registration at activation.
* 2. registerLanguageModelTools() calls vscode.lm.registerTool for every
* manifest entry (mock vscode.lm — no real VS Code host available in CI).
* 3. A registered tool's invoke() dispatches through the SAME shared
* dispatchGsdCommand as gsd.invoke (desktop) and returns REAL output —
* the "user can invoke X" proof, matching the reachability-test pattern.
* 4. The desktop and web entries register the identical tool NAME set (only
* the invoke() behavior differs — real dispatch vs. honest web-mode message).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const pkg = require('../vscode/package.json');
const extension = require('../vscode/extension.js');
const browser = require('../vscode/browser.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
class FakeTextPart {
constructor(text) { this.text = text; }
}
class FakeToolResult {
constructor(parts) { this.parts = parts; }
}
function mockVscodeLm() {
const registered = [];
return {
lm: {
registerTool(name, impl) {
registered.push({ name, impl });
return { dispose() {} };
},
},
LanguageModelTextPart: FakeTextPart,
LanguageModelToolResult: FakeToolResult,
registered,
};
}
test('package.json contributes.languageModelTools is present with well-formed entries', () => {
assert.ok(pkg.contributes && Array.isArray(pkg.contributes.languageModelTools),
'contributes.languageModelTools must be an array');
assert.ok(pkg.contributes.languageModelTools.length > 0, 'must declare at least one tool');
for (const tool of pkg.contributes.languageModelTools) {
assert.equal(typeof tool.name, 'string');
assert.ok(tool.name.length > 0);
assert.equal(typeof tool.toolReferenceName, 'string');
assert.equal(typeof tool.displayName, 'string');
assert.equal(typeof tool.modelDescription, 'string');
assert.equal(typeof tool.userDescription, 'string');
assert.equal(tool.canBeReferencedInPrompt, true);
assert.ok(Array.isArray(tool.tags));
assert.equal(typeof tool.inputSchema, 'object');
}
});
test('manifest tool names exactly match extension.js LM_TOOLS registration names', () => {
const manifestNames = pkg.contributes.languageModelTools.map((t) => t.name).sort();
const runtimeNames = extension.LM_TOOLS.map((t) => t.name).sort();
assert.deepEqual(runtimeNames, manifestNames,
'a mismatch here means VS Code would reject the runtime registerTool call against the manifest');
});
test('manifest tool names exactly match browser.js LM_TOOL_NAMES (web entry registers the same surface)', () => {
const manifestNames = pkg.contributes.languageModelTools.map((t) => t.name).sort();
assert.deepEqual([...browser.LM_TOOL_NAMES].sort(), manifestNames);
});
test('REACHABILITY (desktop): registerLanguageModelTools registers every manifest tool via vscode.lm.registerTool', () => {
const mock = mockVscodeLm();
const context = { subscriptions: [] };
const count = extension.registerLanguageModelTools(mock, context);
assert.equal(count, pkg.contributes.languageModelTools.length);
assert.equal(mock.registered.length, pkg.contributes.languageModelTools.length);
assert.equal(context.subscriptions.length, pkg.contributes.languageModelTools.length);
assert.deepEqual(mock.registered.map((r) => r.name).sort(), extension.LM_TOOLS.map((t) => t.name).sort());
});
test('REACHABILITY (desktop): gsd_progress tool.invoke() dispatches through the hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
assert.ok(progressTool, 'gsd_progress must be registered');
const result = await progressTool.impl.invoke({ input: {}, cwd: dir }, {});
assert.ok(result instanceof FakeToolResult, 'invoke must return a LanguageModelToolResult');
assert.ok(Array.isArray(result.parts) && result.parts.length === 1);
assert.ok(result.parts[0] instanceof FakeTextPart, 'result part must be a LanguageModelTextPart');
const parsed = JSON.parse(result.parts[0].text);
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (engine reached, not a stub)');
} finally {
cleanup(dir);
}
});
test('REACHABILITY (desktop): gsd_plan_phase tool.invoke() forwards the "phase" input through dispatch (real, not UnknownCommand)', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const planPhaseTool = mock.registered.find((r) => r.name === 'gsd_plan_phase');
assert.ok(planPhaseTool);
const result = await planPhaseTool.impl.invoke({ input: { phase: 'nonexistent-phase-8675309' }, cwd: dir }, {});
const parsed = JSON.parse(result.parts[0].text);
// Real dispatch reaches gsd-tools.cjs and returns a structured "phase not
// found" response (proves the engine was reached) — not the manifest's
// own family/subcommand rejected as unknown.
assert.equal(parsed.phase, 'nonexistent-phase-8675309');
assert.ok('error' in parsed || 'plans' in parsed, 'expected a real phase-plan-index response shape');
} finally {
cleanup(dir);
}
});
test('gsd_workstreams tool.invoke() dispatches through the hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const wsTool = mock.registered.find((r) => r.name === 'gsd_workstreams');
const result = await wsTool.impl.invoke({ input: {}, cwd: dir }, {});
const parsed = JSON.parse(result.parts[0].text);
assert.ok('workstreams' in parsed || 'mode' in parsed, 'expected a real workstream list response shape');
} finally {
cleanup(dir);
}
});
test('registerLanguageModelTools fails soft (returns 0, does not throw) when vscode.lm is absent', () => {
assert.doesNotThrow(() => {
const count = extension.registerLanguageModelTools({}, { subscriptions: [] });
assert.equal(count, 0);
});
});
test('WEB MODE: browser.js registerLanguageModelTools registers the same names but invoke() returns an honest web-mode message (no engine dispatch)', async () => {
const mock = mockVscodeLm();
const count = browser.registerLanguageModelTools(mock, { subscriptions: [] });
assert.equal(count, browser.LM_TOOL_NAMES.length);
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
const result = await progressTool.impl.invoke({ input: {} }, {});
assert.match(result.parts[0].text, /web mode/i);
assert.match(result.parts[0].text, /MCP server/);
});