Merge branch 'next' into fix/2117-audit-milestone-not-validated
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user