Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
151 lines
6.5 KiB
JavaScript
151 lines
6.5 KiB
JavaScript
// allow-test-rule: source-text-is-the-product #2072
|
|
// Workflow .md files ARE the deployed orchestration contract the runtime executes;
|
|
// asserting that a spawn threads a resolved model= is asserting the deployed contract.
|
|
|
|
'use strict';
|
|
|
|
/**
|
|
* #2072 — model_overrides / models.<phaseType> were silently inert for
|
|
* msd-assumptions-analyzer and msd-code-reviewer on Claude: the resolver honored
|
|
* them, but the workflows spawned the agents with NO model= param, so the resolved
|
|
* value never reached the Agent tool and the agents inherited the session model.
|
|
*
|
|
* Fix contract: every spawn of these two catalog agents must thread a resolved
|
|
* model=, and the workflow must obtain that model (inline `resolve-model` for the
|
|
* single-agent workflows, or the `reviewer_model` field of the init.quick bundle).
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const WF = path.join(__dirname, '..', 'msd-core', 'workflows');
|
|
const read = (rel) => fs.readFileSync(path.join(WF, rel), 'utf-8');
|
|
|
|
// Every .md under msd-core/workflows (incl. nested steps/ and modes/).
|
|
function allWorkflowMd() {
|
|
const out = [];
|
|
(function walk(dir, rel) {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) walk(path.join(dir, e.name), path.join(rel, e.name));
|
|
else if (e.name.endsWith('.md')) out.push(path.join(rel, e.name));
|
|
}
|
|
})(WF, '');
|
|
return out;
|
|
}
|
|
|
|
// Return the full text of every `Agent( … )` call in `content`, tracking string
|
|
// state (triple- and single-double-quoted) and paren depth so a prompt body's own
|
|
// parens/quotes don't end the call early. Order-independent: a call's params are
|
|
// captured whether subagent_type= appears before or after the prompt.
|
|
function agentCalls(content) {
|
|
const calls = [];
|
|
const re = /Agent\(/g;
|
|
let m;
|
|
while ((m = re.exec(content)) !== null) {
|
|
let i = m.index + m[0].length;
|
|
let depth = 1;
|
|
let tq = false; // inside """ … """
|
|
let sq = false; // inside " … "
|
|
while (i < content.length && depth > 0) {
|
|
if (tq) {
|
|
if (content.startsWith('"""', i)) { tq = false; i += 3; continue; }
|
|
i++; continue;
|
|
}
|
|
if (sq) {
|
|
if (content[i] === '\\') { i += 2; continue; }
|
|
if (content[i] === '"') { sq = false; }
|
|
i++; continue;
|
|
}
|
|
if (content.startsWith('"""', i)) { tq = true; i += 3; continue; }
|
|
if (content[i] === '"') { sq = true; i++; continue; }
|
|
if (content[i] === '(') { depth++; }
|
|
else if (content[i] === ')') { depth--; }
|
|
i++;
|
|
}
|
|
calls.push(content.slice(m.index, i));
|
|
re.lastIndex = i; // don't re-scan inside this call
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
describe('#2072: routed-agent spawns thread the resolved model', () => {
|
|
test('discuss-phase-assumptions.md resolves + threads msd-assumptions-analyzer model', () => {
|
|
const c = read('discuss-phase-assumptions.md');
|
|
assert.match(c, /resolve-model msd-assumptions-analyzer/, 'must resolve the routed model');
|
|
assert.match(
|
|
c,
|
|
/subagent_type="msd-assumptions-analyzer",\s*model="\{ANALYZER_MODEL\}"/,
|
|
'spawn must thread the resolved model=',
|
|
);
|
|
});
|
|
|
|
test('code-review.md resolves + threads msd-code-reviewer model', () => {
|
|
const c = read('code-review.md');
|
|
assert.match(c, /resolve-model msd-code-reviewer/);
|
|
assert.match(c, /subagent_type="msd-code-reviewer",\s*model="\{REVIEWER_MODEL\}"/);
|
|
});
|
|
|
|
test('code-review-fix.md re-review resolves + threads msd-code-reviewer model', () => {
|
|
const c = read('code-review-fix.md');
|
|
assert.match(c, /resolve-model msd-code-reviewer/);
|
|
assert.match(c, /subagent_type="msd-code-reviewer",\s*model="\{REVIEWER_MODEL\}"/);
|
|
});
|
|
|
|
test('quick.md review step threads msd-code-reviewer own model (not executor_model)', () => {
|
|
const c = read('quick.md');
|
|
// reviewer_model comes from the init.quick bundle; the spawn must use it.
|
|
assert.match(c, /subagent_type="msd-code-reviewer",\s*\n\s*model="\{reviewer_model\}"/);
|
|
assert.doesNotMatch(
|
|
c,
|
|
/subagent_type="msd-code-reviewer",\s*\n\s*model="\{executor_model\}"/,
|
|
'reviewer must not reuse the executor model (own model_overrides would be ignored)',
|
|
);
|
|
});
|
|
|
|
test('code-review-fix.md threads msd-code-fixer model at both fixer spawns', () => {
|
|
const c = read('code-review-fix.md');
|
|
assert.match(c, /resolve-model msd-code-fixer/, 'must resolve the fixer model');
|
|
const fixerSpawns = c.match(/subagent_type="msd-code-fixer", model="\{FIXER_MODEL\}"/g) || [];
|
|
assert.strictEqual(fixerSpawns.length, 2, 'both msd-code-fixer spawns must thread FIXER_MODEL');
|
|
});
|
|
|
|
// Parity guard: EVERY spawn of the fixed routed agents across ALL workflows must
|
|
// carry a model= in its Agent(...) call, so a new silently-inert spawn cannot
|
|
// regress. Uses a quote/paren-aware scan of the WHOLE Agent(...) call, so it holds
|
|
// for single-line and multi-line calls, multiple spawns per file, and either param
|
|
// ordering (subagent_type= before OR after the prompt body).
|
|
test('no spawn of the fixed routed agents is missing a model= (parity guard)', () => {
|
|
const ROUTED = /subagent_type="(msd-code-reviewer|msd-assumptions-analyzer|msd-code-fixer)"/;
|
|
const offenders = [];
|
|
for (const rel of allWorkflowMd()) {
|
|
for (const call of agentCalls(read(rel))) {
|
|
const routed = call.match(ROUTED);
|
|
if (routed && !/\bmodel\s*=/.test(call)) {
|
|
offenders.push(`${rel}: ${routed[1]} spawn missing model=`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(offenders, [], `routed-agent spawn(s) missing model=:\n${offenders.join('\n')}`);
|
|
});
|
|
|
|
// The scanner itself must catch a prompt-first, un-threaded spawn (the exact blind
|
|
// spot a naive "params before prompt=" heuristic misses) — otherwise the guard
|
|
// above could pass vacuously.
|
|
test('parity guard detects a prompt-first spawn that omits model=', () => {
|
|
const synthetic = [
|
|
'Agent(',
|
|
' prompt="""do the thing (with parens) and a " quote""",',
|
|
' subagent_type="msd-code-reviewer"',
|
|
')',
|
|
].join('\n');
|
|
const [call] = agentCalls(synthetic);
|
|
assert.ok(/subagent_type="msd-code-reviewer"/.test(call), 'scanner must capture the prompt-first subagent_type');
|
|
assert.ok(!/\bmodel\s*=/.test(call), 'and correctly see that model= is absent');
|
|
// A well-formed prompt-first spawn WITH model= must be accepted.
|
|
const ok = agentCalls(synthetic.replace(')', ' model="{reviewer_model}"\n)'))[0];
|
|
assert.ok(/\bmodel\s*=/.test(ok));
|
|
});
|
|
});
|