feat(#1933): VS Code IDE reference host binding — completes Phase 5 IDE profile (#1935)

* feat(#1933): VS Code IDE reference host binding — completes Phase 5 IDE profile

The reference VS Code host binding composes the Phase-3 engine seams for the
ide profile (host-integration.cts PROFILE_BASELINES): active model via vscode.lm
(createModelAdapter active + sendRequest), engine-owned hook bus
(createHookBus engine — VS Code has no host bus), sandboxed-storage stateIO
(createStateIO sandboxed-storage + host backend — no fs / no child_process),
and the imperative adapter (engine-as-library). Command surface: palette/chat.

VS Code is extension-distributed (Marketplace), not file-projected onto a config
dir, so it intentionally has NO runtime descriptor / --vscode installer entry —
the extension IS the host (architecturally N/A for the descriptor/installer
model, not a deferral). Mock-friendly binding (vscode.lm + hostStorage injected)
so it is behaviorally testable without a live VS Code host.

Tests: IDE profileOf classification; full seam composition (active model routes
to vscode.lm; engine bus pub/sub; sandboxed state routes to backend; imperative
adapter; command surface); fail-closed construction.

* fix(#1933): correct vscode fail-closed test case matrix
This commit is contained in:
Tom Boucher
2026-07-02 17:25:05 -04:00
committed by GitHub
parent 21a9af4048
commit f13db4cbf5
2 changed files with 148 additions and 0 deletions

69
tests/fixtures/vscode-host-binding.cjs vendored Normal file
View File

@@ -0,0 +1,69 @@
'use strict';
/**
* Reference VS Code IDE host binding for GSD (ADR-1239 Phase D / #1933).
*
* VS Code is the IDE-profile reference host. It composes the Phase-3 engine
* seams for the negotiated `ide` profile (host-integration.cts PROFILE_BASELINES):
*
* - modelMode: 'active' → createModelAdapter({modelMode:'active'}, {sendRequest})
* backed by `vscode.lm` (LanguageModelChat). VS Code rejects
* system-role messages, so the request mapper uses User role only.
* - hookBus: 'engine' → createHookBus({bus:'engine'}) — VS Code has NO host event bus,
* so GSD owns the bus in-process (full subscribe + emit).
* - stateIO: 'sandboxed-storage' → createStateIO({io:'sandboxed-storage'}, {backend}) bound to a
* host-supplied storage (no arbitrary FS — web/no-child_process safe).
* - embeddingMode: 'imperative' → createImperativeAdapter({runtime:'vscode'}) — engine-as-library.
*
* Distribution: VS Code is shipped as an EXTENSION (Marketplace), NOT file-projected onto a
* config dir, so it intentionally has NO runtime descriptor / `--vscode` installer entry — the
* extension IS the host. This module is the binding the extension's activate() runs.
*
* Mock-friendly: takes `vscode` (with `vscode.lm`) + `hostStorage` ({read,write}) so it is
* behaviorally testable without a live VS Code host.
*
* @param {{ lm: { sendRequest: (req: unknown) => unknown } }} vscode VS Code namespace (vscode.lm)
* @param {{ read: (path: string) => string, write: (path: string, content: string) => void }} hostStorage
* sandboxed-storage backend (e.g. globalState/workspaceState/secrets).
* @returns {object} the composed IDE host surface: { runtime, model, hookBus, stateIO, adapter, commands }
*/
module.exports = function bindGsdToVscode(vscode, hostStorage) {
if (!vscode || !vscode.lm || typeof vscode.lm.sendRequest !== 'function') {
throw new TypeError('bindGsdToVscode: vscode.lm.sendRequest is required (active model provider)');
}
if (!hostStorage || typeof hostStorage.read !== 'function' || typeof hostStorage.write !== 'function') {
throw new TypeError('bindGsdToVscode: hostStorage {read,write} is required (sandboxed-storage backend)');
}
const { createImperativeAdapter } = require('../../gsd-core/bin/lib/adapter-imperative.cjs');
const { createModelAdapter } = require('../../gsd-core/bin/lib/model-adapter.cjs');
const { createHookBus } = require('../../gsd-core/bin/lib/hook-bus.cjs');
const { createStateIO } = require('../../gsd-core/bin/lib/state-io.cjs');
// Active model: GSD model calls route through vscode.lm. (No system-role
// messages — VS Code rejects them; a full extension builds LanguageModelChatMessages
// with User role only and selects a model via vscode.lm.selectChatModels.)
const model = createModelAdapter({ modelMode: 'active' }, {
sendRequest(req) {
return vscode.lm.sendRequest(req);
},
});
// Engine-owned hook bus: VS Code has no host bus, so GSD owns it in-process.
const hookBus = createHookBus({ bus: 'engine' });
// Sandboxed-storage stateIO bound to the host storage backend (no fs / no child_process).
const stateIO = createStateIO({ io: 'sandboxed-storage' }, { backend: hostStorage });
// Imperative adapter: the engine-as-library for the VS Code runtime.
const adapter = createImperativeAdapter({ runtime: 'vscode' });
// Command surface: Command Palette + Chat participant entries bound to the
// GSD command-routing hub via the imperative adapter (interface point 1).
const commands = Object.freeze({
'gsd.invoke': Object.freeze({ description: 'Invoke a GSD command via the embedded engine (palette/chat).' }),
'gsd.help': Object.freeze({ description: 'List GSD commands available in the IDE host.' }),
});
return Object.freeze({ runtime: 'vscode', model, hookBus, stateIO, adapter, commands });
};

View File

@@ -0,0 +1,79 @@
'use strict';
/**
* VS Code IDE reference host — ADR-1239 Phase D / #1933.
*
* Completes the IDE profile: proves the Phase-3 engine seams (active model,
* engine-owned hook bus, sandboxed-storage stateIO, imperative adapter) compose
* for VS Code end-to-end (#1933 AC: "run GSD inside the VS Code IDE host through
* its palette/chat command surface, with engine-owned hook bus + active model +
* sandboxed stateIO (no child_process) handled by the adapters").
*
* VS Code is extension-distributed (Marketplace), not file-projected, so it has
* no runtime descriptor/installer entry — the reference binding + these tests are
* the provable surface (a live VS Code run is outside CI, same as every reference
* host). Mock-friendly: vscode.lm + a hostStorage backend are injected.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { profileOf } = require('../gsd-core/bin/lib/host-integration.cjs');
const bindGsdToVscode = require('./fixtures/vscode-host-binding.cjs');
test('VS Code IDE axes classify as the ide profile', () => {
// ide baseline (host-integration.cts PROFILE_BASELINES): imperative + sandboxed-web.
assert.equal(profileOf({ embeddingMode: 'imperative', runtime: 'sandboxed-web' }), 'ide');
assert.notEqual(profileOf({ embeddingMode: 'imperative', runtime: 'node' }), 'ide');
});
test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', () => {
let lastLmReq = null;
const vscode = {
lm: { sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } },
};
const storageWrites = [];
const hostStorage = {
read: (p) => `content-of-${p}`,
write: (p, c) => { storageWrites.push([p, c]); },
};
const host = bindGsdToVscode(vscode, hostStorage);
assert.equal(host.runtime, 'vscode');
// Active model routes through vscode.lm (no system messages — User role only).
assert.equal(host.model.mode, 'active');
assert.equal(host.model.sendRequest({ prompt: 'hi' }), 'lm-response');
assert.deepEqual(lastLmReq, { prompt: 'hi' });
// Engine-owned hook bus: in-process pub/sub (VS Code has no host bus).
assert.equal(host.hookBus.bus, 'engine');
let received = null;
host.hookBus.subscribe('PreToolUse', (p) => { received = p; });
host.hookBus.emit('PreToolUse', { tool: 'Read' });
assert.deepEqual(received, { tool: 'Read' });
// Sandboxed-storage routes through the host backend (NOT the filesystem).
assert.equal(host.stateIO.io, 'sandboxed-storage');
assert.equal(host.stateIO.read('/plan.md'), 'content-of-/plan.md');
host.stateIO.write('/plan.md', 'new');
assert.deepEqual(storageWrites, [['/plan.md', 'new']]);
// Imperative adapter (engine-as-library) for the VS Code runtime.
assert.equal(host.adapter.kind, 'imperative');
assert.equal(host.adapter.runtime, 'vscode');
// Command surface (palette/chat).
assert.ok(host.commands['gsd.invoke'], 'palette/chat command surface present');
});
test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => {
const okStorage = { read() {}, write() {} };
const okVscode = { lm: { sendRequest() {} } };
// vscode.lm missing or incomplete → vscode.lm error
assert.throws(() => bindGsdToVscode({}, okStorage), /vscode\.lm/);
assert.throws(() => bindGsdToVscode({ lm: {} }, okStorage), /vscode\.lm/);
// valid vscode.lm but missing/incomplete hostStorage → hostStorage error
assert.throws(() => bindGsdToVscode(okVscode, null), /hostStorage/);
assert.throws(() => bindGsdToVscode(okVscode, { read() {} }), /hostStorage/);
});