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>
198 lines
9.5 KiB
JavaScript
198 lines
9.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* VS Code extension reachability test — ADR-1239 Phase D / #1942, upgraded #2103.
|
|
*
|
|
* Proves the VS Code extension is keystone-WIRED: the gsd.invoke handler
|
|
* dispatches through gsd-tools.cjs (subprocess-reuse — the shared
|
|
* `dispatchGsdCommand` in gsd-core/bin/lib/shell-command-projection.cjs) and
|
|
* returns REAL output, not just a registration on a stub. The handler is
|
|
* exported separately from activate() so it is testable WITHOUT a VS Code host.
|
|
*
|
|
* The original cut called `createHub()` with NO args — no hub factory in the
|
|
* tree fully populates a hub, so every dispatch silently answered
|
|
* UnknownCommand. The prior version of this test only asserted the result was
|
|
* "a JSON object" — a VACUOUS assertion that passed whether or not dispatch
|
|
* actually worked. Dispatch is now exercised with a real read-only
|
|
* family/subcommand (progress/json) against a real temp project, matching the
|
|
* sibling tests/pi-extension-reachability.test.cjs pattern — no fake
|
|
* dispatcher injected, because the whole point of "reachability" is that the
|
|
* real engine is reached.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const Module = require('node:module');
|
|
const path = require('node:path');
|
|
|
|
const extension = require('../vscode/extension.js');
|
|
const { activate, dispatchGsdCommand, resolveEngineRoot, resolveWorkspaceCwd } = extension;
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const shellCommandProjection = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
|
|
|
test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot', () => {
|
|
assert.equal(typeof activate, 'function');
|
|
assert.equal(typeof dispatchGsdCommand, 'function');
|
|
assert.equal(typeof resolveEngineRoot, 'function');
|
|
});
|
|
|
|
test('REACHABILITY: dispatchGsdCommand dispatches a real family/subcommand through gsd-tools.cjs and returns REAL output (keystone wired, not UnknownCommand)', async () => {
|
|
const dir = createTempDir();
|
|
try {
|
|
const result = await dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: dir });
|
|
assert.equal(typeof result, 'string', 'returns a string result');
|
|
const parsed = JSON.parse(result);
|
|
assert.ok(parsed !== null && typeof parsed === 'object', 'dispatch produced a result object');
|
|
assert.equal(parsed.ok, true, `expected ok:true (real dispatch), got: ${result}`);
|
|
assert.equal(typeof parsed.stdout, 'string');
|
|
assert.ok(parsed.stdout.length > 0, 'stdout must be non-empty');
|
|
const inner = JSON.parse(parsed.stdout);
|
|
assert.equal(typeof inner.percent, 'number',
|
|
'the real progress command ran (proves the engine was reached — not UnknownCommand)');
|
|
assert.equal(parsed.code, 0);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('REACHABILITY: an unknown family surfaces ok:false without throwing (not a silent UnknownCommand success)', async () => {
|
|
const dir = createTempDir();
|
|
try {
|
|
const result = await dispatchGsdCommand({ family: 'no-such-family-8675309', cwd: dir });
|
|
const parsed = JSON.parse(result);
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.stderr, /no-such-family-8675309|Unknown command/);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('dispatchGsdCommand works with default args (no args → gsd-tools.cjs --help, a real working default)', async () => {
|
|
const result = await dispatchGsdCommand();
|
|
assert.equal(typeof result, 'string');
|
|
const parsed = JSON.parse(result); // must be valid JSON
|
|
assert.equal(parsed.ok, true, `expected the --help default to be ok:true, got: ${result}`);
|
|
assert.match(parsed.stdout, /Usage: gsd-tools/, 'the --help default produced real usage output');
|
|
});
|
|
|
|
test('resolveEngineRoot finds the gsd-core/ dir from the extension location', () => {
|
|
const root = resolveEngineRoot(__dirname + '/../vscode');
|
|
const fs = require('fs');
|
|
assert.ok(fs.existsSync(require('path').join(root, 'gsd-core')),
|
|
'resolveEngineRoot finds a dir containing gsd-core/');
|
|
});
|
|
|
|
test('the extension manifest declares the gsd.invoke command', () => {
|
|
const pkg = require('../vscode/package.json');
|
|
assert.ok(pkg.contributes && pkg.contributes.commands, 'manifest has commands');
|
|
const cmd = pkg.contributes.commands.find((c) => c.command === 'gsd.invoke');
|
|
assert.ok(cmd, 'manifest declares gsd.invoke');
|
|
assert.ok(pkg.engines && pkg.engines.vscode, 'manifest declares VS Code engine');
|
|
assert.equal(pkg.main, './extension.js', 'manifest main points to extension.js');
|
|
});
|
|
|
|
// ── #2103 FIX (adversarial review, MAJOR): all three desktop dispatch
|
|
// surfaces previously called dispatchGsdCommand WITHOUT a cwd, silently
|
|
// defaulting to the extension host's own process.cwd() instead of the user's
|
|
// project — meaning GSD ran against the wrong directory. resolveWorkspaceCwd
|
|
// resolves vscode.workspace.workspaceFolders[0].uri.fsPath, computed fresh
|
|
// per-invocation (never cached at activate() time). ─────────────────────────
|
|
|
|
test('resolveWorkspaceCwd resolves workspaceFolders[0].uri.fsPath when a workspace is open', () => {
|
|
const mockVscode = { workspace: { workspaceFolders: [{ uri: { fsPath: '/tmp/some-workspace' } }] } };
|
|
assert.equal(resolveWorkspaceCwd(mockVscode), '/tmp/some-workspace');
|
|
});
|
|
|
|
test('resolveWorkspaceCwd falls back to process.cwd() when no workspace folder is open (fail-soft, never throws)', () => {
|
|
assert.doesNotThrow(() => {
|
|
assert.equal(resolveWorkspaceCwd({ workspace: { workspaceFolders: [] } }), process.cwd());
|
|
assert.equal(resolveWorkspaceCwd({ workspace: {} }), process.cwd());
|
|
assert.equal(resolveWorkspaceCwd({}), process.cwd());
|
|
});
|
|
});
|
|
|
|
test('#2103 FIX: all three desktop dispatch surfaces (gsd.invoke command, @gsd chat participant, LM tool invoke) thread the resolved workspace folder as cwd — not process.cwd()', async () => {
|
|
// Spy on the SHARED subprocess-shim dispatchGsdCommand (the one every
|
|
// surface ultimately calls via extension.js's own dispatchGsdCommand) so we
|
|
// observe the actual `cwd` each surface passes, without needing a real
|
|
// gsd-tools.cjs project rooted at the mock workspace path.
|
|
const originalShimDispatch = shellCommandProjection.dispatchGsdCommand;
|
|
const calls = [];
|
|
shellCommandProjection.dispatchGsdCommand = (args) => {
|
|
calls.push(args);
|
|
return originalShimDispatch(args);
|
|
};
|
|
|
|
const registeredCommands = {};
|
|
let chatHandler = null;
|
|
const toolImpls = [];
|
|
const mockVscode = {
|
|
commands: {
|
|
registerCommand(id, handler) { registeredCommands[id] = handler; return { dispose() {} }; },
|
|
},
|
|
chat: {
|
|
createChatParticipant(id, handler) { chatHandler = handler; return { id, dispose() {} }; },
|
|
},
|
|
lm: {
|
|
registerTool(name, impl) { toolImpls.push({ name, impl }); return { dispose() {} }; },
|
|
},
|
|
workspace: {
|
|
workspaceFolders: [{ uri: { fsPath: '/tmp/mock-gsd-workspace' } }],
|
|
getConfiguration: () => ({ get: () => undefined }),
|
|
},
|
|
LanguageModelTextPart: class { constructor(t) { this.text = t; } },
|
|
LanguageModelToolResult: class { constructor(parts) { this.parts = parts; } },
|
|
};
|
|
|
|
const originalLoad = Module._load;
|
|
Module._load = function (request, ...rest) {
|
|
if (request === 'vscode') return mockVscode;
|
|
return originalLoad.call(this, request, ...rest);
|
|
};
|
|
|
|
const extensionPath = path.join(__dirname, '..', 'vscode', 'extension.js');
|
|
const hostBindingPath = path.join(__dirname, '..', 'vscode', 'host-binding.js');
|
|
try {
|
|
delete require.cache[extensionPath];
|
|
delete require.cache[hostBindingPath];
|
|
const freshExtension = require(extensionPath);
|
|
const context = {
|
|
subscriptions: [],
|
|
globalState: { get: () => undefined, update: () => Promise.resolve() },
|
|
};
|
|
freshExtension.activate(context);
|
|
|
|
// Surface 1: gsd.invoke command handler.
|
|
calls.length = 0;
|
|
await registeredCommands['gsd.invoke']({ family: 'progress', subcommand: 'json' });
|
|
assert.equal(calls.length, 1, 'gsd.invoke handler must dispatch exactly once');
|
|
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'gsd.invoke must thread the workspace folder as cwd');
|
|
|
|
// Surface 1b: an explicit args.cwd still takes precedence over the resolved workspace.
|
|
calls.length = 0;
|
|
await registeredCommands['gsd.invoke']({ family: 'progress', subcommand: 'json', cwd: '/explicit/override' });
|
|
assert.equal(calls[0].cwd, '/explicit/override', 'an explicit cwd argument must still override the resolved workspace folder');
|
|
|
|
// Surface 2: @gsd chat participant handler.
|
|
calls.length = 0;
|
|
let markdownOut = '';
|
|
await chatHandler({ prompt: 'progress json' }, {}, { markdown: (t) => { markdownOut += t; } }, {});
|
|
assert.equal(calls.length, 1, 'the chat participant handler must dispatch exactly once');
|
|
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'the chat participant must thread the workspace folder as cwd');
|
|
assert.ok(markdownOut.length > 0);
|
|
|
|
// Surface 3: Language Model Tool invoke().
|
|
calls.length = 0;
|
|
const progressTool = toolImpls.find((t) => t.name === 'gsd_progress');
|
|
assert.ok(progressTool, 'gsd_progress tool must be registered');
|
|
await progressTool.impl.invoke({ input: {} }, {});
|
|
assert.equal(calls.length, 1, 'the LM tool invoke() must dispatch exactly once');
|
|
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'the LM tool invoke() must thread the workspace folder as cwd');
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
shellCommandProjection.dispatchGsdCommand = originalShimDispatch;
|
|
delete require.cache[extensionPath];
|
|
delete require.cache[hostBindingPath];
|
|
}
|
|
});
|