Files
msd-core/tests/qwen-upgrades.test.cjs
Tom Boucher c6ce110efa feat(#2092): migrate Qwen Code onto EoS imperative adapter + native subagents + SubagentStart (ADR-1239)
Fold all runtime==='qwen'/isQwen logic branches (skill-priority frontmatter,
branding/path rewrites, legacy commands/gsd cleanup, hyphen-namespace
normalization, RUNTIME_CONTENT_DISPATCH, hooks-surface label) into
descriptor-driven runtime.hostBehaviors on capabilities/qwen/capability.json,
read via _hostBehaviors(). Shared claude/qwen/hermes legacy-migration branches
in install-engine.cts folded to descriptor flags (claude+hermes descriptors
updated; FALLBACK_HOST_BEHAVIORS.claude floored). Byte-identical golden parity
for qwen/hermes/claude(global+local).

UPGRADE 1: native .qwen/agents/*.md subagent projection — new agents
artifact-layout kind + convertClaudeAgentToQwenAgent converter (name +
description + tools YAML block list; color/model dropped). qwen routed onto
the descriptor-driven agents path (_DESCRIPTOR_AGENTS_RUNTIMES).
UPGRADE 2: SubagentStart hook wired into extendedHookEvents + the
descriptor-gated hook-writer loop (activates only for qwen).

Tests: qwen-imperative-reference (adapter/axes/fail-closed/hostBehaviors +
no runtime==='qwen' source-grep across 4 files) + qwen-upgrades (agents file
validity + SubagentStart mirrors SubagentStop, descriptor-gated). Docs matrix
+ how-to updated; changeset added.

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

153 lines
7.3 KiB
JavaScript

'use strict';
/**
* qwen capability UPGRADES — ADR-1239 Phase D / #2092 (EoS/qwen).
*
* Drives the user-reachable surface (spawned `bin/install.js` via
* `runMinimalInstall`) to prove the two real upgrades qwen contributes beyond
* the hermes migration (#2091):
*
* UPGRADE 1 — native `agents` artifact-layout kind: GSD specialist agents
* project into `<configDir>/agents/gsd-*.md` as native Qwen subagents
* (`name:`/`description:`/`tools:` YAML-block frontmatter — Qwen's own
* subagent schema, converted by `convertClaudeAgentToQwenAgent`).
*
* UPGRADE 2 — `SubagentStart` hook: wired symmetrically with the existing
* `SubagentStop` hook (same command + timeout), and proven to be
* descriptor-gated — a runtime whose `extendedHookEvents` omits
* `SubagentStart` (e.g. claude) must NOT get the hook, even though it does
* get `SubagentStop`.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runMinimalInstall } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const { listAgentFiles } = require('./helpers/agent-roster.cjs');
const QWEN_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'qwen', 'capability.json'), 'utf8'),
);
/** 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 agents artifact-layout kind ---------------------------
for (const scope of ['global', 'local']) {
test(`qwen --${scope}: native .qwen/agents/*.md subagent projection (UPGRADE 1)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'qwen', scope });
t.after(() => cleanup(root));
const agentsDir = path.join(configDir, 'agents');
assert.ok(fs.existsSync(agentsDir), `${agentsDir} must exist`);
const expectedNames = listAgentFiles(); // dynamically derived source roster
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'));
// Boundary-safe: at least the full expected roster, AND every expected
// name present by NAME (not just count) — a mis-copy that drops one
// agent while adding a stray file would still satisfy a bare >= count.
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}`);
}
// A couple of named agents, explicitly, spanning both source frontmatter
// styles (single-line `tools:` vs YAML block `tools:`) to exercise
// parseFrontmatterTools' dual-format tolerance end to end.
for (const known of ['gsd-code-reviewer', 'gsd-security-auditor', 'gsd-nyquist-auditor']) {
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 — resolvable by Qwen's subagent loader`);
assert.match(fm, /^name:\s*\S+/m, `${known}.md frontmatter must declare name:`);
assert.match(fm, /^description:\s*\S/m, `${known}.md frontmatter must declare description:`);
// tools:, when present, must be a YAML block list (Qwen's documented
// schema — https://qwenlm.github.io/qwen-code-docs — not Claude's
// single-line comma-separated string.
if (/^tools:/m.test(fm)) {
assert.match(fm, /^tools:\r?\n(?: {2}- .+\r?\n?)+/m,
`${known}.md tools: must be emitted as a YAML block list`);
}
// color: is a documented Claude-Code-compatibility field in Qwen's
// native subagent schema — it must be preserved, not dropped.
assert.match(fm, /^color:\s*\S+/m, `${known}.md frontmatter must carry a preserved color: field`);
// No branding residue from the Claude Code source.
assert.ok(!content.includes('CLAUDE.md'), `${known}.md must not contain residual "CLAUDE.md"`);
assert.ok(!content.includes('Claude Code'), `${known}.md must not contain residual "Claude Code"`);
assert.ok(!content.includes('.claude/'), `${known}.md must not contain residual ".claude/"`);
}
});
}
// -- UPGRADE 2: SubagentStart hook fires symmetrically with SubagentStop ----
test('qwen --global: settings.json SubagentStart mirrors SubagentStop (UPGRADE 2)', (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'qwen', scope: 'global' });
t.after(() => cleanup(root));
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const subagentStart = settings.hooks && settings.hooks.SubagentStart;
const subagentStop = settings.hooks && settings.hooks.SubagentStop;
assert.ok(Array.isArray(subagentStart) && subagentStart.length > 0,
'settings.hooks.SubagentStart must exist and be non-empty');
assert.ok(Array.isArray(subagentStop) && subagentStop.length > 0,
'settings.hooks.SubagentStop must exist and be non-empty');
const startEntry = subagentStart[0].hooks[0];
const stopEntry = subagentStop[0].hooks[0];
assert.equal(startEntry.command, stopEntry.command, 'SubagentStart must wire the same command as SubagentStop');
assert.equal(startEntry.timeout, 10);
assert.equal(stopEntry.timeout, 10);
});
test('a non-qwen runtime that does not declare SubagentStart does NOT get a SubagentStart hook (descriptor-gated, not global)', (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' });
t.after(() => cleanup(root));
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// claude DOES get SubagentStop (sanity — proves hooks are wired at all for
// this runtime) but must NOT get SubagentStart — claude's
// extendedHookEvents omits it, proving the loop extension is
// descriptor-gated, not a global default applied to every runtime.
assert.ok(
settings.hooks && Array.isArray(settings.hooks.SubagentStop) && settings.hooks.SubagentStop.length > 0,
'claude must have SubagentStop wired (sanity — proves hooks ARE configured)',
);
assert.ok(
!settings.hooks || settings.hooks.SubagentStart === undefined,
"claude must NOT have SubagentStart wired — it is not in claude's extendedHookEvents",
);
});
// -- boundary/negative: extendedHookEvents is exactly the 4 documented events
test('capabilities/qwen/capability.json extendedHookEvents contains exactly the 4 documented events', () => {
const events = QWEN_CAP.runtime.extendedHookEvents;
assert.deepEqual(events, ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']);
assert.equal(events.length, 4);
});