feat(#2103): drive VS Code through the Embeddable Orchestration System (ADR-1239)
VS Code is a net-new EoS runtime that — unlike every prior migration — is NOT CLI-installed (Marketplace/VSIX extension). It has zero runtime==='vscode' branches in bin/install.js and stays that way (regression-guarded); it is driven entirely through the negotiated imperative Host-Integration adapter. Registry + validator (the hard part): - capabilities/vscode/capability.json (role:runtime): full hostIntegration block (imperative / palette / active vscode.lm model / engine hook bus / sandboxed-storage / mcp transport / sandboxed-web runtime; dispatch nested, maxDepth 5 per VS Code's documented subagent depth). - capability-validator.cjs extended so a role:runtime capability can legitimately declare "extension-distributed, no config directory": new configHome.kind:'none' + installSurface:'none' (+ GATE-A pairing + the parity maps), with localConfigDir and configHome.name made conditional on kind!=='none'. All 18 runtimes still validate; getDirName returns a distinct sentinel (not '.claude') for a no-config runtime. - The add-a-registry-runtime tax: NON_INSTALLABLE_RUNTIMES exemption in the runtime-flags drift guard, vscode added to global-config-home SPECIAL_CASED, EXPECTED_PROFILES.vscode='ide', and the config-adapter/derivation/pin-count guards updated. No golden-install fixture, model-catalog, or CONFIGURATION rows (vscode never enters allRuntimes). Dispatch + extension surface: - Fixed vscode/extension.js's createHub()-no-args bug (every dispatch was UnknownCommand, masked by a vacuous reachability test) — now reuses the shared dispatchGsdCommand subprocess-shim (Node/desktop); the reachability test is tightened to assert real dispatch. - Promoted the #1933 host binding to a shipped vscode/host-binding.js; activate() now composes the model/hookBus/stateIO seams through it. Corrected the model seam to VS Code's real API (vscode.lm.selectChatModels() -> model.sendRequest(); vscode.lm.sendRequest does not exist) so the binding actually composes on real desktop VS Code instead of throwing. - New vscode/browser.js Web Extension entry with ZERO Node APIs (the engine's config/capability loading is Node-bound, so the web entry registers the surface and directs full dispatch to the native MCP server — honestly documented). - UPGRADE 1: GSD skills as native Language Model Tools (contributes.languageModelTools + vscode.lm.registerTool), invoke() dispatching through the hub. - UPGRADE 2: native subagent dispatch wired onto #runSubagent / chat.subagents.allowInvocationsFromSubagents (fail-soft on API availability, maxDepth 5 enforced). - vscode/package.json: browser entry, engines.vscode ^1.105, chatParticipants + languageModelTools contributions; fixed a stale activationPoints->activationEvents manifest key. Added "vscode" to the package files array. Docs (## vscode matrix section) + changeset (Added). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/plucky-wasps-sprint.md
Normal file
5
.changeset/plucky-wasps-sprint.md
Normal file
@@ -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)
|
||||
51
capabilities/vscode/capability.json
Normal file
51
capabilities/vscode/capability.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": []
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"GEMINI.md",
|
||||
"hooks",
|
||||
"scripts",
|
||||
"pi"
|
||||
"pi",
|
||||
"vscode"
|
||||
],
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
||||
@@ -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<string, { runtime: Record<string, unknown> | 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<string> = 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<ConfigInstallSurface> = Object.freeze([
|
||||
'cline-rules',
|
||||
'cursor-hooks-json',
|
||||
'profile-marker-only',
|
||||
'none',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, { runtime?: { localConfigDir?: string } } | undefined>;
|
||||
runtimes: Record<string, { runtime?: { localConfigDir?: string; configHome?: { kind?: string } } } | undefined>;
|
||||
};
|
||||
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<Record<string, string>> = {
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
71
tests/fixtures/vscode-host-binding.cjs
vendored
71
tests/fixtures/vscode-host-binding.cjs
vendored
@@ -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');
|
||||
|
||||
@@ -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<Runtime> 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 --<rt> 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)', () => {
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 --<rt> 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 --<rt>
|
||||
// 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');
|
||||
});
|
||||
|
||||
128
tests/vscode-browser-no-node-api.test.cjs
Normal file
128
tests/vscode-browser-no-node-api.test.cjs
Normal file
@@ -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];
|
||||
}
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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])}`);
|
||||
}
|
||||
});
|
||||
|
||||
155
tests/vscode-lm-tools.test.cjs
Normal file
155
tests/vscode-lm-tools.test.cjs
Normal file
@@ -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/);
|
||||
});
|
||||
148
tests/vscode-subagent-dispatch.test.cjs
Normal file
148
tests/vscode-subagent-dispatch.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
197
vscode/browser.js
Normal file
197
vscode/browser.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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<string>} 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<string>} 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 <family> [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 <phase>`.
|
||||
// (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<string>} 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,
|
||||
};
|
||||
|
||||
113
vscode/host-binding.js
Normal file
113
vscode/host-binding.js
Normal file
@@ -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<Array<{ sendRequest: (messages: unknown, options?: unknown, token?: unknown) => 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 });
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user