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>
This commit is contained in:
@@ -4001,9 +4001,9 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
|
||||
// ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ─────────────────
|
||||
|
||||
describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => {
|
||||
test('VALID_CONVERTER_NAMES has exactly 25 entries (16 command/skill/workflow + 9 agent converters)', () => {
|
||||
test('VALID_CONVERTER_NAMES has exactly 26 entries (16 command/skill/workflow + 10 agent converters)', () => {
|
||||
assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set');
|
||||
assert.strictEqual(VALID_CONVERTER_NAMES.size, 25, 'VALID_CONVERTER_NAMES must have exactly 25 entries, got: ' + VALID_CONVERTER_NAMES.size);
|
||||
assert.strictEqual(VALID_CONVERTER_NAMES.size, 26, 'VALID_CONVERTER_NAMES must have exactly 26 entries, got: ' + VALID_CONVERTER_NAMES.size);
|
||||
});
|
||||
|
||||
test('VALID_CONVERTER_NAMES contains all expected converter names', () => {
|
||||
@@ -4035,6 +4035,8 @@ describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => {
|
||||
'convertClaudeAgentToCodebuddyAgent',
|
||||
'convertClaudeAgentToClineAgent',
|
||||
'convertClaudeAgentToCodexAgent',
|
||||
// ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection.
|
||||
'convertClaudeAgentToQwenAgent',
|
||||
];
|
||||
for (const name of expected) {
|
||||
assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"');
|
||||
@@ -4424,10 +4426,11 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT
|
||||
}
|
||||
});
|
||||
|
||||
test('VALID_EXTENDED_HOOK_EVENTS covers all 7 known extended events', () => {
|
||||
test('VALID_EXTENDED_HOOK_EVENTS covers all 8 known extended events', () => {
|
||||
assert.ok(VALID_EXTENDED_HOOK_EVENTS instanceof Set, 'Must be a Set');
|
||||
assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 7, 'Must cover 7 extended hook events');
|
||||
for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 8, 'Must cover 8 extended hook events');
|
||||
// SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today).
|
||||
for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']) {
|
||||
assert.ok(VALID_EXTENDED_HOOK_EVENTS.has(ev), 'Must include event "' + ev + '"');
|
||||
}
|
||||
});
|
||||
|
||||
68
tests/fixtures/golden-install-parity/qwen.json
vendored
68
tests/fixtures/golden-install-parity/qwen.json
vendored
@@ -1,40 +1,40 @@
|
||||
{
|
||||
".gsd-profile": "0e716a5fef4e6dc1",
|
||||
".gsd/defaults.json": "560664b045e645cb",
|
||||
"agents/gsd-advisor-researcher.md": "f1c4a47d3d8cea72",
|
||||
"agents/gsd-ai-researcher.md": "08847faf5494ff08",
|
||||
"agents/gsd-assumptions-analyzer.md": "f11a80795163bb5d",
|
||||
"agents/gsd-code-fixer.md": "5660b5d7a912dbac",
|
||||
"agents/gsd-code-reviewer.md": "f70ee630a166fec8",
|
||||
"agents/gsd-codebase-mapper.md": "b2885ffa8bb75d47",
|
||||
"agents/gsd-debug-session-manager.md": "0b23e9c20c77f55c",
|
||||
"agents/gsd-debugger.md": "7411b2f67a801c3e",
|
||||
"agents/gsd-doc-classifier.md": "29b563a146c9d22c",
|
||||
"agents/gsd-doc-synthesizer.md": "652beb928e93fa1d",
|
||||
"agents/gsd-doc-verifier.md": "3767850df78f4a36",
|
||||
"agents/gsd-doc-writer.md": "10962b6bd4212379",
|
||||
"agents/gsd-domain-researcher.md": "412cdbb05ba252ea",
|
||||
"agents/gsd-eval-auditor.md": "2d121dbec93fd4ce",
|
||||
"agents/gsd-eval-planner.md": "03448fc9c5774b56",
|
||||
"agents/gsd-executor.md": "0735dcf053be7c76",
|
||||
"agents/gsd-framework-selector.md": "ea9981d65d6b3429",
|
||||
"agents/gsd-integration-checker.md": "33fd537b862eb944",
|
||||
"agents/gsd-intel-updater.md": "699dacdc0a1f2478",
|
||||
"agents/gsd-mempalace-curator.md": "399e90e473b33814",
|
||||
"agents/gsd-nyquist-auditor.md": "7fb0ecf639c1cdfe",
|
||||
"agents/gsd-pattern-mapper.md": "9ab071c3ffc1cf46",
|
||||
"agents/gsd-phase-researcher.md": "ecb365e5f62aaafc",
|
||||
"agents/gsd-plan-checker.md": "ca47fdf6d4a36849",
|
||||
"agents/gsd-planner.md": "7cb18e515bab3c05",
|
||||
"agents/gsd-project-researcher.md": "d38509cab69224d9",
|
||||
"agents/gsd-research-synthesizer.md": "6d64fce8b06b7a62",
|
||||
"agents/gsd-roadmapper.md": "604b25c6687811f4",
|
||||
"agents/gsd-security-auditor.md": "f85447e5b5300feb",
|
||||
"agents/gsd-ui-auditor.md": "cc46d104cbc49079",
|
||||
"agents/gsd-ui-checker.md": "702b224dede2618f",
|
||||
"agents/gsd-ui-researcher.md": "3cab4101dd0a3e1d",
|
||||
"agents/gsd-user-profiler.md": "ca3bf75581f211a0",
|
||||
"agents/gsd-verifier.md": "45c450b6d7cc5f0d",
|
||||
"agents/gsd-advisor-researcher.md": "692fbf5ed5e233d5",
|
||||
"agents/gsd-ai-researcher.md": "f32dc6c6ed7e89fd",
|
||||
"agents/gsd-assumptions-analyzer.md": "6a2e38d685ba0fce",
|
||||
"agents/gsd-code-fixer.md": "3ed1a27ddc372ef8",
|
||||
"agents/gsd-code-reviewer.md": "27eeeee6cb600e9b",
|
||||
"agents/gsd-codebase-mapper.md": "032ff8ac55466a74",
|
||||
"agents/gsd-debug-session-manager.md": "9f64af6513b8ec9e",
|
||||
"agents/gsd-debugger.md": "d0a1e6a1b1cfd9e6",
|
||||
"agents/gsd-doc-classifier.md": "bdf3d54082424e76",
|
||||
"agents/gsd-doc-synthesizer.md": "96c383b74a60fbbe",
|
||||
"agents/gsd-doc-verifier.md": "da9ab6f3ec27778a",
|
||||
"agents/gsd-doc-writer.md": "7b247b42f674c9ba",
|
||||
"agents/gsd-domain-researcher.md": "bd054bb27beed2a7",
|
||||
"agents/gsd-eval-auditor.md": "57cc7458ab5de6b7",
|
||||
"agents/gsd-eval-planner.md": "01b665728dde4ccf",
|
||||
"agents/gsd-executor.md": "b50d53e45df6431c",
|
||||
"agents/gsd-framework-selector.md": "82ba6abea84226b7",
|
||||
"agents/gsd-integration-checker.md": "90835dbc7dfa1691",
|
||||
"agents/gsd-intel-updater.md": "3cc4f6ddd04676ec",
|
||||
"agents/gsd-mempalace-curator.md": "7a81acfbacc8bbd6",
|
||||
"agents/gsd-nyquist-auditor.md": "2bf7126001ba3e70",
|
||||
"agents/gsd-pattern-mapper.md": "83c66c7722e8b165",
|
||||
"agents/gsd-phase-researcher.md": "284e55a86ae46d7f",
|
||||
"agents/gsd-plan-checker.md": "c8a8fcc8ed38eff0",
|
||||
"agents/gsd-planner.md": "a14e9e4981519580",
|
||||
"agents/gsd-project-researcher.md": "b5baac64a15c85e2",
|
||||
"agents/gsd-research-synthesizer.md": "6cd9b501dc97bd50",
|
||||
"agents/gsd-roadmapper.md": "c357a77ab919e9e5",
|
||||
"agents/gsd-security-auditor.md": "d6d8f82501f10b92",
|
||||
"agents/gsd-ui-auditor.md": "47937e784c9ae541",
|
||||
"agents/gsd-ui-checker.md": "7d708c53a106f748",
|
||||
"agents/gsd-ui-researcher.md": "e3768304c1c77753",
|
||||
"agents/gsd-user-profiler.md": "13388a80dc302a66",
|
||||
"agents/gsd-verifier.md": "113d8dcff860b595",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2",
|
||||
|
||||
127
tests/qwen-imperative-reference.test.cjs
Normal file
127
tests/qwen-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,127 @@
|
||||
// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'qwen'` string-equality branch remains in bin/install.js, src/install-engine.cts, src/runtime-artifact-conversion.cts, and src/runtime-hooks-surface.cts — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2092)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* qwen imperative reference host — ADR-1239 Phase D / #2092 (EoS/qwen).
|
||||
*
|
||||
* Proves qwen is driven through the PUBLIC Host-Integration Interface (the
|
||||
* imperative adapter), that its negotiated axes classify + negotiate correctly,
|
||||
* that negotiation fails CLOSED on a corrupted descriptor, and that the
|
||||
* migration retired the hardcoded `runtime === 'qwen'` branches across the
|
||||
* install engine, artifact conversion, and hooks-surface modules (folded into
|
||||
* descriptor-driven `runtime.hostBehaviors`).
|
||||
*
|
||||
* Unlike hermes (#2091), qwen has NO `extensionEvents` dialect of its own — it
|
||||
* still uses the borrowed `hookEvents: "claude"` 6-event surface plus
|
||||
* `extendedHookEvents` for the SubagentStop/Stop/PreCompact/SubagentStart
|
||||
* lifecycle events, so this file does not assert an extensionEvents dialect.
|
||||
* Instead qwen contributes two real upgrades hermes lacked: a native `agents`
|
||||
* artifact-layout kind (`.qwen/agents/*.md` subagent projection) and a
|
||||
* `SubagentStart` hook — both covered in tests/qwen-upgrades.test.cjs.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const QWEN_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'qwen', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const QWEN_AXES = QWEN_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies qwen as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'qwen' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'qwen');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('qwen axes classify as the programmatic-cli reference profile', () => {
|
||||
assert.equal(profileOf(QWEN_AXES), 'programmatic-cli');
|
||||
});
|
||||
|
||||
// -- AC3: all axes populated + validated -------------------------------------
|
||||
|
||||
test('qwen descriptor declares all 8 axes + 6 dispatch sub-axes with exact values', () => {
|
||||
assert.equal(QWEN_AXES.embeddingMode, 'imperative');
|
||||
assert.equal(QWEN_AXES.commandSurface, 'slash-file');
|
||||
assert.equal(QWEN_AXES.modelMode, 'passive');
|
||||
assert.equal(QWEN_AXES.hookBus, 'host');
|
||||
assert.equal(QWEN_AXES.stateIO, 'filesystem');
|
||||
assert.equal(QWEN_AXES.transport, 'mcp');
|
||||
assert.equal(QWEN_AXES.runtime, 'node');
|
||||
const d = QWEN_AXES.dispatch;
|
||||
assert.equal(d.namedDispatch, true);
|
||||
assert.equal(d.nested, false);
|
||||
assert.equal(d.maxDepth, 1);
|
||||
assert.equal(d.background, true);
|
||||
assert.equal(d.subagentToolkit, 'full');
|
||||
assert.equal(d.backgroundDispatch, false);
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for qwen, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...QWEN_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...QWEN_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty qwen descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the folded-in behaviors ---------------------------------------------
|
||||
|
||||
test('qwen descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = QWEN_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.skillPriorityFrontmatter, true);
|
||||
assert.ok(hb.brandingRewrites && typeof hb.brandingRewrites === 'object');
|
||||
assert.equal(hb.brandingRewrites['CLAUDE.md'], 'QWEN.md');
|
||||
assert.equal(hb.brandingRewrites['Claude Code'], 'Qwen Code');
|
||||
assert.equal(hb.brandingRewrites['.claude/'], '.qwen/');
|
||||
assert.equal(hb.legacyCommandsGsdCleanup, true);
|
||||
assert.equal(hb.legacyCommandsGsdInstallMigration, true);
|
||||
assert.equal(hb.legacyCommandsGsdUninstall, true);
|
||||
assert.equal(hb.hyphenNameAgentBody, true);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired across all folded modules -------
|
||||
|
||||
test('no `runtime === "qwen"` string-equality branch remains in the descriptor-migrated modules (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const files = [
|
||||
path.join(repoRoot, 'bin', 'install.js'),
|
||||
path.join(repoRoot, 'src', 'install-engine.cts'),
|
||||
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
|
||||
path.join(repoRoot, 'src', 'runtime-hooks-surface.cts'),
|
||||
];
|
||||
for (const file of files) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'qwen'/g) || [];
|
||||
assert.deepEqual(offenders, [],
|
||||
`AC2: no hardcoded runtime==='qwen' branch may remain in ${path.relative(repoRoot, file)}; found: ${offenders.join(', ')}`);
|
||||
}
|
||||
});
|
||||
152
tests/qwen-upgrades.test.cjs
Normal file
152
tests/qwen-upgrades.test.cjs
Normal file
@@ -0,0 +1,152 @@
|
||||
'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);
|
||||
});
|
||||
@@ -143,11 +143,15 @@ const GOLDEN = {
|
||||
|
||||
// ── qwen ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
// ADR-1239 / #2092 Phase B Upgrade 1: agents kind added (native
|
||||
// .qwen/agents/*.md subagent projection).
|
||||
'qwen/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'qwen/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── hermes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -222,15 +222,23 @@ describe('resolveRuntimeArtifactLayout — trae', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — qwen', () => {
|
||||
test('returns correct layout for qwen', () => {
|
||||
test('returns correct layout for qwen (skills + agents — #2092 Phase B Upgrade 1)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('qwen', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'qwen');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'must have a skills kind');
|
||||
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
||||
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skillsKind.stage, 'function');
|
||||
|
||||
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
||||
assert.ok(agentsKind, 'must have an agents kind (#2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection)');
|
||||
assert.strictEqual(agentsKind.destSubpath, 'agents');
|
||||
assert.strictEqual(agentsKind.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof agentsKind.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user