Files
msd-core/tests/issue-2939-dispatch-flatten-maxdepth.test.cjs
Tom Boucher 83a26ed1dc fix(#2939): honor the declared depth budget in shouldFlattenDispatch (#3063)
* test(#2939): prove shouldFlattenDispatch ignores the depth budget

Failing-first regression for #2939. shouldFlattenDispatch checks only
background+backgroundDispatch, never nested/subagentToolkit/maxDepth, so a
maxDepth:1 descriptor (no room for a bg orchestrator plus a leaf) is told it
may background. Row 1 (codex-like, maxDepth:1) asserts true (flatten) and
fails today; rows 2/3 guard the unchanged depth-sufficient cases.

* fix(#2939): honor the declared depth budget in shouldFlattenDispatch

shouldFlattenDispatch checked only background+backgroundDispatch, never
nested/subagentToolkit/maxDepth, so a maxDepth:1 descriptor (no room for a
backgrounded orchestrator plus a delegated leaf) was told it may background —
producing a depth-2 tree (Codex MultiAgent V2) the declared contract cannot
support.

canBackground now ALSO requires nested:true + subagentToolkit:"full" + a depth
budget > 1 (or unbounded -1), reusing the exact predicate shape from
bin/install.js _normalizeDispatchCallSpan and matching degradationFor's
treatment of maxDepth===1 as flat. Non-finite/missing maxDepth fails closed to
flatten. Correct the two existing pins that asserted the buggy output (bare
{bg,bgDispatch} now fail-closes on missing depth; the codex-like maxDepth:1 pin
flips to flatten) and add a maxDepth:2 negative-space row.

* fix(#2939): propagate depth-aware flatten to all pinned descriptors + tests

The isolated adversarial review found the depth-aware predicate reclassifies
codex/kimi/kimi-code (previously background-eligible under the two-field rule)
to flatten — the correct behavior, since each lacks what a backgrounded nesting
orchestrator needs:

  - codex: maxDepth:1 (no room for a depth-2 leaf)
  - kimi: nested:false (cannot host a nesting orchestrator)
  - kimi-code: subagentToolkit:'built-in-only' (cannot delegate to full subagents)

Only cursor (maxDepth:2) remains background-eligible. Update the three test
files that pinned the old contract (host-integration-descriptors EXPECTED_FLATTEN,
kimi-upgrades UPGRADE 2, trae-imperative-reference), and align the unbounded
convention to maxDepth < 0 (matching degradationFor/negotiateHostCapabilities)
with an accurate docstring noting the deliberate nested-check addition over
_normalizeDispatchCallSpan.

* fix(#2939): update dispatch-should-flatten CLI query pins for codex

The depth-aware rule (a0ad0f680) reclassifies codex (maxDepth:1) to flatten, but
command-routing-hub.test.cjs exercises the contract through the CLI query route
(runGsdTools query dispatch-should-flatten), not a direct shouldFlattenDispatch
call — so neither the reviewer's caller-search nor a grep for the symbol found
it; only the full gsd-test matrix did. Update the codex query assertions to
shouldFlatten=true (maxDepth:1 insufficient), preserving cursor (maxDepth:2 →
false) and the backgroundDispatch:true descriptor field.

* chore(#2939): add changeset fragment

pr:0 placeholder backfilled with the real PR number once the PR exists.

* fix(#2939): rephrase changeset for product-name-purity + opencode flatten pin

Two failures from the full gsd-test matrix on the prior sha:

1. product-name-purity: changeset fragments must not include parenthetical product
   descriptions (they render verbatim into CHANGELOG.md). 'Codex (and kimi/kimi-code)'
   tripped it — rephrase to lead with the behavior, naming runtimes inline without
   the parenthetical. lint:ci changeset-lint does not catch this; only the test does.

2. opencode-imperative-reference: the #2087-retraction pin flipped only the two
   background booleans and asserted shouldFlatten:false. Under #2939 that is no
   longer sufficient (opencode lacks nested + full toolkit + depth budget), so the
   retracted axes now correctly flatten — update the pin to true with rationale.

* chore(#2939): backfill changeset PR number 3063

---------

Co-authored-by: sim <sim@local>
2026-08-04 20:33:50 -04:00

136 lines
4.9 KiB
JavaScript

'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Regression test for #2939 — `shouldFlattenDispatch` ignores the declared
* depth budget, so a runtime advertising `maxDepth:1` (no room for a background
* orchestrator plus a delegated leaf) is still told it may background.
*
* Root cause: `shouldFlattenDispatch` (src/host-integration.cts) checked ONLY
* `dispatch.background` and `dispatch.backgroundDispatch`, never `nested`,
* `subagentToolkit`, or `maxDepth`. With the live Codex descriptor
* (background:true, backgroundDispatch:true, nested:true, subagentToolkit:"full",
* maxDepth:1) it returned `shouldFlatten:false`, which then permitted a depth-2
* orchestration tree the declared contract cannot support.
*
* The fix reconciles `shouldFlattenDispatch` with the depth-budget convention
* already used in the same file (`degradationFor`: nested && depth>=2 is
* full-depth; maxDepth===1 is flat) and in `bin/install.js`
* (`_normalizeDispatchCallSpan`: subagentToolkit==='full' && (maxDepth===-1 ||
* maxDepth>1)). A host may background only if it can background AND has a depth
* budget sufficient for a backgrounded orchestrator plus a delegated leaf.
*
* Matrix: .gsd/bug/fix/2939-dispatch-flatten-maxdepth/50-test-matrix.md
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { shouldFlattenDispatch } = require('../gsd-core/bin/lib/host-integration.cjs');
/** The live Codex-shaped descriptor (the bug input), with per-test depth overrides. */
function codexLike(overrides = {}) {
return {
namedDispatch: true,
nested: true,
maxDepth: 1,
background: true,
subagentToolkit: 'full',
backgroundDispatch: true,
...overrides,
};
}
describe('shouldFlattenDispatch depth budget (#2939)', () => {
test('codexLikeMaxDepth1Flattens', () => {
// Row 1 (failing-first regression): maxDepth:1 cannot host a bg orchestrator
// (depth 1) AND a delegated leaf (depth 2) → must flatten (inline).
assert.strictEqual(
shouldFlattenDispatch(codexLike({ maxDepth: 1 })),
true,
'maxDepth:1 is insufficient for a backgrounded orchestrator plus a leaf → flatten',
);
});
test('maxDepth2BackgroundsUnchanged', () => {
// Row 2: maxDepth:2 leaves room → background permitted, unchanged from today.
assert.strictEqual(
shouldFlattenDispatch(codexLike({ maxDepth: 2 })),
false,
'maxDepth:2 is sufficient → background permitted (unchanged)',
);
});
test('maxDepthUnboundedBackgroundsUnchanged', () => {
// Row 3: maxDepth:-1 (unbounded) → background permitted, unchanged.
assert.strictEqual(
shouldFlattenDispatch(codexLike({ maxDepth: -1 })),
false,
'maxDepth:-1 (unbounded) → background permitted (unchanged)',
);
});
test('nestedFalseFlattensRegardlessOfDepth', () => {
// Row 4 / acceptance #4: nested:false cannot host a nesting orchestrator →
// flatten regardless of maxDepth.
assert.strictEqual(
shouldFlattenDispatch(codexLike({ nested: false, maxDepth: 5 })),
true,
'nested:false → flatten regardless of maxDepth',
);
});
test('nonFullToolkitFlattens', () => {
// Row 5 / acceptance #4: a non-full toolkit cannot delegate → flatten
// regardless of maxDepth.
assert.strictEqual(
shouldFlattenDispatch(codexLike({ subagentToolkit: 'read-only', maxDepth: 5 })),
true,
'subagentToolkit!=="full" → flatten regardless of maxDepth',
);
});
test('backgroundFalseStillFlattens', () => {
// Row 6 / negative-space: background:false → flatten (the existing
// background-boolean fail-closed path is unchanged).
assert.strictEqual(
shouldFlattenDispatch(codexLike({ background: false, maxDepth: 5 })),
true,
'background:false → flatten (unchanged)',
);
});
test('backgroundDispatchFalseStillFlattens', () => {
// Row 7 / negative-space: backgroundDispatch:false → flatten (unchanged).
assert.strictEqual(
shouldFlattenDispatch(codexLike({ backgroundDispatch: false, maxDepth: 5 })),
true,
'backgroundDispatch:false → flatten (unchanged)',
);
});
test('maxDepth0Flattens', () => {
// Row 9: maxDepth:0 (zero depth budget) → flatten.
assert.strictEqual(
shouldFlattenDispatch(codexLike({ maxDepth: 0 })),
true,
'maxDepth:0 → flatten (zero depth budget)',
);
});
test('maxDepthMissingFlattens', () => {
// Row 10: maxDepth missing/non-number → flatten (fail-closed on absent
// budget, mirrors degradationFor treating non-finite as 0).
assert.strictEqual(
shouldFlattenDispatch(codexLike({ maxDepth: undefined })),
true,
'maxDepth missing → flatten (fail-closed)',
);
assert.strictEqual(
shouldFlattenDispatch(codexLike({ maxDepth: 'deep' })),
true,
'maxDepth non-number → flatten (fail-closed)',
);
});
});