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>
156 lines
7.1 KiB
JavaScript
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/);
|
|
});
|