From a0fafedfa0203d699759357f649274dd8e4ddeff Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Jul 2026 22:35:18 -0400 Subject: [PATCH] feat(#2103): drive VS Code through the Embeddable Orchestration System (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/plucky-wasps-sprint.md | 5 + capabilities/vscode/capability.json | 51 +++ docs/reference/capability-matrix.md | 3 +- .../host-integration-capability-matrix.md | 77 ++++ gsd-core/bin/lib/capability-registry.cjs | 103 +++++ gsd-core/bin/lib/capability-validator.cjs | 53 ++- package.json | 3 +- src/runtime-config-adapter-registry.cts | 21 +- src/runtime-name-policy.cts | 38 +- tests/capability-registry.test.cjs | 85 +++- tests/fixtures/vscode-host-binding.cjs | 71 +--- tests/gemini-runtime-removed.test.cjs | 12 +- tests/getdirname-registry-derivation.test.cjs | 38 +- tests/global-config-home-fragment.test.cjs | 6 +- tests/host-integration-descriptors.test.cjs | 14 +- ...issue-57-runtime-install-no-drift.test.cjs | 30 ++ ...aude-runtimes-registry-derivation.test.cjs | 16 +- .../runtime-config-adapter-registry.test.cjs | 19 +- tests/runtime-flags.test.cjs | 28 +- tests/vscode-browser-no-node-api.test.cjs | 128 +++++++ tests/vscode-extension-reachability.test.cjs | 172 ++++++++- tests/vscode-ide-reference.test.cjs | 112 +++++- tests/vscode-lm-tools.test.cjs | 155 ++++++++ tests/vscode-subagent-dispatch.test.cjs | 148 +++++++ vscode/browser.js | 197 ++++++++++ vscode/extension.js | 362 ++++++++++++++++-- vscode/host-binding.js | 113 ++++++ vscode/package.json | 70 +++- 28 files changed, 1973 insertions(+), 157 deletions(-) create mode 100644 .changeset/plucky-wasps-sprint.md create mode 100644 capabilities/vscode/capability.json create mode 100644 tests/vscode-browser-no-node-api.test.cjs create mode 100644 tests/vscode-lm-tools.test.cjs create mode 100644 tests/vscode-subagent-dispatch.test.cjs create mode 100644 vscode/browser.js create mode 100644 vscode/host-binding.js diff --git a/.changeset/plucky-wasps-sprint.md b/.changeset/plucky-wasps-sprint.md new file mode 100644 index 000000000..c0a10e0cb --- /dev/null +++ b/.changeset/plucky-wasps-sprint.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2210 +--- +**GSD now drives VS Code through the Embeddable Orchestration System** — the VS Code extension is rewired through the negotiated imperative Host-Integration adapter (active `vscode.lm` model, engine hook bus, sandboxed storage), gains native Language Model Tools (GSD skills as `#gsd-*` tools) and `#runSubagent` dispatch, and runs as a Web Extension (no Node APIs). (#2103) diff --git a/capabilities/vscode/capability.json b/capabilities/vscode/capability.json new file mode 100644 index 000000000..aeccd0ee2 --- /dev/null +++ b/capabilities/vscode/capability.json @@ -0,0 +1,51 @@ +{ + "id": "vscode", + "role": "runtime", + "version": "1.7.0-rc.5", + "title": "VS Code", + "description": "VS Code — Marketplace/VSIX extension; no file-projected config directory; IDE-profile reference host (active vscode.lm model, engine-owned hook bus, sandboxed globalState/workspaceState stateIO).", + "tier": "core", + "requires": [], + "engines": { + "gsd": ">=1.7.0" + }, + "runtime": { + "configHome": { + "kind": "none", + "name": "vscode", + "env": [] + }, + "localConfigDir": null, + "configFormat": "none", + "artifactLayout": { + "global": [], + "local": [] + }, + "commandStyle": "slash-hyphen", + "hooksSurface": "none", + "extensionEvents": "none", + "sandboxTier": "none", + "supportTier": 1, + "installSurface": "none", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "palette", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 5, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "active", + "hookBus": "engine", + "stateIO": "sandboxed-storage", + "transport": "mcp", + "runtime": "sandboxed-web" + } + } +} diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index fcee42040..ca245dce4 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -72,7 +72,7 @@ points. | `tdd` | feature | full | `>=1.6.0` | `plan:pre`, `execute:post` | contribution, gate | first-party | | `ui` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post`, `verify:post` | step, gate | first-party | -### Runtime capabilities (role: runtime) — 17 +### Runtime capabilities (role: runtime) — 18 Runtime capabilities adapt GSD to a specific AI runtime or IDE — emitting skills, agents, hooks configuration, and surface files for that host. They @@ -96,6 +96,7 @@ emission), so their extension-point and hook-kind cells are `—`. | `pi` | runtime | core | `>=1.7.0` | — | — | first-party | | `qwen` | runtime | core | `>=1.6.0` | — | — | first-party | | `trae` | runtime | core | `>=1.6.0` | — | — | first-party | +| `vscode` | runtime | core | `>=1.7.0` | — | — | first-party | | `windsurf` | runtime | core | `>=1.6.0` | — | — | first-party | | `zcode` | runtime | core | `>=1.6.0` | — | — | first-party | diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 5d700cf4c..d9052207f 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -691,3 +691,80 @@ EoS migration status (#2102 Stage 2, ADR-1239): Stage 1's "in-process `gsd-core` **Adversarial-review correction (#2102 Stage 2, post-review):** the event bridges above and the `/gsd` tokenizer's `hooks/lib/git-cmd.js` require were DEAD in a real install — Stage 1's `hostBehaviors.skipSharedHooksInstall:true` meant pi shipped NO `hooks/` directory at all, so `runHook('gsd-ensure-canonical-path.js', ...)` etc. always hit the "hook file absent → silent no-op" branch, and the tokenizer always fell back to plain whitespace-splitting. The tests masked this because they run against the dev tree, where `hooks/` genuinely exists. **Fix:** `capabilities/pi/capability.json` no longer sets `skipSharedHooksInstall` — pi is architecturally identical to OpenCode here (`hooksSurface: "none"` + a native extension that spawns the staged hooks), not to Kilo/ZCode (`hooksSurface: "none"` with NO plugin surface, where the same hooks genuinely are dead weight). pi now installs `hooks/` + `hooks/lib/` (27 entries: the same `INSTALLED_HOOK_FILES` set OpenCode gets) alongside `extensions/gsd.cjs`, verified end-to-end via a real `node bin/install.js --pi --global`/`--local` — `resolveEngineRoot`'s walk-up from the installed extension's own directory finds `ENGINE_ROOT/hooks/{gsd-ensure-canonical-path.js,gsd-workflow-guard.js,gsd-context-monitor.js,lib/git-cmd.js}`, and each bridge/`runHook` call exits 0 against the real installed files. `hooksSurface: "none"` + `configFormat: "none"` + `writesSharedSettings: false` are unaffected — no settings/hooks.json/config.toml is written for pi; the extension spawns hooks by absolute path, not via a config-file hook bus. `tests/fixtures/golden-install-parity/pi.json` grew from 292 → 320 entries (the 28 new `hooks/`/`hooks/lib/` files); `commands/`, `agents/`, `skills/` remain absent (`pluginOnlyInstall` is untouched — it only gates the declarative-markdown surfaces, not hooks). `tests/install-minimal-hooks.test.cjs`'s #1821 suite moved pi from the Kilo/ZCode (no-hooks) group into the OpenCode (ships-hooks) group accordingly. +## vscode + +> VS Code is the IDE-profile reference host: a Marketplace/VSIX-distributed extension, NOT +> file-projected onto a config directory — it has no `runtime.localConfigDir` in the usual sense +> (`configHome.kind: "none"`, `localConfigDir: null`) and no CLI install surface at all +> (`installSurface: "none"`; it is never installed by `bin/install.js` — no `--vscode` flag, no +> `allRuntimes` membership; see `capabilities/vscode/capability.json`). The extension IS the host. +> **Sourcing note:** the citations below are the VS Code extension API documentation pages named +> in ADR-1239 (#2103) as the source for each axis; this environment did not have live Context7/ +> web-fetch access at authoring time, so the Evidence column is a paraphrase of VS Code's +> documented extension model rather than a verbatim excerpt — a maintainer with Context7/web +> access should verify the exact wording before treating this section as fully cited (same caveat +> already flagged for the pi section above). + +| Axis | Value | Source | Evidence | +|---|---|---|---| +| embeddingMode | imperative | https://code.visualstudio.com/api/references/vscode-api | The extension is loaded in-process by the extension host and calls the `vscode` namespace API directly (`vscode.commands.registerCommand`, `vscode.chat.createChatParticipant`, `vscode.lm.registerTool`) — an in-process programmatic API, not a config-file-only integration. | +| commandSurface | palette | https://code.visualstudio.com/api/extension-guides/command | Commands are contributed via `contributes.commands` in package.json and registered with `vscode.commands.registerCommand`, surfaced through the Command Palette (and the Chat view via the chat participant) — not a markdown/TOML slash-command file format. | +| modelMode | active | https://code.visualstudio.com/api/extension-guides/ai/language-model | The `vscode.lm` namespace lets an extension actively select a model (`vscode.lm.selectChatModels`) and send requests to it programmatically, rather than only reading a static config value. | +| hookBus | engine | https://code.visualstudio.com/api/references/activation-events | VS Code has no cross-extension lifecycle-hook bus that GSD subscribes to; the extension host (the "engine" here, per this axis's own `host`/`engine`/`none` vocabulary) owns activation events, and GSD's own hook lifecycle runs fully in-process/engine-owned inside the extension. | +| stateIO | sandboxed-storage | https://code.visualstudio.com/api/references/vscode-api#Memento | `context.globalState`/`context.workspaceState` (both `Memento`) are the extension's persistent storage surface — sandboxed key/value storage scoped to the extension, not unrestricted local filesystem access. | +| transport | mcp | https://code.visualstudio.com/api/extension-guides/ai/mcp | VS Code 1.99 added native MCP client support; on the Web (webworker) entry, full GSD command dispatch is available through VS Code's native MCP client connecting to the GSD companion MCP server (`gsd-mcp-server`), not an in-process Node dispatch (which the web entry cannot run at all). | +| runtime | sandboxed-web | https://code.visualstudio.com/api/extension-guides/web-extensions | The `browser` entry point (`vscode/browser.js`) runs in a webworker context with no Node core modules — the Web Extension execution model VS Code documents for extensions that must run in vscode.dev/github.dev. | +| dispatch.namedDispatch | true | https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode#_agent-mode-tools | Registered `languageModelTools` (and the chat participant) are addressable by name — the primary agent references a tool/participant by its declared name/`toolReferenceName`, not only positionally. | +| dispatch.nested | true | https://code.visualstudio.com/docs/copilot/copilot-chat-agents (subagents) | VS Code's chat subagent model (`#runSubagent`) explicitly supports a subagent invoking further subagents, gated by `chat.subagents.allowInvocationsFromSubagents`. | +| dispatch.maxDepth | 5 | https://code.visualstudio.com/docs/copilot/copilot-chat-agents (subagents) | Documented as VS Code's maximum nesting depth for `#runSubagent` chains — also matches this repo's existing `PROFILE_BASELINES.ide.dispatch.maxDepth` baseline. | +| dispatch.background | true | https://code.visualstudio.com/api/extension-guides/ai/tools | Language Model Tools can be invoked as part of an asynchronous agent turn (the primary agent does not block synchronously on a single extension call). | +| dispatch.subagentToolkit | undocumented | no authoritative doc found at authoring time | VS Code's subagent documentation does not state whether a subagent's tool surface is restricted to read-only tools or the full set an extension registers; recorded `undocumented` (fails closed to `read-only` in negotiation) rather than guessed. | +| dispatch.backgroundDispatch | undocumented | no authoritative doc found at authoring time | Whether a background-dispatched subagent can itself spawn further NAMED subagents (the #853 discriminator) is not stated in the sources reviewed; recorded `undocumented` (fails closed to `false`) rather than guessed. | + +Sources consulted: +- https://code.visualstudio.com/api/references/vscode-api +- https://code.visualstudio.com/api/extension-guides/command +- https://code.visualstudio.com/api/extension-guides/ai/language-model +- https://code.visualstudio.com/api/extension-guides/ai/tools +- https://code.visualstudio.com/api/extension-guides/ai/mcp +- https://code.visualstudio.com/api/extension-guides/web-extensions +- https://code.visualstudio.com/api/references/activation-events +- https://code.visualstudio.com/docs/copilot/copilot-chat-agents + +Documentation gaps: +- dispatch.subagentToolkit / dispatch.backgroundDispatch — the reviewed sources document that + `#runSubagent` exists (v1.105+, `chat.subagents.allowInvocationsFromSubagents`, max nesting + depth 5) but do not state the subagent tool-restriction model or whether a background-dispatched + subagent can itself spawn further named subagents; both stay `undocumented` and negotiation + fails closed. +- This section's Evidence-column wording was authored without live Context7/web-fetch access (see + the sourcing note above the table) — verify against the cited pages before relying on it for a + future capability upgrade, same caveat as the pi section above. + +EoS migration status (#2103): vscode lands as a registry runtime (role:runtime) for +validator/host-integration coverage ONLY — it is deliberately NOT a CLI-installable runtime +(`installSurface: "none"`, never in `bin/install.js`'s `allRuntimes`; see the +`NON_INSTALLABLE_RUNTIMES` carve-out in `tests/runtime-flags.test.cjs`). The extension surface +(`vscode/extension.js`, `vscode/browser.js`, `vscode/host-binding.js`, `vscode/package.json`) is +distributed via the Marketplace/VSIX, not `npx --vscode` — there is no `docs/how-to/install-on- +your-runtime.md` entry for it. Dispatch is SUBPROCESS REUSE on desktop (the same shared +`dispatchGsdCommand` in `gsd-core/bin/lib/shell-command-projection.cjs` the pi extension and the +companion MCP server use) via `vscode/extension.js`'s `main` entry (Node). The `browser` entry +(`vscode/browser.js`) is a SEPARATE, independently zero-Node-API file: it does NOT require +`host-binding.js` because that module's engine-lib dependencies (`state-io.cjs`, +`adapter-imperative.cjs` → `install-engine.cjs`/`capability-loader.cjs`, +`model-adapter.cjs` → `model-resolver.cjs` → `config-loader.cjs`/`configuration.cjs`) all pull in +Node's `fs`/`os`/`path` at module-load time — requiring any of them from a webworker context would +throw immediately. `browser.js` instead composes its own minimal surface directly against +`vscode.lm`, and its command/tool/chat handlers surface an honest "full dispatch is unavailable on +web; configure the GSD MCP server" message rather than a silent failure. The chat participant +(`@gsd`) and Language Model Tools (a representative 3-tool set — `gsd_progress`, `gsd_workstreams`, +`gsd_plan_phase` — matching real shipped skills that map onto a single, safe, read-only +`gsd-tools.cjs` command) are registered on BOTH entries identically; only the dispatch behavior +differs. `#runSubagent` wiring (`registerSubagentDispatch`/`dispatchAsSubagent`, gated on +`chat.subagents.allowInvocationsFromSubagents` availability, fail-soft on older/Insiders-gated +hosts) adds a belt-and-suspenders `maxDepth: 5` ceiling independent of whatever VS Code's own chat +engine enforces natively — there is no separate extension-side "subagent contribution" +registration API beyond the chat participant + Language Model Tools already registered; VS Code's +chat engine surfaces them to `#runSubagent` on its own. + diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 368d09bf1..80bc9001a 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -2756,6 +2756,57 @@ const capabilities = { } ] }, + "vscode": { + "id": "vscode", + "role": "runtime", + "version": "1.7.0-rc.5", + "title": "VS Code", + "description": "VS Code — Marketplace/VSIX extension; no file-projected config directory; IDE-profile reference host (active vscode.lm model, engine-owned hook bus, sandboxed globalState/workspaceState stateIO).", + "tier": "core", + "requires": [], + "engines": { + "gsd": ">=1.7.0" + }, + "runtime": { + "configHome": { + "kind": "none", + "name": "vscode", + "env": [] + }, + "localConfigDir": null, + "configFormat": "none", + "artifactLayout": { + "global": [], + "local": [] + }, + "commandStyle": "slash-hyphen", + "hooksSurface": "none", + "extensionEvents": "none", + "sandboxTier": "none", + "supportTier": 1, + "installSurface": "none", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "palette", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 5, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "active", + "hookBus": "engine", + "stateIO": "sandboxed-storage", + "transport": "mcp", + "runtime": "sandboxed-web" + } + } + }, "windsurf": { "id": "windsurf", "role": "runtime", @@ -5290,6 +5341,57 @@ const runtimes = { } } }, + "vscode": { + "id": "vscode", + "role": "runtime", + "version": "1.7.0-rc.5", + "title": "VS Code", + "description": "VS Code — Marketplace/VSIX extension; no file-projected config directory; IDE-profile reference host (active vscode.lm model, engine-owned hook bus, sandboxed globalState/workspaceState stateIO).", + "tier": "core", + "requires": [], + "engines": { + "gsd": ">=1.7.0" + }, + "runtime": { + "configHome": { + "kind": "none", + "name": "vscode", + "env": [] + }, + "localConfigDir": null, + "configFormat": "none", + "artifactLayout": { + "global": [], + "local": [] + }, + "commandStyle": "slash-hyphen", + "hooksSurface": "none", + "extensionEvents": "none", + "sandboxTier": "none", + "supportTier": 1, + "installSurface": "none", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [], + "hostIntegration": { + "embeddingMode": "imperative", + "commandSurface": "palette", + "dispatch": { + "namedDispatch": true, + "nested": true, + "maxDepth": 5, + "background": true, + "subagentToolkit": "undocumented", + "backgroundDispatch": "undocumented" + }, + "modelMode": "active", + "hookBus": "engine", + "stateIO": "sandboxed-storage", + "transport": "mcp", + "runtime": "sandboxed-web" + } + } + }, "windsurf": { "id": "windsurf", "role": "runtime", @@ -5667,6 +5769,7 @@ const _requiresGraph = { "tdd": [], "trae": [], "ui": [], + "vscode": [], "windsurf": [], "zcode": [] }; diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 221e51d78..da4ad5a9b 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -703,7 +703,9 @@ const VALID_CONVERTER_NAMES = new Set([ // C3: Validate role:runtime body const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']); -const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']); +// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with +// no file-projected config directory at all. +const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']); const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']); const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']); const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']); @@ -716,7 +718,9 @@ const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey']; -const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']); +// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that +// are never CLI-installed (no allRuntimes membership, no install flag). +const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', 'none']); // 'antigravity' added #2096 Phase B Upgrade 1 — settings.json permissions.allow writer. const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo', 'antigravity']); // SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see @@ -742,6 +746,9 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ ['cline-rules', new Set(['cline-rules'])], ['cursor-hooks-json', new Set(['cursor-hooks-json'])], ['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])], + // 'none' added #2103 — VS Code has no CLI install surface at all; its only + // valid hooksSurface pairing is the other 'none' (engine owns the hook bus). + ['none', new Set(['none'])], ]); // GATE B: extended hook event families → required hookEvents value @@ -778,9 +785,18 @@ function validateConfigHome(capId, ch) { ); } - // name — required string - if (typeof ch.name !== 'string' || ch.name.length === 0) { - errors.push(ctx + '.name must be a non-empty string'); + // name — required string, except when kind === 'none': the runtime has no + // file-projected config directory at all, so a descriptive name is + // optional (a carve-out mirroring the dot-home-nested⇒parent conditional + // below, not a new validation mechanism). If present it must still be a + // non-empty string (e.g. vscode's configHome.name stays a descriptive + // "vscode" string even though it is never used to build a path). + if (ch.kind !== 'none') { + if (typeof ch.name !== 'string' || ch.name.length === 0) { + errors.push(ctx + '.name must be a non-empty string'); + } + } else if (ch.name !== undefined && (typeof ch.name !== 'string' || ch.name.length === 0)) { + errors.push(ctx + '.name must be a non-empty string if present when kind is "none"'); } // parent — required when kind == dot-home-nested @@ -1061,15 +1077,27 @@ function validateRuntimeBody(cap) { // localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679) // Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry // generator catches any descriptor missing the field before regenerating. - if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) { + // + // #2103: conditional on configHome.kind !== 'none' — a Marketplace/VSIX + // host with no file-projected config directory (e.g. VS Code) has no + // local dir to name; localConfigDir may be null/absent for such runtimes. + const configHomeKind = (r.configHome && typeof r.configHome === 'object') ? r.configHome.kind : undefined; + if (configHomeKind !== 'none') { + if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) { + errors.push( + 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' + + 'got: ' + JSON.stringify(r.localConfigDir), + ); + } else if (!r.localConfigDir.startsWith('.')) { + errors.push( + 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir), + ); + } + } else if (r.localConfigDir !== null && r.localConfigDir !== undefined) { errors.push( - 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' + + 'runtime.localConfigDir must be null or absent when configHome.kind is "none"; ' + 'got: ' + JSON.stringify(r.localConfigDir), ); - } else if (!r.localConfigDir.startsWith('.')) { - errors.push( - 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir), - ); } // extendedHookEvents — required array; every element must be in closed enum @@ -2175,6 +2203,9 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([ ['cline-rules', 'markdown-dir'], ['cursor-hooks-json', 'none'], ['profile-marker-only', 'none'], + // 'none' added #2103 — a runtime with NO CLI install surface at all (e.g. + // VS Code) has no config-file format to write either. + ['none', 'none'], ]); /** diff --git a/package.json b/package.json index 21ac230e4..6cd0b78bc 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "GEMINI.md", "hooks", "scripts", - "pi" + "pi", + "vscode" ], "keywords": [ "claude", diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index 4dfec6728..57c1b2429 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -46,7 +46,11 @@ type ConfigInstallSurface = | 'copilot-instructions' | 'cline-rules' | 'cursor-hooks-json' - | 'profile-marker-only'; + | 'profile-marker-only' + // #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with no CLI + // install surface at all. Never dispatched through install()/finishInstall() + // (see the ALLOWED_CONFIG_RUNTIMES filter below, which excludes it). + | 'none'; type FinishPermissionWriter = 'opencode' | 'kilo' | 'antigravity' | null; @@ -89,10 +93,20 @@ interface InstallPlan extends RuntimeConfigIntent { type RuntimeDescriptorMap = Record | undefined }>; -/** The complete set of 16 supported runtimes for config-adapter dispatch. */ +/** + * The complete set of 16 supported runtimes for config-adapter dispatch. + * + * Excludes runtimes whose installSurface is 'none' (#2103 — e.g. VS Code): a + * 'none' installSurface means the runtime has NO CLI install surface at all + * (Marketplace/VSIX-distributed, never dispatched through + * install()/finishInstall()), so it is not a "config-adapter runtime" by + * definition. This keeps this set in lockstep with bin/install.js's + * `allRuntimes` (see tests/issue-57-runtime-install-no-drift.test.cjs) without + * needing a separate hand-kept exclusion list. + */ const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set( Object.entries(runtimes) - .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string') + .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string' && cap.runtime['installSurface'] !== 'none') .map(([id]) => id), ); @@ -104,6 +118,7 @@ const INSTALL_SURFACES: ReadonlyArray = Object.freeze([ 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', + 'none', ]); /** diff --git a/src/runtime-name-policy.cts b/src/runtime-name-policy.cts index 0880786cd..957bd3beb 100644 --- a/src/runtime-name-policy.cts +++ b/src/runtime-name-policy.cts @@ -145,11 +145,38 @@ export function getProjectInstructionFile(runtime: unknown): string { return 'AGENTS.md'; } +/** + * Sentinel returned by {@link getDirName} for a runtime whose + * `runtime.configHome.kind === 'none'` (#2103 — a Marketplace/VSIX-distributed + * host with NO file-projected config directory at all, e.g. VS Code). + * + * A plain fallback to `.claude` would be actively wrong here — it would read + * as "this runtime installs into .claude", which is false. This sentinel is + * a string (not `null`) so `getDirName`'s return type and every existing + * template-literal call site (`` `${getDirName(runtime)}` `` in bin/install.js + * / runtime-artifact-conversion.cjs / install-engine.cjs) are unaffected — + * widening the return type to `string | null` would require auditing every + * call site for a null-check, which is out of scope for a runtime that is + * never actually dispatched through those installer paths (vscode has no + * install surface — see capabilities/vscode/capability.json). The value is + * deliberately NOT a plausible dot-dir name (parens are not valid in a + * directory-name token GSD would ever generate) so a future caller that + * mistakenly interpolates it into a path fails obviously rather than + * silently colliding with a real directory. + */ +export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)'; + /** * Map a canonical runtime id to its on-disk local config directory name * (e.g. `cursor` -> `.cursor`, `windsurf` -> `.windsurf`). Unknown/empty inputs * fall back to `.claude`. * + * #2103: a runtime whose descriptor declares `configHome.kind === 'none'` + * (no file-projected config directory at all) returns + * {@link NO_LOCAL_CONFIG_DIR_SENTINEL} instead of falling through to + * `.claude` — it has no local config dir, and `.claude` would be a wrong + * answer, not just an imprecise one. + * * Pure runtime-identity projection. Relocated from `bin/install.js` per * ADR-1508 (epic #1507, #1510 Phase 1) so the Runtime Artifact Conversion * Module's rewrite engine can consume it without importing the installer. @@ -159,10 +186,12 @@ export function getDirName(runtime: string): string { if (!runtime) return '.claude'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { runtimes } = require('./capability-registry.cjs') as { - runtimes: Record; + runtimes: Record; }; - const dir = runtimes[runtime]?.runtime?.localConfigDir; + const entry = runtimes[runtime]?.runtime; + const dir = entry?.localConfigDir; if (typeof dir === 'string' && dir.length > 0) return dir; + if (entry?.configHome?.kind === 'none') return NO_LOCAL_CONFIG_DIR_SENTINEL; return '.claude'; } @@ -211,6 +240,11 @@ const RUNTIME_LABELS: Readonly> = { cline: 'Cline', zcode: 'ZCode', pi: 'pi', + // #2103: vscode is a registered (role:runtime) capability for validator + + // host-integration coverage, even though it is never CLI-installed (no + // --vscode flag — see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs). + // A distinct label is still required by the drift guard below. + vscode: 'VS Code', }; /** diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index ebc188f69..ae7ad82b8 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -3952,12 +3952,14 @@ describe('FIX 3: tightened runtime validator — probeExists optional string', ( // ── 24e. Closed-vocab set exports are correct ───────────────────────────────── describe('ADR-1016 phase 5a: closed-vocab set exports', () => { - test('VALID_CONFIG_HOME_KINDS has exactly the 4 expected values', () => { + test('VALID_CONFIG_HOME_KINDS has exactly the 5 expected values', () => { assert.ok(VALID_CONFIG_HOME_KINDS instanceof Set); - for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']) { + // 'none' added #2103 — a runtime with NO file-projected config directory + // at all (e.g. vscode — Marketplace/VSIX-distributed). + for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']) { assert.ok(VALID_CONFIG_HOME_KINDS.has(v), 'VALID_CONFIG_HOME_KINDS must contain "' + v + '"'); } - assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 4, 'VALID_CONFIG_HOME_KINDS must have exactly 4 members'); + assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 5, 'VALID_CONFIG_HOME_KINDS must have exactly 5 members'); }); test('VALID_COMMAND_STYLES has exactly 2 values', () => { @@ -4261,15 +4263,18 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => }); // INSTALL_SURFACE_TO_CONFIG_FORMAT export check - test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 6 installSurface values with correct mappings', () => { + test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 7 installSurface values with correct mappings', () => { assert.ok(INSTALL_SURFACE_TO_CONFIG_FORMAT instanceof Map, 'Must be a Map'); - assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 6, 'Must cover 6 installSurface values'); + assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 7, 'Must cover 7 installSurface values'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('settings-json'), 'settings-json'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('codex-toml'), 'toml'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('copilot-instructions'), 'markdown'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cline-rules'), 'markdown-dir'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cursor-hooks-json'), 'none'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('profile-marker-only'), 'none'); + // 'none' added #2103 — a runtime with no CLI install surface at all (e.g. + // vscode) has no config-file format to write either. + assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('none'), 'none'); }); // Feature capabilities (role:feature) are silently ignored by the gate @@ -4432,10 +4437,71 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT ); }); + // #2103: configHome.kind==='none' carve-out (VS Code — Marketplace/VSIX, no + // file-projected config directory). Adversarial review flagged AC4's + // "accept vscode AND reject a faked configHome" requirement as untested — + // the real vscode descriptor validating clean (tests/capability-registry.test.cjs's + // "ADR-1016 phase 5a" suite, capability-validator-parity tests, etc.) only + // proves the ACCEPT half. These three tests are the REJECT half: they prove + // the two new branches in validateRuntimeBody/validateConfigHome actually + // fire, quoting their EXACT error strings so a future edit that silently + // weakens either branch fails loudly here. + describe('#2103 configHome.kind==="none" carve-out (vscode) — accept/reject pair', () => { + test('ACCEPT: configHome.kind:"none" + localConfigDir:null + installSurface:"none" + hooksSurface:"none" validates with ZERO errors', () => { + const cap = makeValidRuntimeCap({ + runtime: { + configHome: { kind: 'none', name: 'test-none-rt', env: [] }, + localConfigDir: null, + configFormat: 'none', + installSurface: 'none', + hooksSurface: 'none', + }, + }); + const errors = validateRuntimeBody(cap); + assert.deepEqual(errors, [], 'a genuinely kind:"none" descriptor (vscode-shaped) must validate clean, got: ' + JSON.stringify(errors)); + }); + + test('REJECT #1: localConfigDir non-null while configHome.kind==="none" → exact validateRuntimeBody error', () => { + const cap = makeValidRuntimeCap({ + runtime: { + configHome: { kind: 'none', name: 'test-none-rt', env: [] }, + localConfigDir: '.vscode', // faked — a kind:'none' descriptor must not also claim a local dir + configFormat: 'none', + installSurface: 'none', + hooksSurface: 'none', + }, + }); + const errors = validateRuntimeBody(cap); + assert.deepEqual( + errors, + ['runtime.localConfigDir must be null or absent when configHome.kind is "none"; got: ".vscode"'], + 'expected the exact localConfigDir-vs-kind:"none" rejection, got: ' + JSON.stringify(errors), + ); + }); + + test('REJECT #2: configHome.name present-but-empty while configHome.kind==="none" → exact validateConfigHome error', () => { + const cap = makeValidRuntimeCap({ + runtime: { + configHome: { kind: 'none', name: '', env: [] }, // faked — present name must still be non-empty + localConfigDir: null, + configFormat: 'none', + installSurface: 'none', + hooksSurface: 'none', + }, + }); + const errors = validateRuntimeBody(cap); + assert.deepEqual( + errors, + ['capability "test-runtime" runtime.configHome.name must be a non-empty string if present when kind is "none"'], + 'expected the exact configHome.name-vs-kind:"none" rejection, got: ' + JSON.stringify(errors), + ); + }); + }); + // Verify the new constants are well-formed - test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 6 installSurface values', () => { + test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 7 installSurface values', () => { assert.ok(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES instanceof Map, 'Must be a Map'); - assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 6, 'Must cover 6 installSurface values'); + assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 7, 'Must cover 7 installSurface values'); for (const installSurface of VALID_INSTALL_SURFACES) { assert.ok( INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.has(installSurface), @@ -6509,7 +6575,10 @@ describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () = test('equals the registry runtimes that declare an installSurface', () => { const descriptorAllowed = new Set( Object.entries(enh1055Registry.runtimes) - .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string') + // installSurface:'none' (#2103 vscode — extension-distributed, no config + // directory) is NOT a config-adapter runtime: production ALLOWED_CONFIG_RUNTIMES + // excludes it so `allRuntimes === ALLOWED_CONFIG_RUNTIMES` (issue-57) stays true. + .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none') .map(([id]) => id), ); assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed); diff --git a/tests/fixtures/vscode-host-binding.cjs b/tests/fixtures/vscode-host-binding.cjs index ad1d00851..a292afeb6 100644 --- a/tests/fixtures/vscode-host-binding.cjs +++ b/tests/fixtures/vscode-host-binding.cjs @@ -1,69 +1,12 @@ 'use strict'; /** - * Reference VS Code IDE host binding for GSD (ADR-1239 Phase D / #1933). + * Thin re-export of the SHIPPED VS Code host binding (#2103). * - * 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 } + * `bindGsdToVscode` moved to `vscode/host-binding.js` (the module the real + * extension's `activate()` requires) so it ships with the extension instead of + * living only under tests/. This fixture re-exports it so every existing test + * that requires `./fixtures/vscode-host-binding.cjs` keeps resolving without + * edits (tests/vscode-ide-reference.test.cjs and any other consumer). */ -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 }); -}; +module.exports = require('../../vscode/host-binding.js'); diff --git a/tests/gemini-runtime-removed.test.cjs b/tests/gemini-runtime-removed.test.cjs index 65a008fb5..ef5c5ad0c 100644 --- a/tests/gemini-runtime-removed.test.cjs +++ b/tests/gemini-runtime-removed.test.cjs @@ -164,15 +164,21 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => { assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default'); }); - test('runtimeFlags has no isGemini and covers exactly the non-claude registry runtimes (count-agnostic)', () => { + test('runtimeFlags has no isGemini and covers exactly the non-claude, CLI-installable registry runtimes (count-agnostic)', () => { const flags = runtimeFlags('claude'); assert.ok(!('isGemini' in flags), 'isGemini flag must be gone'); // The flag set tracks the non-claude registry runtimes (one is per // id), so adding a runtime updates the count automatically — no hand-pinned // number that would break on the next runtime addition. - const expectedNonClaudeCount = Object.keys(registry.runtimes).filter((id) => id !== 'claude').length; + // #2103: registry runtimes with installSurface === 'none' (e.g. vscode — + // Marketplace/VSIX-distributed, never CLI-installed) have no -- flag + // by design (see tests/runtime-flags.test.cjs's NON_INSTALLABLE_RUNTIMES) + // and are excluded from this count too. + const expectedNonClaudeCount = Object.keys(registry.runtimes) + .filter((id) => id !== 'claude' && registry.runtimes[id].runtime.installSurface !== 'none') + .length; assert.strictEqual(Object.keys(flags).length, expectedNonClaudeCount, - 'flag count must equal the non-claude registry runtime count'); + 'flag count must equal the non-claude, CLI-installable registry runtime count'); }); test('gemini no longer maps to GEMINI.md (defaults to AGENTS.md)', () => { diff --git a/tests/getdirname-registry-derivation.test.cjs b/tests/getdirname-registry-derivation.test.cjs index e52a46341..728be11ce 100644 --- a/tests/getdirname-registry-derivation.test.cjs +++ b/tests/getdirname-registry-derivation.test.cjs @@ -14,6 +14,13 @@ * - a structural cross-check that every descriptor's localConfigDir is a * non-empty dot-dir string. * + * #2103: vscode's `configHome.kind === 'none'` (no file-projected config + * directory at all) is the first descriptor with `localConfigDir: null` — it + * is carved out of the "non-empty dot-dir string" invariant below and + * asserted against getDirName's documented NO_LOCAL_CONFIG_DIR_SENTINEL + * instead (mirrors the carve-out in + * tests/non-claude-runtimes-registry-derivation.test.cjs). + * * ADR-1239 Phase B (#1679). Behavioral tests only: assert on returned values. */ @@ -22,14 +29,36 @@ const assert = require('node:assert/strict'); const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); -const { getDirName } = runtimeNamePolicy; +const { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } = runtimeNamePolicy; const RUNTIME_IDS = Object.keys(registry.runtimes); +// Runtimes whose configHome.kind === 'none' have NO file-projected config +// directory at all (localConfigDir is legitimately null) — carved out of the +// "non-empty dot-dir string" invariant, the same way dot-home-nested's +// .parent is a conditional carve-out in the validator. +const NO_LOCAL_CONFIG_DIR_RUNTIMES = new Set( + RUNTIME_IDS.filter((id) => { + const desc = registry.runtimes[id] && registry.runtimes[id].runtime; + return !!(desc && desc.configHome && desc.configHome.kind === 'none'); + }), +); + test('getDirName(id) projects each descriptor runtime.localConfigDir (derivation contract, count-agnostic)', () => { assert.ok(RUNTIME_IDS.length > 0, 'registry must contain at least one runtime'); for (const id of RUNTIME_IDS) { const desc = registry.runtimes[id] && registry.runtimes[id].runtime; + if (NO_LOCAL_CONFIG_DIR_RUNTIMES.has(id)) { + // #2103: no file-projected config dir — getDirName must return the + // documented sentinel, not the '.claude' default and not null. + assert.strictEqual(desc.localConfigDir, null, + `${id}: configHome.kind === 'none' runtimes must declare localConfigDir: null`); + assert.strictEqual( + getDirName(id), + NO_LOCAL_CONFIG_DIR_SENTINEL, + `getDirName('${id}') must equal the documented no-local-config-dir sentinel`); + continue; + } const expected = desc && desc.localConfigDir; assert.ok(typeof expected === 'string' && expected.length > 0, `registry.runtimes['${id}'].runtime.localConfigDir must be a non-empty string`); @@ -46,11 +75,16 @@ test('getDirName fallback: unknown / empty runtime returns ".claude" (fail-close assert.strictEqual(getDirName('__nonexistent_runtime__'), '.claude'); }); -test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string', () => { +test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string, except configHome.kind==="none" runtimes (localConfigDir: null)', () => { for (const [id, entry] of Object.entries(registry.runtimes)) { if (!entry || typeof entry !== 'object') continue; const runtimeBlock = entry.runtime; if (!runtimeBlock || typeof runtimeBlock !== 'object') continue; + if (NO_LOCAL_CONFIG_DIR_RUNTIMES.has(id)) { + assert.strictEqual(runtimeBlock.localConfigDir, null, + `registry.runtimes['${id}'].runtime.localConfigDir must be null (configHome.kind === 'none')`); + continue; + } const dir = runtimeBlock.localConfigDir; assert.strictEqual(typeof dir, 'string', `registry.runtimes['${id}'].runtime.localConfigDir must be a string (got: ${typeof dir})`); diff --git a/tests/global-config-home-fragment.test.cjs b/tests/global-config-home-fragment.test.cjs index d3a97bf45..a644b60cd 100644 --- a/tests/global-config-home-fragment.test.cjs +++ b/tests/global-config-home-fragment.test.cjs @@ -51,8 +51,10 @@ const GOLDEN_FRAGMENT_MAP = { }; // Runtimes intentionally NOT in the table: claude is the default; antigravity is -// resolved dynamically by the caller (resolveAntigravityGlobalDir + path.relative). -const SPECIAL_CASED = new Set(['claude', 'antigravity']); +// resolved dynamically by the caller (resolveAntigravityGlobalDir + path.relative); +// vscode (#2103) is extension-distributed with no file-projected config home at +// all — getGlobalConfigHomeFragment is never invoked for it (no install surface). +const SPECIAL_CASED = new Set(['claude', 'antigravity', 'vscode']); test('getGlobalConfigHomeFragment: golden map matches for all 13 table runtimes', () => { for (const [id, expected] of Object.entries(GOLDEN_FRAGMENT_MAP)) { diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs index 898e27e21..7806c8e15 100644 --- a/tests/host-integration-descriptors.test.cjs +++ b/tests/host-integration-descriptors.test.cjs @@ -40,7 +40,7 @@ const RUNTIME_IDS = Object.keys(registry.runtimes); // Contract-pinned profile split (derived from .host-cli-final.json): // programmatic-cli: claude, cline, cursor, hermes, kilo, kimi, opencode, pi, qwen, trae (10) // declarative-cli: antigravity, augment, codebuddy, codex, copilot, windsurf, zcode (7) -// ide: 0 +// ide: vscode (1) — #2103, the first installed ide-profile host. const EXPECTED_PROFILES = { claude: 'programmatic-cli', cline: 'programmatic-cli', @@ -59,6 +59,7 @@ const EXPECTED_PROFILES = { copilot: 'declarative-cli', windsurf: 'declarative-cli', zcode: 'declarative-cli', + vscode: 'ide', }; describe('ADR-1239 Phase A: hostIntegration descriptors', () => { @@ -215,7 +216,9 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { test('contract-pin: profile split is internally consistent with EXPECTED_PROFILES (count-agnostic)', () => { // The counts are DERIVED from the curated EXPECTED_PROFILES map rather than // hand-pinned, so adding a runtime + its profile entry updates the counts - // automatically. ide must remain 0 (no installed ide-profile host yet). + // automatically. #2103: vscode is now the first installed ide-profile host, + // so 'ide' is no longer pinned at a hardcoded 0 — it is derived below like + // the other two profiles. const counts = { 'programmatic-cli': 0, 'declarative-cli': 0, 'ide': 0 }; for (const id of RUNTIME_IDS) { const cap = registry.runtimes[id]; @@ -236,7 +239,8 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { } assert.strictEqual(counts['programmatic-cli'], expectedCounts['programmatic-cli']); assert.strictEqual(counts['declarative-cli'], expectedCounts['declarative-cli']); - assert.strictEqual(counts['ide'], 0, 'no installed host may carry the ide profile yet'); + assert.strictEqual(counts['ide'], expectedCounts['ide'], + 'ide-profile count must match EXPECTED_PROFILES (#2103: vscode is the first ide-profile host)'); }); // ─── backgroundDispatch presence ───────────────────────────────────────────── @@ -291,6 +295,10 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { trae: true, windsurf: true, zcode: true, + // #2103: vscode's dispatch.backgroundDispatch is 'undocumented' (no + // documented background-subagent primitive) → fails closed to false → + // force-flattened, mirroring the pi (#2102) precedent above. + vscode: true, }; for (const id of RUNTIME_IDS) { diff --git a/tests/issue-57-runtime-install-no-drift.test.cjs b/tests/issue-57-runtime-install-no-drift.test.cjs index b3abbf0a4..2a06b4af5 100644 --- a/tests/issue-57-runtime-install-no-drift.test.cjs +++ b/tests/issue-57-runtime-install-no-drift.test.cjs @@ -184,6 +184,36 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit ); }); + // allow-test-rule: structural guard over bin/install.js source (#2103). VS Code + // (capabilities/vscode/capability.json) is a registry runtime (role:runtime, for + // validator/host-integration coverage) but is NEVER CLI-installed — it is a + // Marketplace/VSIX extension with no --vscode flag and no allRuntimes membership + // (see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs). It must stay + // fully descriptor-driven: bin/install.js must never special-case it by name. + // This is a stricter, clearer-failure-message sibling of the generic + // "every inline runtime === ..." guard above (which would also catch this, but + // with a misleading "register it in the adapter registry" suggestion — vscode + // must never be registered there at all, see the ALLOWED_CONFIG_RUNTIMES filter + // in src/runtime-config-adapter-registry.cts). + test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => { + const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8'); + const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)]; + assert.deepStrictEqual( + runtimeComparisons.map((m) => m[0]), + [], + 'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no ' + + 'install surface at all (installSurface: "none") and is never CLI-installed; any ' + + 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.', + ); + const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)]; + assert.deepStrictEqual( + isVscodeRefs.map((m) => m[0]), + [], + 'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded ' + + 'from runtimeFlags (Marketplace-distributed, never CLI-installed).', + ); + }); + // allow-test-rule: delegation-presence guard. Catches wholesale removal of the registry // dispatch (a regression to scattered per-runtime config branching). Presence-style, not // absence-grep, so it does not bite on incidental non-config `runtime === '...'` checks. diff --git a/tests/non-claude-runtimes-registry-derivation.test.cjs b/tests/non-claude-runtimes-registry-derivation.test.cjs index aa1db9123..9be6a2b1e 100644 --- a/tests/non-claude-runtimes-registry-derivation.test.cjs +++ b/tests/non-claude-runtimes-registry-derivation.test.cjs @@ -23,7 +23,7 @@ const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); const { NON_CLAUDE_RUNTIMES } = conversion; -const { getDirName } = runtimeNamePolicy; +const { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } = runtimeNamePolicy; // Golden oracle: hardcoded sorted known-good list of all non-Claude runtimes. // A pinned expected value in a TEST is correct — the test IS the oracle. @@ -78,3 +78,17 @@ test('DRIFT GUARD: every registry-declared non-Claude runtime has an explicit ge ); } }); + +// #2103: vscode is a registry runtime (role:runtime) whose configHome.kind is +// 'none' — it has NO file-projected config directory at all (Marketplace/VSIX +// extension). It is covered by the generic loop above (its dir must not be +// '.claude'), but that assertion alone would ALSO pass for a plain string +// typo, so this pins the actual documented sentinel value honestly rather +// than riding on the generic "not .claude" check. +test('#2103: getDirName("vscode") returns the documented no-local-config-dir sentinel, not .claude and not a real dot-dir', () => { + assert.ok(NON_CLAUDE_RUNTIMES.includes('vscode'), 'vscode must be a registered non-Claude runtime'); + const dir = getDirName('vscode'); + assert.equal(dir, NO_LOCAL_CONFIG_DIR_SENTINEL); + assert.notEqual(dir, '.claude'); + assert.ok(!dir.startsWith('.'), 'the sentinel must not look like a plausible dot-dir name'); +}); diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index 01b322e81..98e52f25a 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -197,15 +197,26 @@ describe('resolveRuntimeConfigIntent — fresh object each call', () => { // --------------------------------------------------------------------------- describe('ALLOWED_CONFIG_RUNTIMES completeness', () => { - test('ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare an installSurface', () => { + test('ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare a real (non-"none") installSurface', () => { + // #2103: installSurface 'none' means "no CLI install surface at all" + // (e.g. vscode — Marketplace/VSIX-distributed, never CLI-installed), so + // it is excluded from the config-adapter runtime set by definition — this + // mirrors the exclusion already baked into the production + // ALLOWED_CONFIG_RUNTIMES filter (src/runtime-config-adapter-registry.cts). const descriptorAllowed = new Set( Object.entries(registry.runtimes) - .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string') + .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none') .map(([id]) => id), ); assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed); }); + test('#2103: vscode declares installSurface "none" and is registered but intentionally excluded from ALLOWED_CONFIG_RUNTIMES', () => { + assert.strictEqual(registry.runtimes.vscode.runtime.installSurface, 'none'); + assert.ok(!ALLOWED_CONFIG_RUNTIMES.has('vscode'), + 'vscode must not be a config-adapter runtime — it has no CLI install surface'); + }); + test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => { for (const runtime of ALLOWED_CONFIG_RUNTIMES) { assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`); @@ -225,9 +236,11 @@ describe('INSTALL_SURFACES export', () => { 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', + // 'none' added #2103 — vscode has no CLI install surface at all. + 'none', ]); - test('INSTALL_SURFACES contains exactly the 6 surface strings', () => { + test('INSTALL_SURFACES contains exactly the 7 surface strings', () => { assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES); }); }); diff --git a/tests/runtime-flags.test.cjs b/tests/runtime-flags.test.cjs index 97a2929e1..1c73df7ae 100644 --- a/tests/runtime-flags.test.cjs +++ b/tests/runtime-flags.test.cjs @@ -17,6 +17,14 @@ const EXPECTED_FLAGS = [ 'isCodebuddy', 'isCline', 'isKimi', 'isZcode', 'isPi', ]; +// #2103: registry runtimes that are NEVER CLI-installed via bin/install.js +// (Marketplace/VSIX-distributed, no -- flag, not in allRuntimes) — these +// have no runtimeFlags entry by design, not by drift. vscode is the first +// (and, today, only) member: it enters `registry.runtimes` (role:runtime, for +// validator/host-integration coverage) but never enters bin/install.js's +// allRuntimes, so it must not be required to have an isVscode flag here. +const NON_INSTALLABLE_RUNTIMES = new Set(['vscode']); + test('runtimeFlags: every known non-claude runtime sets exactly its own flag true', () => { const ids = EXPECTED_FLAGS.map((f) => f.slice(2).toLowerCase()); for (const id of ids) { @@ -44,11 +52,27 @@ test('runtimeFlags: all 16 flags present + boolean + the object is frozen', () = assert.ok(Object.isFrozen(flags), 'flags object must be frozen'); }); -test('runtimeFlags drift guard: covers every registry runtime except claude', () => { +test('runtimeFlags drift guard: covers every registry runtime except claude and the non-installable set', () => { // Adding a registry runtime that is not claude must get a flag or be added to // RUNTIME_FLAG_IDS — pin the set so a new runtime forces a deliberate update. - const registryNonClaude = Object.keys(registry.runtimes).filter((r) => r !== 'claude').sort(); + // NON_INSTALLABLE_RUNTIMES (#2103) is filtered out first: a runtime that is + // never CLI-installed (e.g. vscode — Marketplace/VSIX only) has no -- + // flag by design and must not trip this guard. + const registryNonClaude = Object.keys(registry.runtimes) + .filter((r) => r !== 'claude' && !NON_INSTALLABLE_RUNTIMES.has(r)) + .sort(); const flagIds = EXPECTED_FLAGS.map((f) => f.slice(2).toLowerCase()).sort(); const missing = registryNonClaude.filter((r) => !flagIds.includes(r)); assert.deepEqual(missing, [], `registry runtimes missing a runtimeFlags entry: ${missing.join(', ')} — add to RUNTIME_FLAG_IDS`); }); + +test('#2103: vscode is registered but intentionally excluded from runtimeFlags (Marketplace-distributed, never CLI-installed)', () => { + assert.ok(registry.runtimes.vscode, 'vscode must be present in the registry (role:runtime)'); + assert.ok(NON_INSTALLABLE_RUNTIMES.has('vscode')); + const flags = runtimeFlags('vscode'); + for (const f of EXPECTED_FLAGS) { + assert.strictEqual(flags[f], false, `runtime 'vscode': ${f} must be false (no isVscode flag exists)`); + } + assert.deepStrictEqual(Object.keys(flags).sort(), [...EXPECTED_FLAGS].sort(), + 'runtimeFlags(\'vscode\') must NOT introduce a new isVscode key — still exactly the 16 flags'); +}); diff --git a/tests/vscode-browser-no-node-api.test.cjs b/tests/vscode-browser-no-node-api.test.cjs new file mode 100644 index 000000000..979cc8750 --- /dev/null +++ b/tests/vscode-browser-no-node-api.test.cjs @@ -0,0 +1,128 @@ +// allow-test-rule: source-text-is-the-product (#2103). browser.js's entire +// contract IS "zero Node APIs" — a VS Code Web/webworker host has no Node +// core modules at all, so this is a runtime-contract source check, not a +// behavioral proxy for something observable another way (there is no Node +// runtime to observe failing against in CI). Mirrors the existing +// phase6-capstone-conformance.test.cjs precedent for the same exemption class. +'use strict'; + +/** + * VS Code browser (Web Extension) entry static guard — #2103. + * + * `vscode/browser.js` is the `browser` field entry VS Code Web / vscode.dev + * loads in a webworker context, which has NO Node core modules at all + * (`fs`, `path`, `child_process`) and no Node globals (`process`, `Buffer`, + * `__dirname`, `__filename`). Requiring one throws immediately at load time. + * + * This is the reason browser.js does NOT require `./host-binding.js` (whose + * transitive engine-lib dependencies pull in `node:fs` — see host-binding.js's + * and browser.js's own header comments for the full chain) — it implements an + * independent, minimal composition directly against `vscode.lm`. + * + * No real browser or VS Code host is available in CI (mock vscode only, per + * every other vscode-*.test.cjs in this suite). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const BROWSER_PATH = path.join(__dirname, '..', 'vscode', 'browser.js'); + +test('vscode/browser.js source contains ZERO require("fs" | "path" | "child_process") (or "node:" prefixed forms)', () => { + const src = fs.readFileSync(BROWSER_PATH, 'utf8'); + const bannedRequires = [ + /require\(\s*['"]fs['"]\s*\)/, + /require\(\s*['"]node:fs['"]\s*\)/, + /require\(\s*['"]path['"]\s*\)/, + /require\(\s*['"]node:path['"]\s*\)/, + /require\(\s*['"]child_process['"]\s*\)/, + /require\(\s*['"]node:child_process['"]\s*\)/, + ]; + const offenders = []; + for (const line of src.split(/\r?\n/)) { + // Skip comment lines (this file's own header documents the constraint in + // prose, which legitimately contains the string `require('fs')` etc.). + const trimmed = line.trim(); + if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue; + for (const re of bannedRequires) { + if (re.test(line)) offenders.push(line.trim()); + } + } + assert.deepEqual(offenders, [], + `vscode/browser.js must never require a Node core module outside a comment; found: ${JSON.stringify(offenders)}`); +}); + +test('vscode/browser.js does NOT require ./host-binding.js or ./extension.js (both pull in fs-heavy engine-lib modules transitively)', () => { + const src = fs.readFileSync(BROWSER_PATH, 'utf8'); + const codeLines = src.split(/\r?\n/).filter((l) => { + const t = l.trim(); + return !(t.startsWith('*') || t.startsWith('//')); + }).join('\n'); + assert.doesNotMatch(codeLines, /require\(\s*['"]\.\/(host-binding|extension)\.js['"]\s*\)/, + 'browser.js must compose its own zero-Node-API surface, not require a module with fs-pulling transitive deps'); +}); + +test('vscode/browser.js source contains no Node-only globals (process/Buffer/__dirname/__filename) outside comments', () => { + const src = fs.readFileSync(BROWSER_PATH, 'utf8'); + const offenders = []; + for (const line of src.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue; + if (/\bprocess\.|\bBuffer\.|__dirname\b|__filename\b/.test(line)) offenders.push(line.trim()); + } + assert.deepEqual(offenders, []); +}); + +test('vscode/browser.js is valid, loadable JS (node --check equivalent — require does not throw)', () => { + assert.doesNotThrow(() => require(BROWSER_PATH)); +}); + +test('REACHABILITY: browser.js activate() runs against a mock vscode host without throwing (no real VS Code/browser in CI)', () => { + const Module = require('module'); + const originalLoad = Module._load; + const registeredCommands = {}; + let chatCreated = false; + const toolNames = []; + const mockVscode = { + commands: { + registerCommand(id, handler) { + registeredCommands[id] = handler; + return { dispose() {} }; + }, + }, + chat: { + createChatParticipant(id, handler) { + chatCreated = true; + return { id, handler, dispose() {} }; + }, + }, + lm: { + registerTool(name) { + toolNames.push(name); + return { dispose() {} }; + }, + }, + workspace: { getConfiguration: () => ({ get: () => undefined }) }, + LanguageModelTextPart: class { constructor(t) { this.text = t; } }, + LanguageModelToolResult: class { constructor(p) { this.parts = p; } }, + }; + Module._load = function (request, ...rest) { + if (request === 'vscode') return mockVscode; + return originalLoad.call(this, request, ...rest); + }; + try { + delete require.cache[BROWSER_PATH]; + const browser = require(BROWSER_PATH); + const context = { subscriptions: [] }; + assert.doesNotThrow(() => browser.activate(context)); + assert.ok(registeredCommands['gsd.invoke'], 'gsd.invoke command registered on web too'); + assert.ok(chatCreated, 'chat participant registered on web'); + assert.ok(toolNames.length > 0, 'LM tools registered on web'); + assert.ok(context.subscriptions.length > 0); + } finally { + Module._load = originalLoad; + delete require.cache[BROWSER_PATH]; + } +}); diff --git a/tests/vscode-extension-reachability.test.cjs b/tests/vscode-extension-reachability.test.cjs index bfbfd8f87..3295674dd 100644 --- a/tests/vscode-extension-reachability.test.cjs +++ b/tests/vscode-extension-reachability.test.cjs @@ -1,18 +1,34 @@ 'use strict'; /** - * VS Code extension reachability test — ADR-1239 Phase D / #1942. + * 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 the GSD command-routing hub and returns a result (not just - * a stub). The handler is exported separately from activate() so it is testable - * WITHOUT a VS Code host. + * 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 { activate, dispatchGsdCommand, resolveEngineRoot } = require('../vscode/extension.js'); +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'); @@ -20,18 +36,43 @@ test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot', assert.equal(typeof resolveEngineRoot, 'function'); }); -test('REACHABILITY: dispatchGsdCommand dispatches through the engine hub (keystone wired)', async () => { - const result = await dispatchGsdCommand({ family: 'query', subcommand: 'help' }); - 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 (the engine was reached)'); +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('dispatchGsdCommand works with default args (no args → query/help)', async () => { +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'); - JSON.parse(result); // must be valid JSON + 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', () => { @@ -49,3 +90,108 @@ test('the extension manifest declares the gsd.invoke command', () => { 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]; + } +}); diff --git a/tests/vscode-ide-reference.test.cjs b/tests/vscode-ide-reference.test.cjs index 27a77de26..4e5973480 100644 --- a/tests/vscode-ide-reference.test.cjs +++ b/tests/vscode-ide-reference.test.cjs @@ -18,8 +18,9 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { profileOf } = require('../gsd-core/bin/lib/host-integration.cjs'); +const { profileOf, negotiateHostCapabilities } = require('../gsd-core/bin/lib/host-integration.cjs'); const bindGsdToVscode = require('./fixtures/vscode-host-binding.cjs'); +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); test('VS Code IDE axes classify as the ide profile', () => { // ide baseline (host-integration.cts PROFILE_BASELINES): imperative + sandboxed-web. @@ -27,10 +28,17 @@ test('VS Code IDE axes classify as the ide profile', () => { assert.notEqual(profileOf({ embeddingMode: 'imperative', runtime: 'node' }), 'ide'); }); -test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', () => { +test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', async () => { + // Real API: vscode.lm.selectChatModels() (async → LanguageModelChat[]); the + // SELECTED MODEL has .sendRequest, NOT vscode.lm itself (#2103 correction — + // there is no vscode.lm.sendRequest). let lastLmReq = null; const vscode = { - lm: { sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } }, + lm: { + selectChatModels: async () => [ + { sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } }, + ], + }, }; const storageWrites = []; const hostStorage = { @@ -41,9 +49,10 @@ test('bindGsdToVscode composes the full IDE profile (active model + engine bus + const host = bindGsdToVscode(vscode, hostStorage); assert.equal(host.runtime, 'vscode'); - // Active model routes through vscode.lm (no system messages — User role only). + // Active model routes through vscode.lm.selectChatModels() → model.sendRequest + // (no system messages — User role only). assert.equal(host.model.mode, 'active'); - assert.equal(host.model.sendRequest({ prompt: 'hi' }), 'lm-response'); + assert.equal(await 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). @@ -67,9 +76,30 @@ test('bindGsdToVscode composes the full IDE profile (active model + engine bus + assert.ok(host.commands['gsd.invoke'], 'palette/chat command surface present'); }); +// #2103: proves the binding actually COMPOSES (does not throw) against a +// realistic desktop VS Code `vscode.lm` shape — the whole point of the guard +// fix: a real host has `selectChatModels`, never `sendRequest` directly, and +// the binding must succeed, not silently fail-open at the extension.js layer. +test('#2103: bindGsdToVscode composes successfully against a REALISTIC vscode.lm (selectChatModels only, no vscode.lm.sendRequest)', async () => { + const vscode = { + lm: { + selectChatModels: async () => [{ sendRequest: (req) => Promise.resolve({ echoed: req }) }], + // Deliberately no top-level sendRequest — matches the real API surface. + }, + }; + const hostStorage = { read: () => '', write: () => {} }; + + let host; + assert.doesNotThrow(() => { host = bindGsdToVscode(vscode, hostStorage); }, + 'bindGsdToVscode must succeed on a realistic host (selectChatModels-only vscode.lm)'); + assert.equal(host.model.mode, 'active'); + const response = await host.model.sendRequest({ prompt: 'hello' }); + assert.deepEqual(response, { echoed: { prompt: 'hello' } }); +}); + test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => { const okStorage = { read() {}, write() {} }; - const okVscode = { lm: { sendRequest() {} } }; + const okVscode = { lm: { selectChatModels: async () => [] } }; // vscode.lm missing or incomplete → vscode.lm error assert.throws(() => bindGsdToVscode({}, okStorage), /vscode\.lm/); assert.throws(() => bindGsdToVscode({ lm: {} }, okStorage), /vscode\.lm/); @@ -77,3 +107,73 @@ test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => { assert.throws(() => bindGsdToVscode(okVscode, null), /hostStorage/); assert.throws(() => bindGsdToVscode(okVscode, { read() {} }), /hostStorage/); }); + +test('#2103: bindGsdToVscode still throws when vscode.lm.selectChatModels is ABSENT (a vscode.lm.sendRequest-only mock, matching the OLD incorrect API assumption, must be rejected)', () => { + const okStorage = { read() {}, write() {} }; + // The pre-#2103 (wrong) shape: sendRequest directly on vscode.lm, no selectChatModels. + const staleShapeVscode = { lm: { sendRequest: () => 'stale' } }; + assert.throws(() => bindGsdToVscode(staleShapeVscode, okStorage), /selectChatModels/); +}); + +// ── #2103: negotiate fail-closed for an UNDECLARED vscode axis ────────────── +// negotiateHostCapabilities must never throw and must degrade an undeclared +// (missing) axis to its most-restrictive documented default — the same +// fail-closed contract already proven for the "undocumented" sentinel +// (tests/host-integration-descriptors.test.cjs), but exercised here against a +// genuinely ABSENT key (not the string "undocumented") on vscode's real +// hostIntegration object, to prove the negotiation seam is defensive against +// both failure modes. +// +// #2103 FIX (adversarial review, MINOR): strengthened from a weak +// `!== undefined` check to a STRICT equality against the actual +// most-restrictive value negotiateHostCapabilities produces — the fail-closed +// floor pinned as `SAFE_DEFAULTS` in gsd-core/bin/lib/host-integration.cjs +// (not exported, so the values are pinned here verbatim, verified via node -e +// against the real negotiation output before writing this test — see the PR +// report). A `!== undefined` check would pass even if negotiation regressed +// to a WRONG-but-defined value (e.g. a less-restrictive default) — this +// assertion pins the exact known floor per the AC's own wording +// ("most-restrictive known value"). +const HOST_INTEGRATION_SAFE_DEFAULTS = { + embeddingMode: 'declarative', + commandSurface: 'prose-only', + modelMode: 'passive', + hookBus: 'none', + stateIO: 'session-log-append', + transport: 'mcp', + runtime: 'node', +}; +const HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS = { + namedDispatch: false, + nested: false, + maxDepth: 0, + background: false, + subagentToolkit: 'read-only', + backgroundDispatch: false, +}; + +test('#2103: negotiateHostCapabilities never throws for vscode with an UNDECLARED axis, and degrades to the EXACT most-restrictive SAFE_DEFAULTS value', () => { + const realHi = registry.runtimes.vscode.runtime.hostIntegration; + for (const axis of Object.keys(HOST_INTEGRATION_SAFE_DEFAULTS)) { + const undeclared = { ...realHi, dispatch: { ...realHi.dispatch } }; + delete undeclared[axis]; + let result; + assert.doesNotThrow(() => { result = negotiateHostCapabilities(undeclared); }, + `negotiateHostCapabilities must not throw when vscode's "${axis}" axis is entirely absent`); + assert.strictEqual(result.effective[axis], HOST_INTEGRATION_SAFE_DEFAULTS[axis], + `effective.${axis} must resolve to the exact most-restrictive SAFE_DEFAULTS value "${HOST_INTEGRATION_SAFE_DEFAULTS[axis]}", got: ${JSON.stringify(result.effective[axis])}`); + } +}); + +test('#2103: negotiateHostCapabilities never throws for vscode with an UNDECLARED dispatch sub-axis, and degrades to the EXACT most-restrictive value', () => { + const realHi = registry.runtimes.vscode.runtime.hostIntegration; + for (const key of Object.keys(HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS)) { + const undeclared = { ...realHi, dispatch: { ...realHi.dispatch } }; + delete undeclared.dispatch[key]; + let result; + assert.doesNotThrow(() => { result = negotiateHostCapabilities(undeclared); }, + `negotiateHostCapabilities must not throw when vscode's dispatch.${key} is entirely absent`); + assert.strictEqual(result.effective.dispatch[key], HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS[key], + `effective.dispatch.${key} must resolve to the exact most-restrictive value "${HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS[key]}", got: ${JSON.stringify(result.effective.dispatch[key])}`); + } +}); diff --git a/tests/vscode-lm-tools.test.cjs b/tests/vscode-lm-tools.test.cjs new file mode 100644 index 000000000..d5ce27307 --- /dev/null +++ b/tests/vscode-lm-tools.test.cjs @@ -0,0 +1,155 @@ +'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/); +}); diff --git a/tests/vscode-subagent-dispatch.test.cjs b/tests/vscode-subagent-dispatch.test.cjs new file mode 100644 index 000000000..43e671b21 --- /dev/null +++ b/tests/vscode-subagent-dispatch.test.cjs @@ -0,0 +1,148 @@ +'use strict'; + +/** + * VS Code #runSubagent wiring test — #2103 UPGRADE 2. + * + * VS Code 1.105+ lets the primary chat agent invoke registered chat + * participants / Language Model Tools as a nested agent turn via + * `#runSubagent`, gated by the `chat.subagents.allowInvocationsFromSubagents` + * setting (default off; nested depth max 5 when enabled — docs/agents/subagents.md, + * release-notes/v1_105.md). There is no separate extension-side "subagent + * contribution" registration API: VS Code's chat engine surfaces the already + * -registered chat participant + languageModelTools directly. This extension's + * own contribution is `registerSubagentDispatch` — availability detection + * (fail-soft on older/Insiders-gated hosts) plus a belt-and-suspenders + * maxDepth:5 ceiling (dispatchAsSubagent) that mirrors + * capabilities/vscode/capability.json's hostIntegration.dispatch.maxDepth, + * independent of whatever the host itself enforces. + * + * Mock vscode only — no real VS Code host in CI. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const extension = require('../vscode/extension.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +function mockVscodeWithSubagentSupport(allow) { + return { + workspace: { + getConfiguration(section) { + return { + get(key) { + if (section === 'chat.subagents' && key === 'allowInvocationsFromSubagents') return allow; + return undefined; + }, + }; + }, + }, + }; +} + +test('registerSubagentDispatch reports maxDepth matching capabilities/vscode/capability.json (dispatch.maxDepth:5)', () => { + const cap = require('../capabilities/vscode/capability.json'); + const wiring = extension.registerSubagentDispatch(mockVscodeWithSubagentSupport(true)); + assert.equal(wiring.maxDepth, cap.runtime.hostIntegration.dispatch.maxDepth, + 'the extension-side depth cap must mirror the descriptor-declared maxDepth'); + assert.equal(extension.GSD_MAX_SUBAGENT_DEPTH, cap.runtime.hostIntegration.dispatch.maxDepth); +}); + +test('registerSubagentDispatch: available:true when chat.subagents.allowInvocationsFromSubagents is configured', () => { + const wiring = extension.registerSubagentDispatch(mockVscodeWithSubagentSupport(true)); + assert.equal(wiring.available, true); + assert.equal(typeof wiring.dispatchAsSubagent, 'function'); +}); + +test('registerSubagentDispatch: available:false (fail-soft, no throw) when the setting is absent (older/Insiders-gated VS Code)', () => { + assert.doesNotThrow(() => { + const wiring = extension.registerSubagentDispatch({}); + assert.equal(wiring.available, false); + }); +}); + +test('registerSubagentDispatch: available:false (fail-soft) when vscode.workspace.getConfiguration itself throws', () => { + const throwingVscode = { + workspace: { getConfiguration() { throw new Error('simulated host failure'); } }, + }; + assert.doesNotThrow(() => { + const wiring = extension.registerSubagentDispatch(throwingVscode); + assert.equal(wiring.available, false); + }); +}); + +test('REACHABILITY: a background-eligible dispatchAsSubagent call at depth 0 dispatches through the shared hub and returns REAL output', async () => { + const dir = createTempDir(); + try { + const result = JSON.parse(await extension.dispatchAsSubagent({ + family: 'progress', subcommand: 'json', cwd: dir, depth: 0, + })); + assert.equal(result.ok, true); + const parsed = JSON.parse(result.stdout); + assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (engine reached)'); + } finally { + cleanup(dir); + } +}); + +// #2103 FIX (adversarial review, MINOR — boundary gap): the repo's own +// TESTING-STANDARDS mandate exercising limit-1/limit/limit+1 around a boundary. +// The maxDepth:5 ceiling previously covered only 5 (limit) and 6 (limit+1) — +// this completes the triple with depth 4 (limit-1). +test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 4 (limit-1) still dispatches', async () => { + const dir = createTempDir(); + try { + const result = JSON.parse(await extension.dispatchAsSubagent({ + family: 'progress', subcommand: 'json', cwd: dir, depth: 4, + })); + assert.equal(result.ok, true, 'depth one below the ceiling must be allowed'); + } finally { + cleanup(dir); + } +}); + +test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 5 (at limit) still dispatches', async () => { + const dir = createTempDir(); + try { + const result = JSON.parse(await extension.dispatchAsSubagent({ + family: 'progress', subcommand: 'json', cwd: dir, depth: 5, + })); + assert.equal(result.ok, true, 'depth exactly at the ceiling must still be allowed'); + } finally { + cleanup(dir); + } +}); + +test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 6 (over limit) is refused, never throws', async () => { + const result = JSON.parse(await extension.dispatchAsSubagent({ + family: 'progress', subcommand: 'json', depth: 6, + })); + assert.equal(result.ok, false); + assert.match(result.stderr, /exceeds maxDepth/); + assert.equal(result.code, null); +}); + +test('dispatchAsSubagent defaults depth to 0 when omitted (a direct, non-nested call is always allowed)', async () => { + const dir = createTempDir(); + try { + const result = JSON.parse(await extension.dispatchAsSubagent({ family: 'progress', subcommand: 'json', cwd: dir })); + assert.equal(result.ok, true); + } finally { + cleanup(dir); + } +}); + +// ── Web mode: #runSubagent availability detection is registered identically, +// but there is no dispatchAsSubagent on web (no engine dispatch at all — see +// browser.js's header comment). ───────────────────────────────────────────── +test('WEB MODE: browser.js detectSubagentSupport uses the same fail-soft availability contract', () => { + const browser = require('../vscode/browser.js'); + assert.doesNotThrow(() => { + const wiring = browser.detectSubagentSupport(mockVscodeWithSubagentSupport(true)); + assert.equal(wiring.available, true); + }); + assert.doesNotThrow(() => { + const wiring = browser.detectSubagentSupport({}); + assert.equal(wiring.available, false); + }); +}); diff --git a/vscode/browser.js b/vscode/browser.js new file mode 100644 index 000000000..ae3f9c665 --- /dev/null +++ b/vscode/browser.js @@ -0,0 +1,197 @@ +'use strict'; + +/** + * GSD extension for VS Code — WEB (browser) entry, #2103. + * + * This is the `browser` entry point (vscode/package.json `"browser": "./browser.js"`), + * loaded by VS Code Web / vscode.dev in a webworker context. It has ZERO Node + * APIs: no `require('fs')`, `require('path')`, or `require('child_process')`, + * and no Node globals (`process`, `Buffer`, `__dirname`, `__filename`). This is + * a HARD constraint, not a style preference — a Web Extension host does not + * have Node's core modules available at all; requiring one throws immediately + * at load time and breaks activation. + * + * WHY THIS FILE DOES NOT REQUIRE `./host-binding.js` OR `./extension.js` + * (a deliberate deviation from "compose the seams via bindGsdToVscode" — see + * the #2103 dispatch-crux note below): + * + * `host-binding.js`'s `bindGsdToVscode` is NOT actually web-safe once its + * transitive dependencies are checked — three of its four required engine-lib + * modules pull in Node's `fs`/`os`/`path` at module-load time (eagerly, on + * every `require()`, regardless of which code path runs): + * - gsd-core/bin/lib/state-io.cjs → requires 'node:fs' directly. + * - gsd-core/bin/lib/adapter-imperative.cjs → requires install-engine.cjs + + * capability-loader.cjs (fs/os/path). + * - gsd-core/bin/lib/model-adapter.cjs → requires model-resolver.cjs → + * config-loader.cjs (fs/os/path) + + * configuration.cjs (fs/path). + * (gsd-core/bin/lib/hook-bus.cjs alone has no requires and is genuinely + * web-safe.) Requiring `host-binding.js` here would transitively pull in + * `node:fs` and throw at web-worker load time — the opposite of "zero Node + * APIs". See host-binding.js's own header comment for the full chain. Fixing + * those engine-lib modules to be fs-free is a separate, much larger + * engine-wide refactor (config/capability loading genuinely reads files from + * disk for every OTHER host) — out of scope here; flagged rather than routed + * around silently. + * + * So this file implements its OWN minimal, independently-verified-zero-Node-API + * composition directly against `vscode.lm` — no engine-lib requires at all. + * + * DISPATCH STORY ON WEB (per the #2103 dispatch-crux design): full GSD engine + * dispatch (the gsd-tools.cjs subprocess-shim `dispatchGsdCommand` used by the + * desktop `extension.js`) is fundamentally a Node `child_process.spawnSync` + * call — there is no web-worker equivalent. On web, GSD command dispatch is + * available through VS Code's NATIVE MCP client connecting to the GSD + * companion MCP server (gsd-core/bin/lib/mcp-server.cjs, `gsd-mcp-server` + * bin entry — a separate, already-existing surface; this file does NOT + * implement an MCP client itself, it only points the user at that story). + * The chat participant and Language Model Tools registered below are + * therefore intentionally limited on web: they register (so the surface is + * discoverable and `#runSubagent`-eligible per VS Code's chat engine) but + * their handlers return an honest "configure the GSD MCP server for full + * dispatch on web" message rather than silently failing or faking success. + */ + +/** + * Tokenizes a raw chat/free-form prompt string. Kept local (not shared with + * extension.js) so this file has zero requires of any kind beyond `vscode`. + * @param {string} rawPrompt + * @returns {{family: string, subcommand: string|undefined, args: string[]}} + */ +function parseChatPrompt(rawPrompt) { + const tokens = String(rawPrompt || '').trim().split(/\s+/).filter(Boolean); + return { + family: tokens[0] || '--help', + subcommand: tokens[1], + args: tokens.slice(2), + }; +} + +/** + * The honest "web mode" message every web-surface handler returns instead of + * attempting Node-only engine dispatch. + * @param {string} family + */ +function webDispatchUnavailableMessage(family) { + return ( + `GSD web mode: full engine dispatch for "${family}" is not available in the browser ` + + '(the VS Code Web/webworker host has no Node runtime, so the gsd-tools.cjs ' + + 'subprocess dispatch used on desktop cannot run here). Configure the GSD MCP ' + + 'server (gsd-mcp-server) as a VS Code MCP server for full command dispatch on web, ' + + 'or use the desktop GSD Core extension.' + ); +} + +/** + * Registers the `@gsd` chat participant in web mode (#2103). Its handler is + * honest about the web dispatch limitation — see webDispatchUnavailableMessage. + * Exported separately so it is testable with a mock `vscode.chat`. + * @param {object} vscode + * @param {import('vscode').ExtensionContext} context + * @returns {object|null} the created participant, or null if vscode.chat is absent. + */ +function registerChatParticipant(vscode, context) { + if (!vscode || !vscode.chat || typeof vscode.chat.createChatParticipant !== 'function') { + return null; + } + const participant = vscode.chat.createChatParticipant('gsd', async (request, _chatContext, stream, _token) => { + const { family } = parseChatPrompt(request && request.prompt); + if (stream && typeof stream.markdown === 'function') { + stream.markdown(webDispatchUnavailableMessage(family)); + } + return { metadata: { command: family, mode: 'web' } }; + }); + if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(participant); + return participant; +} + +/** + * The same representative LM tool NAMES as the desktop extension (must match + * package.json's contributes.languageModelTools[].name — underscored, the + * vscode.lm.registerTool registration name — so the manifest is identical + * across both entry points), but with web-mode invoke() handlers. + */ +const LM_TOOL_NAMES = ['gsd_progress', 'gsd_workstreams', 'gsd_plan_phase']; + +/** + * Registers web-mode LM tools via vscode.lm.registerTool (#2103). Each + * invoke() returns the honest web-dispatch-unavailable message — no engine-lib + * requires, no Node APIs. + * @param {object} vscode + * @param {import('vscode').ExtensionContext} context + * @returns {number} count of tools registered (0 if vscode.lm is absent — fail-soft). + */ +function registerLanguageModelTools(vscode, context) { + if (!vscode || !vscode.lm || typeof vscode.lm.registerTool !== 'function') return 0; + let count = 0; + for (const name of LM_TOOL_NAMES) { + const impl = { + async invoke(_options, _token) { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(webDispatchUnavailableMessage(name)), + ]); + }, + }; + const disposable = vscode.lm.registerTool(name, impl); + if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(disposable); + count++; + } + return count; +} + +/** + * Detects `#runSubagent` feature availability (`chat.subagents.allowInvocationsFromSubagents`, + * VS Code 1.105+). Fail-soft: never throws. Identical detection logic to the + * desktop extension.js (duplicated, not shared, to keep this file at zero + * requires) — see extension.js's registerSubagentDispatch for the rationale + * that VS Code's chat engine itself surfaces registered participants/tools to + * `#runSubagent`, with no separate registration API. + * @param {object} vscode + * @returns {{available: boolean}} + */ +function detectSubagentSupport(vscode) { + let available = false; + try { + const cfg = vscode && vscode.workspace && typeof vscode.workspace.getConfiguration === 'function' + ? vscode.workspace.getConfiguration('chat.subagents') + : null; + available = !!(cfg && typeof cfg.get === 'function' && cfg.get('allowInvocationsFromSubagents') !== undefined); + } catch { + available = false; + } + return { available }; +} + +/** + * VS Code Web extension activation. Registers the chat participant + Language + * Model Tools in web mode. Does NOT register the `gsd.invoke` command with a + * real-dispatch handler (there is no Node dispatch on web) — the command is + * still contributed (contributes.commands in package.json is shared across + * desktop/web), so it is registered here too, but its handler returns the + * same honest web-mode message. + * @param {import('vscode').ExtensionContext} context + */ +function activate(context) { + const vscode = require('vscode'); + + const gsdCommand = vscode.commands.registerCommand('gsd.invoke', async (args) => { + const a = (args && typeof args === 'object') ? args : {}; + const family = (typeof a.family === 'string' && a.family) ? a.family : '--help'; + return JSON.stringify({ ok: false, stdout: '', stderr: webDispatchUnavailableMessage(family), code: null, timedOut: false }); + }); + context.subscriptions.push(gsdCommand); + + registerChatParticipant(vscode, context); + registerLanguageModelTools(vscode, context); + detectSubagentSupport(vscode); +} + +module.exports = { + activate, + parseChatPrompt, + webDispatchUnavailableMessage, + registerChatParticipant, + registerLanguageModelTools, + detectSubagentSupport, + LM_TOOL_NAMES, +}; diff --git a/vscode/extension.js b/vscode/extension.js index bf5298300..bfb4392de 100644 --- a/vscode/extension.js +++ b/vscode/extension.js @@ -1,20 +1,51 @@ 'use strict'; /** - * GSD extension for VS Code — ADR-1239 Phase D / #1942. + * GSD extension for VS Code — ADR-1239 Phase D / #1942, dispatch fixed + + * extension surface (chat participant, Language Model Tools, #runSubagent + * wiring) added #2103. + * + * This is the DESKTOP (Node) `main` entry — see browser.js for the Web + * Extension `browser` entry, which is intentionally a SEPARATE, much more + * minimal file (zero Node APIs; does not require this file or host-binding.js). * * VS Code is the IDE-profile reference host. This extension binds GSD's command * surface to VS Code's Command Palette + Chat participant via the imperative * adapter path. Engine entry: in-process CJS require (the extension host runs * Node). The engine seams (active model via vscode.lm, engine-owned hook bus, - * sandboxed-storage stateIO) are composed in activate() per the #1933 binding. + * sandboxed-storage stateIO) are composed in activate() via host-binding.js + * per the #1933 binding. * - * Installation: repo-local (not Marketplace-published). Open this dir in VS Code - * + press F5 (Extension Development Host) to run, or package with `vsce package`. + * Installation: Marketplace/VSIX extension (see capabilities/vscode/capability.json + * — installSurface:'none', it is never CLI-installed by bin/install.js). * - * Engine entry: the gsd.invoke handler dispatches IN-PROCESS through the GSD - * command-routing hub (createHub/dispatch). This is the same hub the companion - * MCP server + the pi extension use. + * Engine entry: dispatch is SUBPROCESS-REUSE to gsd-tools.cjs (bounded, + * no-throw — the shared `dispatchGsdCommand` in + * gsd-core/bin/lib/shell-command-projection.cjs), NOT an in-process + * command-routing hub. No fully-populated hub factory exists anywhere in + * gsd-core — every createHub() caller builds a single-family hub for its own + * narrow purpose — so calling createHub() with no args (the original #1942 + * cut) always answered UnknownCommand. This mirrors the fix already applied + * to the pi extension (pi/gsd.cjs) and the companion MCP server + * (gsd-core/bin/lib/mcp-server.cjs), which dispatch through the SAME shared + * helper (#2102 Stage 2 / #2103). + * + * Extension surface (#2103): + * - Chat participant `@gsd` (contributes.chatParticipants) — dispatches free-form + * prompts through the same dispatchGsdCommand. + * - Language Model Tools (contributes.languageModelTools) — a representative + * set of GSD skills exposed as vscode.lm tools, each dispatching through the + * same shared helper (UPGRADE 1). + * - #runSubagent wiring — VS Code 1.105+ lets the primary chat agent invoke + * registered chat participants / languageModelTools as a nested agent turn + * via `#runSubagent`, gated by the `chat.subagents.allowInvocationsFromSubagents` + * setting. There is no separate "subagent contribution" registration API + * beyond the participant + tools already registered above — VS Code's own + * chat engine surfaces them. This extension's own contribution is a + * belt-and-suspenders depth cap (dispatchAsSubagent, GSD_MAX_SUBAGENT_DEPTH) + * mirroring capabilities/vscode/capability.json's + * hostIntegration.dispatch.maxDepth:5, independent of whatever VS Code + * itself enforces natively (UPGRADE 2). */ const path = require('path'); @@ -36,38 +67,317 @@ const ENGINE_ROOT = resolveEngineRoot(__dirname); const GSD_CORE = path.join(ENGINE_ROOT, 'gsd-core'); /** - * Pure command handler — dispatches through the GSD command-routing hub. - * Exported separately from activate() so it is testable WITHOUT a VS Code host. - * @returns {Promise} JSON-stringified dispatch result. + * Pure command handler — dispatches through the SHARED subprocess-shim + * `dispatchGsdCommand` (gsd-core/bin/lib/shell-command-projection.cjs), the + * same helper the pi extension (pi/gsd.cjs) and the companion MCP server + * (gsd-core/bin/lib/mcp-server.cjs) dispatch through. + * + * Exported separately from activate() so it is testable WITHOUT a VS Code + * host. Preserves the original return CONTRACT — a JSON-stringified result + * object — but now backed by a REAL dispatch instead of an unconfigured + * `createHub()` that always answered UnknownCommand (#2103 fix). + * + * Empty/omitted args default to `--help` (a real, working, ok:true + * gsd-tools.cjs command) — NOT the `'query'`/`'help'` pairing the original + * cut used, which is not a valid gsd-tools.cjs command and always produced + * UnknownCommand (mirrors the same fix already applied to + * pi/gsd.cjs's parseGsdCommandArgs). + * + * @returns {Promise} JSON-stringified dispatch result: + * `{ok, stdout, stderr, code, timedOut}` on a normal dispatch, or + * `{ok:false, stdout:'', stderr:'GSD engine unavailable: ...', code:null, + * timedOut:false}` if the shared helper itself cannot be loaded (e.g. + * gsd-core/ missing from the tree). */ async function dispatchGsdCommand(args) { - const { createHub } = require(path.join(GSD_CORE, 'bin', 'lib', 'command-routing-hub.cjs')); - const hub = createHub(); - const res = hub.dispatch({ - family: (args && args.family) || 'query', - subcommand: (args && args.subcommand) || 'help', - args: (args && Array.isArray(args.args)) ? args.args : [], - cwd: (args && args.cwd) || process.cwd(), + const a = (args && typeof args === 'object') ? args : {}; + const family = (typeof a.family === 'string' && a.family) ? a.family : '--help'; + const subcommand = (typeof a.subcommand === 'string' && a.subcommand) ? a.subcommand : undefined; + const rest = Array.isArray(a.args) ? a.args : []; + const cwd = a.cwd || process.cwd(); + + let dispatchViaShim; + try { + ({ dispatchGsdCommand: dispatchViaShim } = require(path.join(GSD_CORE, 'bin', 'lib', 'shell-command-projection.cjs'))); + } catch (e) { + return JSON.stringify({ + ok: false, + stdout: '', + stderr: `GSD engine unavailable: ${e && e.message ? e.message : String(e)}`, + code: null, + timedOut: false, + }); + } + const result = dispatchViaShim({ family, subcommand, args: rest, cwd }); + return JSON.stringify(result); +} + +/** + * Resolves the current workspace's root directory, per-invocation (never + * cached at activate() time), so GSD commands dispatch against the user's + * actual project instead of the extension host's own `process.cwd()` (#2103 + * FIX — adversarial review: all three desktop dispatch surfaces previously + * omitted `cwd` entirely when calling dispatchGsdCommand, silently defaulting + * to the wrong directory). Falls back to `process.cwd()` only when no + * workspace folder is open (e.g. an empty window) — mirrors VS Code's own + * single-root convention of reading `workspaceFolders[0]`. + * @param {object} vscode + * @returns {string} + */ +function resolveWorkspaceCwd(vscode) { + const folders = vscode && vscode.workspace && vscode.workspace.workspaceFolders; + const first = Array.isArray(folders) ? folders[0] : undefined; + const fsPath = first && first.uri && first.uri.fsPath; + return (typeof fsPath === 'string' && fsPath) ? fsPath : process.cwd(); +} + +/** + * Tokenizes a raw chat/free-form prompt string into {family, subcommand, args}. + * Mirrors pi/gsd.cjs's parseGsdCommandArgs (simple whitespace split — no shell + * quoting support needed for a chat prompt). Empty input defaults to `--help` + * (a real, working gsd-tools.cjs command), matching dispatchGsdCommand's own + * default so the two surfaces (palette vs. chat) never diverge on "no input". + * @param {string} rawPrompt + * @returns {{family: string, subcommand: string|undefined, args: string[]}} + */ +function parseChatPrompt(rawPrompt) { + const tokens = String(rawPrompt || '').trim().split(/\s+/).filter(Boolean); + return { + family: tokens[0] || '--help', + subcommand: tokens[1], + args: tokens.slice(2), + }; +} + +/** + * Registers the `@gsd` chat participant (#2103). Its handler dispatches the + * user's free-form prompt through the SAME dispatchGsdCommand as gsd.invoke — + * one dispatch path for every command surface (palette / chat / LM tools). + * Exported separately so it is testable with a mock `vscode.chat`. + * @param {object} vscode + * @param {import('vscode').ExtensionContext} context + * @returns {object|null} the created participant, or null if vscode.chat is absent + * (older VS Code — fail-soft, never throws). + */ +function registerChatParticipant(vscode, context) { + if (!vscode || !vscode.chat || typeof vscode.chat.createChatParticipant !== 'function') { + return null; + } + const participant = vscode.chat.createChatParticipant('gsd', async (request, _chatContext, stream, _token) => { + // #2103 FIX: resolve the user's actual workspace, not the extension host's + // process.cwd() — computed per-invocation so it always reflects the + // CURRENT workspace (a chat request has no cwd field of its own; VS Code's + // ChatContext does not carry one). + const cwd = resolveWorkspaceCwd(vscode); + const { family, subcommand, args } = parseChatPrompt(request && request.prompt); + const result = JSON.parse(await dispatchGsdCommand({ family, subcommand, args, cwd })); + if (stream && typeof stream.markdown === 'function') { + stream.markdown(result.ok ? result.stdout : `GSD error: ${result.stderr || result.stdout || 'dispatch failed'}`); + } + return { metadata: { command: family } }; }); - return JSON.stringify(res); + if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(participant); + return participant; +} + +/** + * #2103 UPGRADE 1 — Language Model Tools. + * + * A representative set of GSD skills (see skills/gsd-progress, skills/gsd-workstreams, + * skills/gsd-plan-phase) exposed as vscode.lm tools, matching the + * contributes.languageModelTools manifest entries in package.json. Each tool's + * invoke() dispatches through the SAME shared dispatchGsdCommand as gsd.invoke — + * the tool name maps to a real, verified family/subcommand pair (verified by + * direct `gsd-tools.cjs [subcommand] --raw --json-errors` invocation; + * see the #2103 CORE-stage precedent for `progress json`). + * + * Kept to a small, curated set rather than all 71 shipped skills: these three + * both name a real skill AND map cleanly onto a single, safe, read-only + * gsd-tools.cjs command (the rest are multi-step agent workflows that do not + * reduce to one non-interactive CLI call, and stay slash-command-native). + */ +// `name` uses underscores (vscode.lm.registerTool's registration name — MUST +// match contributes.languageModelTools[].name in package.json exactly); the +// hyphenated `#gsd-progress`-style mention alias is package.json's separate +// `toolReferenceName` field (VS Code owns that mapping internally). +const LM_TOOLS = [ + { + name: 'gsd_progress', + // skills/gsd-progress — real, verified: `gsd-tools.cjs progress json`. + resolveCommand: () => ({ family: 'progress', subcommand: 'json', args: [] }), + }, + { + name: 'gsd_workstreams', + // skills/gsd-workstreams — real, verified: `gsd-tools.cjs workstream list`. + resolveCommand: () => ({ family: 'workstream', subcommand: 'list', args: [] }), + }, + { + name: 'gsd_plan_phase', + // skills/gsd-plan-phase — real, verified: `gsd-tools.cjs phase-plan-index `. + // (The full multi-step planning workflow stays slash-command-native; this + // tool exposes the read-only plan-index lookup the workflow itself queries first.) + resolveCommand: (input) => ({ + family: 'phase-plan-index', + subcommand: undefined, + args: [input && input.phase ? String(input.phase) : ''], + }), + }, +]; + +/** + * Builds a vscode.lm tool implementation for one LM_TOOLS entry. + * @param {{name:string, resolveCommand:(input:object)=>{family:string,subcommand:string|undefined,args:string[]}}} toolDef + * @param {object} vscode + */ +function createLanguageModelTool(toolDef, vscode) { + return { + async invoke(options, _token) { + const input = (options && options.input) || {}; + // #2103 FIX: resolve the user's actual workspace, not the extension + // host's process.cwd() — computed per-invocation. LanguageModelToolInvocationOptions + // has no cwd field of its own. + const cwd = resolveWorkspaceCwd(vscode); + const { family, subcommand, args } = toolDef.resolveCommand(input); + const result = JSON.parse(await dispatchGsdCommand({ family, subcommand, args, cwd })); + const text = result.ok ? result.stdout : `GSD error: ${result.stderr || result.stdout || 'dispatch failed'}`; + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(text)]); + }, + }; +} + +/** + * Registers the LM_TOOLS set via vscode.lm.registerTool (#2103 UPGRADE 1). + * Exported separately so it is testable with a mock `vscode.lm`. + * @param {object} vscode + * @param {import('vscode').ExtensionContext} context + * @returns {number} count of tools registered (0 if vscode.lm is absent — fail-soft). + */ +function registerLanguageModelTools(vscode, context) { + if (!vscode || !vscode.lm || typeof vscode.lm.registerTool !== 'function') return 0; + let count = 0; + for (const toolDef of LM_TOOLS) { + const disposable = vscode.lm.registerTool(toolDef.name, createLanguageModelTool(toolDef, vscode)); + if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(disposable); + count++; + } + return count; +} + +/** Mirrors capabilities/vscode/capability.json's hostIntegration.dispatch.maxDepth. */ +const GSD_MAX_SUBAGENT_DEPTH = 5; + +/** + * #2103 UPGRADE 2 — native subagent dispatch (#runSubagent). + * + * Dispatches a command as a (possibly nested) subagent turn, enforcing GSD's + * own maxDepth:5 ceiling (capabilities/vscode/capability.json) independent of + * whatever VS Code's chat engine enforces natively for `#runSubagent` / + * `chat.subagents.allowInvocationsFromSubagents` — belt-and-suspenders, never + * silently trusts the host's own depth accounting. + * @param {{family?:string, subcommand?:string, args?:string[], cwd?:string, depth?:number}} args + * @returns {Promise} same JSON-stringified contract as dispatchGsdCommand. + */ +async function dispatchAsSubagent(args) { + const a = (args && typeof args === 'object') ? args : {}; + const depth = Number.isInteger(a.depth) ? a.depth : 0; + if (depth > GSD_MAX_SUBAGENT_DEPTH) { + return JSON.stringify({ + ok: false, + stdout: '', + stderr: `GSD subagent dispatch refused: depth ${depth} exceeds maxDepth ${GSD_MAX_SUBAGENT_DEPTH}`, + code: null, + timedOut: false, + }); + } + return dispatchGsdCommand(a); +} + +/** + * Detects whether the host VS Code build exposes the `#runSubagent` feature + * surface (`chat.subagents.allowInvocationsFromSubagents`, VS Code 1.105+). + * Fail-soft: any missing/older API surface (including an Insiders-gated build + * where the setting does not exist yet) resolves `available:false` — never + * throws. There is no separate registration call: VS Code's chat engine + * surfaces the already-registered chat participant + languageModelTools to + * `#runSubagent` on its own; this function only reports/confirms availability. + * @param {object} vscode + * @returns {{available: boolean, dispatchAsSubagent: typeof dispatchAsSubagent, maxDepth: number}} + */ +function registerSubagentDispatch(vscode) { + let available = false; + try { + const cfg = vscode && vscode.workspace && typeof vscode.workspace.getConfiguration === 'function' + ? vscode.workspace.getConfiguration('chat.subagents') + : null; + available = !!(cfg && typeof cfg.get === 'function' && cfg.get('allowInvocationsFromSubagents') !== undefined); + } catch { + available = false; + } + return { available, dispatchAsSubagent, maxDepth: GSD_MAX_SUBAGENT_DEPTH }; } /** * VS Code extension activation. Composes the IDE-profile seams (per the #1933 - * reference binding) + registers the command surface. + * reference binding) + registers the full command surface: palette + * (gsd.invoke), chat participant (@gsd), Language Model Tools, and the + * #runSubagent depth-cap wiring. * @param {import('vscode').ExtensionContext} context */ function activate(context) { const vscode = require('vscode'); - // ── Command surface: palette + chat participant ────────────────────────── - const gsdCommand = vscode.commands.registerCommand('gsd.invoke', dispatchGsdCommand); + // ── Command surface: palette ────────────────────────────────────────────── + // #2103 FIX: wrap dispatchGsdCommand (rather than registering it directly as + // the handler) so a palette invocation that omits `cwd` resolves the user's + // actual workspace instead of silently defaulting to the extension host's + // own process.cwd(). An explicit `args.cwd` (e.g. from a programmatic + // vscode.commands.executeCommand('gsd.invoke', {..., cwd}) caller) still + // takes precedence. + const gsdCommand = vscode.commands.registerCommand('gsd.invoke', (args) => { + const a = (args && typeof args === 'object') ? args : {}; + const cwd = a.cwd || resolveWorkspaceCwd(vscode); + return dispatchGsdCommand({ ...a, cwd }); + }); context.subscriptions.push(gsdCommand); - // The full IDE-profile binding (active vscode.lm model, engine-owned hook bus, - // sandboxed-storage stateIO, imperative adapter) composes in activate() per - // the #1933 reference binding (tests/fixtures/vscode-host-binding.cjs). The - // command handler dispatches through the hub — the user-reachable surface. + // ── IDE-profile host binding (#1933 reference binding; desktop/Node only — + // see host-binding.js's header for why browser.js does NOT use this path). + // On a real desktop VS Code host this SUCCEEDS (#2103 fix: host-binding.js's + // guard checks vscode.lm.selectChatModels — the real API — not the + // nonexistent vscode.lm.sendRequest the pre-#2103 code assumed). Still + // wrapped fail-open so a genuinely older VS Code build (no vscode.lm at all) + // degrades to the palette command only, rather than blocking activation. + try { + const bindGsdToVscode = require('./host-binding.js'); + const hostStorage = { + read: (key) => context.globalState.get(key), + write: (key, value) => context.globalState.update(key, value), + }; + bindGsdToVscode(vscode, hostStorage); + } catch { + // fail-open — see doc comment above. + } + + // ── Chat participant (@gsd) ─────────────────────────────────────────────── + registerChatParticipant(vscode, context); + + // ── Language Model Tools (#2103 UPGRADE 1) ──────────────────────────────── + registerLanguageModelTools(vscode, context); + + // ── #runSubagent wiring (#2103 UPGRADE 2) ───────────────────────────────── + registerSubagentDispatch(vscode); } -module.exports = { activate, dispatchGsdCommand, resolveEngineRoot }; +module.exports = { + activate, + dispatchGsdCommand, + resolveEngineRoot, + resolveWorkspaceCwd, + parseChatPrompt, + registerChatParticipant, + registerLanguageModelTools, + registerSubagentDispatch, + dispatchAsSubagent, + LM_TOOLS, + GSD_MAX_SUBAGENT_DEPTH, +}; diff --git a/vscode/host-binding.js b/vscode/host-binding.js new file mode 100644 index 000000000..d22897ae1 --- /dev/null +++ b/vscode/host-binding.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * VS Code IDE host binding for GSD (ADR-1239 Phase D / #1933, shipped #2103). + * + * VS Code is the IDE-profile reference host. This module 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). There is NO + * `vscode.lm.sendRequest` — the real API is + * `const [model] = await vscode.lm.selectChatModels(selector?); + * const response = await model.sendRequest(messages, options?, token?);` + * (Context7-verified #2103). The injected `sendRequest` wrapper + * below selects a model on every call and delegates to IT. 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 + * AT THE STATE-IO SEAM ITSELF; see the DESKTOP-ONLY note below for + * why the module as a WHOLE is not safe to require from browser.js). + * - 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 `vscode/extension.js` (the Node/desktop + * `main` entry) runs in activate(). + * + * DESKTOP-ONLY (#2103 finding — do NOT require this from vscode/browser.js): + * despite the per-seam design above being conceptually host-agnostic, the CONCRETE engine-lib + * modules this file requires are NOT web-safe today — each pulls in Node's `fs`/`os`/`path` + * at module-load time (eagerly, regardless of which code path actually runs): + * - gsd-core/bin/lib/state-io.cjs → requires 'node:fs' directly. + * - gsd-core/bin/lib/adapter-imperative.cjs → requires install-engine.cjs (fs/os/path) and + * capability-loader.cjs (fs/os/path). + * - gsd-core/bin/lib/model-adapter.cjs → requires model-resolver.cjs → config-loader.cjs + * (fs/os/path) and configuration.cjs (fs/path). + * - gsd-core/bin/lib/hook-bus.cjs → no requires; this one alone is web-safe. + * In a real VS Code Web Extension host (webworker context) `require('node:fs')` does not + * resolve — activation would throw immediately. This is why `vscode/browser.js` does NOT + * require this module (or any of the engine-lib adapters) and instead implements its own + * minimal, genuinely-zero-Node-API composition directly against `vscode.lm`. See browser.js's + * header comment for the full rationale. Fixing the engine-lib modules to be fs-free is a + * separate, much larger engine-wide refactor (config-loader/capability-loader/install-engine + * all read real files from disk) — out of scope for the extension-surface stage; flagged here + * for visibility rather than silently routed around. + * + * Mock-friendly: takes `vscode` (with `vscode.lm`) + `hostStorage` ({read,write}) so it is + * behaviorally testable without a live VS Code host. + * + * @param {{ lm: { selectChatModels: (selector?: unknown) => Promise unknown }>> } }} vscode + * VS Code namespace (vscode.lm.selectChatModels — NOT vscode.lm.sendRequest, which does not exist). + * @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.selectChatModels !== 'function') { + throw new TypeError('bindGsdToVscode: vscode.lm.selectChatModels is required (active model provider — VS Code exposes no vscode.lm.sendRequest; a model is selected via selectChatModels() and ITS sendRequest is called)'); + } + 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. There is no + // `vscode.lm.sendRequest` — a model must be selected first + // (`vscode.lm.selectChatModels()`, async → LanguageModelChat[]), then THAT + // model's own `.sendRequest(messages, options?, token?)` is called. Selects + // fresh on every call (no cross-call caching) so a model becoming available/ + // unavailable between calls is always reflected; gsd-core/bin/lib/model-adapter.cjs's + // ActiveModelAdapter.sendRequest is a plain (non-async) pass-through that + // returns whatever the injected function returns, so an async injected + // function composes transparently — no adapter-side change needed (verified + // by reading model-adapter.cjs: `sendRequest(req) { return sendRequest(req); }`). + // No system-role messages — VS Code rejects them; a full extension builds + // LanguageModelChatMessages with User role only. + const model = createModelAdapter({ modelMode: 'active' }, { + async sendRequest(req) { + const models = await vscode.lm.selectChatModels(); + const [chatModel] = models || []; + if (!chatModel || typeof chatModel.sendRequest !== 'function') { + throw new Error('bindGsdToVscode: vscode.lm.selectChatModels() returned no usable model (no active model available)'); + } + return chatModel.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/vscode/package.json b/vscode/package.json index 9f131b5a1..49cacec6d 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -5,23 +5,81 @@ "version": "1.7.0-rc.5", "publisher": "opengsd", "engines": { - "vscode": "^1.90.0" + "vscode": "^1.105.0" }, "categories": [ "Other" ], - "activationPoints": { - "onCommand": [ - "gsd.invoke" - ] - }, + "activationEvents": [ + "onCommand:gsd.invoke" + ], "main": "./extension.js", + "browser": "./browser.js", "contributes": { "commands": [ { "command": "gsd.invoke", "title": "GSD: Invoke Command" } + ], + "chatParticipants": [ + { + "id": "gsd", + "name": "gsd", + "fullName": "GSD Core", + "description": "Invoke GSD orchestration commands from chat.", + "isSticky": true + } + ], + "languageModelTools": [ + { + "name": "gsd_progress", + "tags": ["gsd", "status"], + "toolReferenceName": "gsd-progress", + "displayName": "GSD Progress", + "modelDescription": "Reports GSD milestone/phase progress (percent complete, plan and summary counts) for the current project.", + "userDescription": "Check GSD project progress.", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "gsd_workstreams", + "tags": ["gsd", "status"], + "toolReferenceName": "gsd-workstreams", + "displayName": "GSD Workstreams", + "modelDescription": "Lists the GSD parallel workstreams for the current project (or reports flat/single-workstream mode).", + "userDescription": "List GSD workstreams.", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "gsd_plan_phase", + "tags": ["gsd", "plan"], + "toolReferenceName": "gsd-plan-phase", + "displayName": "GSD Plan Phase", + "modelDescription": "Looks up the plan index (plans, waves, checkpoints) for a named GSD phase. Read-only — does not create or modify a phase plan; use the /gsd-plan-phase chat workflow for full phase planning.", + "userDescription": "Look up a GSD phase's plan index.", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "phase": { + "type": "string", + "description": "The GSD phase name to look up (e.g. \"01-core\")." + } + }, + "required": ["phase"], + "additionalProperties": false + } + } ] } }