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>
149 lines
6.1 KiB
JavaScript
149 lines
6.1 KiB
JavaScript
'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);
|
|
});
|
|
});
|