diff --git a/tests/fixtures/vscode-host-binding.cjs b/tests/fixtures/vscode-host-binding.cjs new file mode 100644 index 000000000..ad1d00851 --- /dev/null +++ b/tests/fixtures/vscode-host-binding.cjs @@ -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 }); +}; diff --git a/tests/vscode-ide-reference.test.cjs b/tests/vscode-ide-reference.test.cjs new file mode 100644 index 000000000..27a77de26 --- /dev/null +++ b/tests/vscode-ide-reference.test.cjs @@ -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/); +});