Files
msd-core/tests/kilo-upgrades.test.cjs
Tom Boucher f014ec83bd feat(#2093): migrate Kilo onto EoS imperative adapter + hook-bus/model/MCP/dispatch upgrades (ADR-1239)
Fold remaining isKilo logic branches into descriptor-driven reads:
finishPermissionWriter (uninstall cleanup), skipSharedHooksInstall (hooks
copy), and a skills converter-name registry (the artifactLayout.converter
field is now load-bearing, not decorative). frontmatterDialect stays the
documented dispatch key for frontmatter (no descriptor field for it). Dead
isKilo destructure bindings removed. Byte-identical golden parity for all 16
runtimes (opencode, which shares kilo's combined-family path, verified clean).

UPGRADE 1 (hook bus): install .kilo/plugins/gsd-core.js native plugin +
extensionEvents:"kilo" + EXTENSION_EVENT_SURFACES.kilo (OpenCode-fork bus).
UPGRADE 2 (active model): populate runtimeTierDefaults.kilo + thread
modelOverride through convertClaudeToKiloFrontmatter — model no longer stripped
from agents. UPGRADE 3 (MCP): document the gsd-core MCP companion under kilo's
mcp config key. UPGRADE 4 (named dispatch): agents/*.md mode:subagent roster is
the Task-tool dispatch surface (tested); subagentToolkit stays 'undocumented'
per AC so dispatch degrades to 'degraded' by design.

Model-catalog single-source edit ripples the shared model-catalog.json hash
into all 16 golden fixtures (expected). Inline defect fixes (no-defer): stale-
bake-guard resolveAgentDir 'agent'->'agents' (was a silent no-op for opencode/
codex), hardcoded 'Removed OpenCode plugin' uninstall log -> generic, and the
connect-gsd-mcp-server.md OpenCode mcpServers->mcp doc error.

Tests: kilo-imperative-reference (adapter/axes/fail-closed/degradation/
hostBehaviors + widened isKilo source-grep across 4 modules) + kilo-upgrades
(plugin parity+load, model-override converter, agents dispatch surface, MCP
doc). Matrix + how-to + config docs updated; changeset added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:55:15 -04:00

296 lines
14 KiB
JavaScript

'use strict';
/**
* kilo capability UPGRADES — ADR-1239 Phase D / #2093 (EoS/kilo).
*
* Drives the user-reachable surface (spawned `bin/install.js` via
* `runMinimalInstall`) plus targeted unit coverage to prove the four real
* upgrades Kilo contributes as part of the imperative-adapter migration:
*
* UPGRADE 1 — native hook-bus plugin: `.kilo/plugins/gsd-core.js`, a
* byte-identical copy of `.opencode/plugins/gsd-core.js` (Kilo is an
* OpenCode fork sharing the same plugin/extension event bus).
*
* UPGRADE 2 — active-model routing: `convertClaudeToKiloFrontmatter` now
* emits a `model:` field from the resolved model override instead of
* always stripping it (mirrors the OpenCode upgrade, #2256).
*
* UPGRADE 3 — MCP companion documented + reachable: `docs/how-to/connect-gsd-mcp-server.md`
* covers Kilo's `mcp`-keyed config (not `mcpServers`), and the companion the
* doc points at (`bin/gsd-mcp-server.js`) is proven live by spawning it and
* performing a real initialize + tools/list handshake (AC4: "test: connect
* and list tools") — mirrors tests/gsd-mcp-server-bin.test.cjs exactly.
*
* UPGRADE 4 — named subagent dispatch: GSD's specialist agents install as
* `<configDir>/agents/gsd-*.md` with `mode: subagent` + a `permission:`
* block — the slug Kilo's Task tool dispatches by.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { runMinimalInstall } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const { listAgentFiles } = require('./helpers/agent-roster.cjs');
const { convertClaudeToKiloFrontmatter } = require('../bin/install.js');
const { PROTOCOL_VERSION } = require('../gsd-core/bin/lib/mcp-server.cjs');
const MCP_SERVER_BIN = path.join(__dirname, '..', 'bin', 'gsd-mcp-server.js');
const KILO_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'kilo', 'capability.json'), 'utf8'),
);
const ADAPTER_SRC = path.join(__dirname, '..', '.kilo', 'plugins', 'gsd-core.js');
const OPENCODE_ADAPTER_SRC = path.join(__dirname, '..', '.opencode', 'plugins', 'gsd-core.js');
/** Extract the YAML frontmatter block (between the first pair of `---` lines), or null. */
function parseFrontmatter(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return m ? m[1] : null;
}
// ---------------------------------------------------------------------------
// UPGRADE 1: native hook-bus plugin (.kilo/plugins/gsd-core.js)
// ---------------------------------------------------------------------------
for (const scope of ['global', 'local']) {
test(`kilo --${scope}: installs .../plugins/gsd-core.js byte-identical to the repo source (UPGRADE 1)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'kilo', scope });
t.after(() => cleanup(root));
const installedPluginPath = path.join(configDir, 'plugins', 'gsd-core.js');
assert.ok(fs.existsSync(installedPluginPath), `${installedPluginPath} must exist`);
const installed = fs.readFileSync(installedPluginPath);
const source = fs.readFileSync(ADAPTER_SRC);
assert.ok(installed.equals(source), 'installed plugin must byte-equal the repo .kilo/plugins/gsd-core.js source');
});
}
// Faithful emulation of a plugin loader: `getServerPlugin` accepts a bare
// function OR an object with a `.server` function (mirrors the OpenCode
// loader contract Kilo forked, tests/opencode-plugin-adapter.test.cjs).
function getServerPlugin(entry) {
if (typeof entry === 'function') return entry;
if (entry && typeof entry === 'object' && typeof entry.server === 'function') return entry.server;
return null;
}
function loaderExtract(mod) {
const servers = [];
for (const entry of Object.values(mod)) {
const s = getServerPlugin(entry);
if (!s) throw new TypeError('Plugin export is not a function');
servers.push(s);
}
return servers;
}
test('.kilo/plugins/gsd-core.js loads as raw CommonJS and exposes id "gsd-core" + server._internals (UPGRADE 1)', () => {
const mod = require(ADAPTER_SRC);
assert.equal(mod.id, 'gsd-core');
// NON-ENUMERABLE so it never lands in Object.values (would throw in the loader loop).
assert.ok(!Object.keys(mod).includes('id'), 'id must be non-enumerable');
const servers = loaderExtract(mod); // must not throw
assert.equal(servers.length, 1);
assert.equal(typeof servers[0], 'function');
assert.equal(typeof mod.server._internals, 'object');
assert.ok(mod.server._internals, 'server._internals must be present');
});
// DEFECT.GENERATIVE-FIX parity guard: .kilo/plugins/gsd-core.js is a deliberate
// byte-copy of .opencode/plugins/gsd-core.js (Kilo is an OpenCode fork sharing
// the same plugin/extension event bus, see the UPGRADE 1 doc comment above).
// Nothing enforces that copy relationship — a future edit to either file that
// forgets its twin would silently drift the two runtimes apart. This fails the
// instant that happens.
test('.kilo/plugins/gsd-core.js stays byte-identical to .opencode/plugins/gsd-core.js (Kilo is an OpenCode fork; parity guard, UPGRADE 1)', () => {
const kilo = fs.readFileSync(ADAPTER_SRC, 'utf8');
const opencode = fs.readFileSync(OPENCODE_ADAPTER_SRC, 'utf8');
assert.equal(
kilo,
opencode,
'.kilo/plugins/gsd-core.js and .opencode/plugins/gsd-core.js must stay byte-identical — ' +
'Kilo is an OpenCode fork and intentionally reuses the same plugin verbatim; if you edited ' +
'one, mirror the change into the other (or this guard will keep failing).',
);
});
// ---------------------------------------------------------------------------
// UPGRADE 2: active-model routing (convertClaudeToKiloFrontmatter)
// ---------------------------------------------------------------------------
const SAMPLE_AGENT = `---
name: gsd-executor
description: Executes GSD plans with atomic commits
tools: Read, Write, Edit, Bash, Grep, Glob
color: yellow
---
<role>
You are a GSD plan executor.
</role>`;
const SAMPLE_COMMAND = `---
name: gsd-execute-phase
description: Execute all plans in a phase
allowed-tools:
- Read
- Write
- Bash
---
Execute the phase plan.`;
test('UPGRADE 2: convertClaudeToKiloFrontmatter emits model: when isAgent + modelOverride is provided', () => {
const result = convertClaudeToKiloFrontmatter(SAMPLE_AGENT, { isAgent: true, modelOverride: 'anthropic/claude-sonnet-5' });
const frontmatter = result.split('---')[1];
assert.match(frontmatter, /^model: anthropic\/claude-sonnet-5$/m, 'model: field must carry the resolved override');
});
test('UPGRADE 2: convertClaudeToKiloFrontmatter emits NO model: when isAgent + modelOverride is null', () => {
const result = convertClaudeToKiloFrontmatter(SAMPLE_AGENT, { isAgent: true, modelOverride: null });
const frontmatter = result.split('---')[1];
assert.ok(!/^model:/m.test(frontmatter), 'model: field must be absent when no override is resolved');
});
test('UPGRADE 2: convertClaudeToKiloFrontmatter emits NO model: for commands, even with a modelOverride (commands strip)', () => {
const result = convertClaudeToKiloFrontmatter(SAMPLE_COMMAND, { isAgent: false, modelOverride: 'x' });
const frontmatter = result.split('---')[1];
assert.ok(!/^model:/m.test(frontmatter), 'commands never carry a model: field, regardless of modelOverride');
});
// Note: a bare runMinimalInstall does NOT configure a runtime model_overrides/
// model_profile_overrides config, so installed agents will NOT carry a model:
// line from a plain install — that is expected (readGsdEffectiveModelOverrides
// / readGsdRuntimeProfileResolver resolve to nothing) and is NOT a regression.
// The unit tests above are the correct surface for proving U2's "stop
// stripping, emit requested model" behavior change.
// ---------------------------------------------------------------------------
// UPGRADE 3: MCP companion documented
// ---------------------------------------------------------------------------
// allow-test-rule: docs-parity (#2093) — docs/how-to/connect-gsd-mcp-server.md
// must document Kilo's real mcp-keyed config (not mcpServers); the doc prose IS
// the canonical statement of that fact and there is no runtime API to enumerate
// it, so reading the file and asserting on its text is the only parity check
// available.
test('UPGRADE 3: docs/how-to/connect-gsd-mcp-server.md documents Kilo\'s mcp-keyed config', () => {
const docPath = path.join(__dirname, '..', 'docs', 'how-to', 'connect-gsd-mcp-server.md');
const doc = fs.readFileSync(docPath, 'utf8');
assert.match(doc, /Kilo/, 'doc must mention Kilo');
assert.match(doc, /`mcp` key \(\*\*not\*\* `mcpServers`\)/,
'doc must call out the mcp (not mcpServers) key for Kilo/OpenCode');
assert.ok(doc.includes('"mcp"'), 'doc must show the literal "mcp" config key');
assert.ok(doc.includes('opencode.jsonc') || doc.includes('opencode.json'),
'doc must name Kilo\'s native config file (shared with OpenCode\'s schema)');
});
// AC4 ("test: connect and list tools"): prove the companion Kilo's `mcp` config
// points at (bin/gsd-mcp-server.js) is actually reachable, not just documented.
// Mirrors tests/gsd-mcp-server-bin.test.cjs's spawn/handshake mechanism exactly
// (same shim, same line-delimited JSON-RPC over stdio, same clean-exit-on-EOF
// contract) rather than reinventing the protocol handshake.
test('UPGRADE 3: gsd-mcp-server companion is reachable — spawn, initialize, tools/list over stdio (AC4)', () => {
const stdin = [
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }),
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
].join('\n') + '\n';
const res = spawnSync(process.execPath, [MCP_SERVER_BIN], {
input: stdin,
encoding: 'utf-8',
timeout: 15000,
env: { ...process.env, GSD_TEST_MODE: '1' },
});
assert.strictEqual(res.status, 0, `gsd-mcp-server must exit cleanly on stdin EOF; stderr: ${res.stderr}`);
const lines = res.stdout.trim().split('\n').map((l) => JSON.parse(l));
assert.strictEqual(lines.length, 2, 'one response per request');
assert.strictEqual(lines[0].id, 1);
assert.strictEqual(lines[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize handshake succeeds');
const toolNames = lines[1].result.tools.map((t) => t.name).sort();
assert.deepStrictEqual(
toolNames,
['gsd_invoke_command', 'gsd_read_state', 'gsd_write_state'],
'the companion Kilo\'s mcp config connects to advertises the real GSD tool surface',
);
});
// ---------------------------------------------------------------------------
// UPGRADE 4: named subagent dispatch (agents/*.md, mode: subagent)
// ---------------------------------------------------------------------------
const KILO_AGENT_PERMISSION_KEYS = [
'read', 'edit', 'bash', 'grep', 'glob', 'task',
'webfetch', 'websearch', 'skill', 'question', 'todowrite', 'list', 'codesearch', 'lsp',
];
for (const scope of ['global', 'local']) {
test(`kilo --${scope}: native agents/*.md subagent projection with mode: subagent (UPGRADE 4)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'kilo', scope });
t.after(() => cleanup(root));
const agentsDir = path.join(configDir, 'agents');
assert.ok(fs.existsSync(agentsDir), `${agentsDir} must exist`);
const expectedNames = listAgentFiles();
assert.equal(expectedNames.length, 34,
'sanity: shipped GSD agent roster is 34 files — update this boundary if the roster changes');
const installedFiles = fs.readdirSync(agentsDir)
.filter((f) => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(installedFiles.length >= expectedNames.length,
`expected at least ${expectedNames.length} installed agents under ${agentsDir}, got ${installedFiles.length}`);
for (const name of expectedNames) {
assert.ok(installedFiles.includes(`${name}.md`), `${name}.md must be installed under ${agentsDir}`);
}
for (const known of ['gsd-code-reviewer', 'gsd-planner', 'gsd-executor']) {
const filePath = path.join(agentsDir, `${known}.md`);
assert.ok(fs.existsSync(filePath), `${filePath} must exist`);
const content = fs.readFileSync(filePath, 'utf8');
const fm = parseFrontmatter(content);
assert.ok(fm, `${known}.md must have YAML frontmatter`);
assert.match(fm, /^name:\s*\S+/m, `${known}.md frontmatter must declare name:`);
assert.match(fm, /^mode:\s*subagent\s*$/m,
`${known}.md frontmatter must declare mode: subagent (the slug Kilo's Task tool dispatches by)`);
assert.match(fm, /^permission:\s*$/m, `${known}.md frontmatter must declare a permission: block`);
for (const key of KILO_AGENT_PERMISSION_KEYS) {
assert.match(fm, new RegExp(`^\\s+${key}:\\s*(allow|deny)\\s*$`, 'm'),
`${known}.md permission: block must declare ${key}: allow|deny`);
}
// Branding-residue checks are scoped to the FRONTMATTER — the part the
// opencode/kilo converter fully rewrites into Kilo-native form. The agent
// BODY legitimately retains Claude-Code source references byte-identical to
// opencode's installed agents (verified): the shared runtime-launcher shell
// preamble's git-root `.claude/` fallback
// (`${RUNTIME_DIR:-$(git rev-parse --show-toplevel)/.claude/…}`) and prose
// product-name mentions (e.g. "…inside a Claude Code worktree…"). These are
// family-wide launcher/prose artifacts, not kilo conversion defects — the
// opencode/kilo family, unlike qwen's aggressive converter, does not rewrite
// body prose.
assert.ok(!fm.includes('CLAUDE.md'), `${known}.md frontmatter must not contain residual "CLAUDE.md"`);
assert.ok(!fm.includes('Claude Code'), `${known}.md frontmatter must not contain residual "Claude Code"`);
assert.ok(!fm.includes('.claude/'), `${known}.md frontmatter must not contain residual ".claude/"`);
}
});
}
// -- boundary/negative: hooksSurface:'none' + subagentToolkit stays undocumented
test('capabilities/kilo/capability.json extendedHookEvents is exactly [] (hooksSurface: "none") and dispatch.subagentToolkit stays "undocumented"', () => {
assert.deepEqual(KILO_CAP.runtime.extendedHookEvents, []);
assert.equal(KILO_CAP.runtime.hooksSurface, 'none');
assert.equal(KILO_CAP.runtime.hostIntegration.dispatch.subagentToolkit, 'undocumented');
});