diff --git a/.changeset/curious-quails-glide.md b/.changeset/curious-quails-glide.md new file mode 100644 index 000000000..47b1827e1 --- /dev/null +++ b/.changeset/curious-quails-glide.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3063 +--- +**Dispatch flattening now honors the declared nesting depth budget, so runtimes that cannot host a backgrounded orchestrator plus a delegated leaf run inline instead of producing an unsupported depth-2 tree** — `shouldFlattenDispatch` checked only the two background booleans, so a host advertising `maxDepth:1` was told it may background, which under Codex MultiAgent V2 produced a depth-2 orchestration tree the declared contract forbids. The decision now also requires `nested` + a full subagent toolkit + a depth budget greater than 1 or unbounded, reusing the convention already in `degradationFor` and `_normalizeDispatchCallSpan`. Runtimes lacking any of those — codex at `maxDepth:1`, kimi with `nested:false`, kimi-code with a built-in-only toolkit — now correctly run inline, the safer path that keeps worktree isolation and verification in force; only cursor remains background-eligible. (#2939) diff --git a/src/host-integration.cts b/src/host-integration.cts index d80249f0b..472fd47a4 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -569,13 +569,34 @@ function negotiateHostCapabilities( * may be backgrounded. * * A host may background only if it can reliably background a nesting-capable - * orchestrator — i.e. both `background` AND `backgroundDispatch` are - * explicitly `true`. Any other value (false, missing, 'undocumented') fails - * closed to inline (the always-safe path). + * orchestrator that still has room to delegate to a leaf — i.e. ALL of: + * - `background === true` AND `backgroundDispatch === true` (it can background at all), AND + * - `nested === true` AND `subagentToolkit === 'full'` (it can host a nesting orchestrator), AND + * - a depth budget greater than 1, or unbounded (`maxDepth < 0`): a budget of exactly 1 + * is consumed by the backgrounded orchestrator itself (depth 1) and leaves no room for the + * delegated leaf (depth 2) its own contract would require. + * + * Any other value (false, missing, 'undocumented', or an insufficient depth budget) fails closed + * to inline (the always-safe path). This closes #2939, where a `maxDepth:1` descriptor + * (the live Codex capability) was told it may background a nesting orchestrator that then + * produced a depth-2 tree its declared contract cannot support. + * + * The depth-budget test mirrors the convention already used elsewhere in the codebase: + * `degradationFor` (same file) treats `nested && depth >= 2` as full-depth, `maxDepth === 1` + * as flat, and any `maxDepth < 0` as unbounded; `negotiateHostCapabilities` (same file) also + * treats `maxDepth < 0` as unbounded; `bin/install.js`'s `_normalizeDispatchCallSpan` uses + * `subagentToolkit === 'full' && (maxDepth === -1 || maxDepth > 1)`. This function adopts the + * broader `maxDepth < 0` = unbounded convention from `degradationFor`/`negotiateHostCapabilities` + * (every shipped descriptor uses `-1` for unbounded, so the two conventions agree on live input). + * It is STRICTER than `_normalizeDispatchCallSpan` in one respect: it also requires `nested === true`, + * because a host that cannot nest cannot host a backgrounded orchestrator that delegates — + * `_normalizeDispatchCallSpan` runs per-call after the dispatch decision and does not need that gate. * * This graduates the #853 prose rule (originally `RUNTIME === 'codex'`, then - * extended to cursor) to a typed, documentation-sourced decision; codex AND - * cursor are both background-eligible in the registry. See + * extended to cursor) to a typed, documentation-sourced decision; of the shipped + * background-capable hosts only cursor (`maxDepth:2`) remains background-eligible under the + * depth-aware rule — codex (`maxDepth:1`), kimi (`nested:false`), and kimi-code + * (`built-in-only` toolkit) now correctly flatten. See * docs/reference/host-integration-capability-matrix.md. * * Null-safety: if dispatch is null, undefined, or not an object, returns true @@ -585,8 +606,18 @@ type UnvalidatedDispatch = (Partial & { background?: unknown function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean { if (!dispatch || typeof dispatch !== 'object') return true; + // Can background at all: both background flags must be explicitly true. const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true; - return !canBackground; + if (!canBackground) return true; + // #2939: can background a NESTING orchestrator with room to delegate. A depth budget of 1 + // is consumed by the backgrounded orchestrator itself; it needs > 1 (or unbounded, maxDepth < 0) + // to host a delegated leaf at depth 2. Non-finite/missing maxDepth fails closed (no budget → + // flatten), mirroring degradationFor's treatment of non-finite depth as 0. + const canNest = dispatch.nested === true && dispatch.subagentToolkit === 'full'; + if (!canNest) return true; + const depth = typeof dispatch.maxDepth === 'number' && Number.isFinite(dispatch.maxDepth) ? dispatch.maxDepth : 0; + const depthSufficient = depth < 0 || depth > 1; + return !depthSufficient; } // --------------------------------------------------------------------------- diff --git a/tests/command-routing-hub.test.cjs b/tests/command-routing-hub.test.cjs index 876b68a28..e1b06f2a9 100644 --- a/tests/command-routing-hub.test.cjs +++ b/tests/command-routing-hub.test.cjs @@ -1693,23 +1693,25 @@ describe('bug-853 — manager/autonomous gate background dispatch by runtime', ( describe('dispatch-should-flatten query — behavioral', () => { // #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks. - // shouldFlattenDispatch returns false only when both dispatch.background AND - // dispatch.backgroundDispatch are true in the capability registry. + // #2939: shouldFlattenDispatch is now DEPTH-AWARE — a host may background only if it can + // background AND host a nesting orchestrator with a depth budget > 1 (nested:true + + // subagentToolkit:"full" + maxDepth > 1 or unbounded). The two-field background rule is the + // first gate, not the whole decision. // // Registry values (from host-integration-capability-matrix.md): - // codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background) - // claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline) - // cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background) - // unknown: no entry → fail-closed → shouldFlatten=true (must inline) + // codex: background=true, backgroundDispatch=true, BUT maxDepth:1 → shouldFlatten=true (must inline — #2939: depth budget insufficient for a bg orchestrator + leaf) + // claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline) + // cursor: background=true, backgroundDispatch=true, maxDepth:2 → shouldFlatten=false (may background — the one shipped host with a sufficient depth budget) + // unknown: no entry → fail-closed → shouldFlatten=true (must inline) - test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => { + test('runtime=codex → shouldFlatten=true (#2939: maxDepth:1 cannot host a bg orchestrator + leaf)', () => { const tmpDir = createTempProject(); try { const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { GSD_RUNTIME: 'codex', }); assert.ok(result.success, `Expected success, got error: ${result.error}`); - assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`); + assert.strictEqual(result.output, 'true', `#2939: codex maxDepth:1 should return true (must inline), got: ${result.output}`); } finally { cleanupDir(tmpDir); } @@ -1770,7 +1772,10 @@ describe('dispatch-should-flatten query — behavioral', () => { assert.fail(`Expected valid JSON output, got: ${result.output}`); } assert.strictEqual(parsed.runtime, 'codex'); - assert.strictEqual(parsed.shouldFlatten, false); + // #2939: codex maxDepth:1 → shouldFlatten:true (depth budget insufficient). backgroundDispatch + // stays true on the descriptor (codex CAN background a single agent); only the flatten + // consequence changes. + assert.strictEqual(parsed.shouldFlatten, true); assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object'); assert.strictEqual(parsed.dispatch.backgroundDispatch, true); } finally { @@ -1797,9 +1802,9 @@ describe('dispatch-should-flatten query — behavioral', () => { const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, { GSD_RUNTIME: '', }); - // config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false + // #2939: config.runtime=codex with GSD_RUNTIME cleared → codex maxDepth:1 → shouldFlatten=true (must inline) assert.ok(result.success, `Expected success, got error: ${result.error}`); - assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`); + assert.strictEqual(result.output, 'true', `#2939: config.runtime=codex (GSD_RUNTIME cleared) should return true (must inline, maxDepth:1), got: ${result.output}`); } finally { cleanupDir(tmpDir); } diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs index 93af42da6..2c1138744 100644 --- a/tests/host-integration-descriptors.test.cjs +++ b/tests/host-integration-descriptors.test.cjs @@ -272,28 +272,36 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { // ─── shouldFlattenDispatch per-host (#853 discriminator) ───────────────────── - // Expected: false (may background) for codex, cursor, kimi, and kimi-code; - // true (must inline) for the other 14. + // Expected: false (may background) ONLY for cursor — the one shipped host whose + // dispatch declares nested:true + subagentToolkit:"full" + a depth budget > 1. + // true (must inline) for the other 17. + // + // #2939: the depth-aware rule reclassifies codex/kimi/kimi-code, which the old + // two-field (background+backgroundDispatch) rule admitted as background-eligible + // despite each lacking what a backgrounded nesting orchestrator actually needs: + // - codex: nested:true + full toolkit, BUT maxDepth:1 (no room for a depth-2 leaf) + // - kimi: nested:false (cannot host a nesting orchestrator at all) + // - kimi-code: subagentToolkit:'built-in-only' (cannot delegate to full subagents) + // cursor (maxDepth:2) remains the only background-capable host with a sufficient budget. const EXPECTED_FLATTEN = { antigravity: true, augment: true, claude: true, cline: true, codebuddy: true, - codex: false, + codex: true, copilot: true, cursor: false, hermes: true, kilo: true, - // #2095: Kimi Upgrade 2 — Kimi's Agent tool takes a run_in_background - // call-time param (Context7 agents.html) → backgroundDispatch flipped to - // true → dispatch.background/backgroundDispatch both true → NOT - // force-flattened (mirrors the #2087 OpenCode precedent below). - kimi: false, - // #2454: Kimi Code (Node CLI) — same background-dispatch model as Python - // kimi-cli per Kimi Code docs (dispatch.background/backgroundDispatch both - // true) → NOT force-flattened. - 'kimi-code': false, + // #2095/#2939: Kimi CAN background a single agent (backgroundDispatch:true), BUT + // nested:false means a backgrounded kimi agent cannot nest the plan-checker/executor/ + // verifier pipeline the workflows require → flatten. backgroundDispatch stays true on + // the descriptor (UPGRADE 2 holds); only the flatten consequence changes. + kimi: true, + // #2454/#2939: Kimi Code declares background/backgroundDispatch both true, BUT + // subagentToolkit:'built-in-only' cannot delegate to full subagents → flatten. + 'kimi-code': true, // #2598: OpenCode's background subagents sit behind the opt-in // OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS flag (default false), and the // session loop still handles one subtask at a time (upstream #29638, OPEN). diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index f89d14172..1e5aca7e3 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -955,9 +955,12 @@ describe('Phase B: shouldFlattenDispatch — contract pin', () => { 'shouldFlattenDispatch must be exported from host-integration module'); }); - test('{background:true, backgroundDispatch:true} → false (background OK)', () => { - assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: true }), false, - 'canBackground=true when both background===true AND backgroundDispatch===true → flatten=false'); + test('{background:true, backgroundDispatch:true} → true (no depth budget declared → fail-closed/flatten)', () => { + // #2939: can background AT ALL, but declares no nested/toolkit/maxDepth, so the depth + // budget is unknown → fail-closed to inline (flatten). A real background-eligible host + // also carries nested:true + subagentToolkit:"full" + a sufficient maxDepth. + assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: true }), true, + 'canBackground=true but no depth budget declared → fail-closed/flatten=true'); }); test('{background:true, backgroundDispatch:false} → true (must flatten)', () => { @@ -1003,12 +1006,24 @@ describe('Phase B: shouldFlattenDispatch — contract pin', () => { 'non-object dispatch (string) must fail-closed to true'); }); - // #853 codex-like profile: full dispatch including backgroundDispatch:true → background OK - test('#853 codex-like: {namedDispatch:true,nested:true,maxDepth:1,background:true,subagentToolkit:"full",backgroundDispatch:true} → false (background OK)', () => { + // #2939: the codex-like profile with maxDepth:1 now FLATTENS. A depth budget of 1 is consumed + // by the backgrounded orchestrator itself (depth 1) and leaves no room for the delegated leaf + // (depth 2) its own contract requires. This corrects the prior pin, which asserted the buggy + // shouldFlatten:false output that permitted a depth-2 tree the descriptor cannot support. + test('#2939 codex-like: {namedDispatch:true,nested:true,maxDepth:1,background:true,subagentToolkit:"full",backgroundDispatch:true} → true (flatten — depth budget insufficient)', () => { assert.strictEqual( shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: 1, background: true, subagentToolkit: 'full', backgroundDispatch: true }), + true, + 'maxDepth:1 is insufficient for a backgrounded orchestrator plus a delegated leaf → flatten=true', + ); + }); + + // #2939 negative-space: the same codex-like profile with a SUFFICIENT depth budget backgrounds. + test('#2939 codex-like maxDepth:2 → false (background OK — depth budget sufficient)', () => { + assert.strictEqual( + shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: 2, background: true, subagentToolkit: 'full', backgroundDispatch: true }), false, - 'codex-like dispatch with backgroundDispatch:true must be background-OK (flatten=false)', + 'maxDepth:2 leaves room for a backgrounded orchestrator plus a leaf → background OK', ); }); diff --git a/tests/issue-2939-dispatch-flatten-maxdepth.test.cjs b/tests/issue-2939-dispatch-flatten-maxdepth.test.cjs new file mode 100644 index 000000000..7d19fe1b3 --- /dev/null +++ b/tests/issue-2939-dispatch-flatten-maxdepth.test.cjs @@ -0,0 +1,135 @@ +'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)', + ); + }); +}); diff --git a/tests/kimi-upgrades.test.cjs b/tests/kimi-upgrades.test.cjs index 6fe338405..ad5559e81 100644 --- a/tests/kimi-upgrades.test.cjs +++ b/tests/kimi-upgrades.test.cjs @@ -253,12 +253,17 @@ test('UPGRADE 2: capabilities/kimi/capability.json declares dispatch.background assert.equal(d.backgroundDispatch, true); }); -test('UPGRADE 2: negotiateHostCapabilities against kimi axes yields effective.dispatch.backgroundDispatch === true, and shouldFlattenDispatch is false (background now allowed)', () => { +test('UPGRADE 2: negotiateHostCapabilities against kimi axes yields effective.dispatch.backgroundDispatch === true, but shouldFlattenDispatch is true (#2939: nested:false cannot host a nesting orchestrator)', () => { const KIMI_AXES = KIMI_CAP.runtime.hostIntegration; const { effective } = negotiateHostCapabilities(KIMI_AXES); + // UPGRADE 2 still holds: the descriptor declares backgroundDispatch:true (kimi CAN background + // a single agent). #2939 changes only the FLATTEN consequence: kimi's nested:false means a + // backgrounded kimi agent cannot itself nest the plan-checker/executor/verifier pipeline the + // workflows require, so the orchestrator must run inline (flatten) even though backgrounding + // a single agent is possible. assert.equal(effective.dispatch.backgroundDispatch, true); - assert.equal(shouldFlattenDispatch(effective.dispatch), false, - 'kimi may now background — dispatch must not be flattened to inline'); + assert.equal(shouldFlattenDispatch(effective.dispatch), true, + '#2939: kimi nested:false → a backgrounded orchestrator cannot nest the pipeline → flatten'); }); test('UPGRADE 2: a corrupted/undeclared dispatch still fails closed to inline (shouldFlattenDispatch === true)', () => { diff --git a/tests/opencode-imperative-reference.test.cjs b/tests/opencode-imperative-reference.test.cjs index 164c325c8..982b5f8d7 100644 --- a/tests/opencode-imperative-reference.test.cjs +++ b/tests/opencode-imperative-reference.test.cjs @@ -72,11 +72,14 @@ test('synchronous dispatch force-flattens; the retracted axes would not have', ( // capability must serialize rather than be trusted. assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), true, 'with background:false, GSD must force-flatten opencode dispatch (fail closed)'); - // Pin the retracted contract so a silent re-flip is caught: had the #2087 - // values been accurate, dispatch would NOT have been flattened. + // #2939: pin the retracted contract so a silent re-flip is caught. Under the depth-aware + // rule, flipping ONLY the two background booleans is no longer sufficient to background — + // opencode's axes lack nested:true + subagentToolkit:"full" + a depth budget > 1, so even + // the #2087 background values still flatten. A future accurate declaration would need to + // establish the full nesting capability, not just the background booleans. const retracted = { ...OC_AXES.dispatch, background: true, backgroundDispatch: true }; - assert.equal(shouldFlattenDispatch(retracted), false, - 'the #2087 axes did not flatten — that is exactly the overstatement #2598 retracts'); + assert.equal(shouldFlattenDispatch(retracted), true, + '#2939: the #2087 background-only values still flatten — opencode lacks nested + full toolkit + depth budget'); }); test('opencode extension-event surface includes the #2087 additions (permission + session.error)', () => { diff --git a/tests/trae-imperative-reference.test.cjs b/tests/trae-imperative-reference.test.cjs index 0b004652b..bf452993d 100644 --- a/tests/trae-imperative-reference.test.cjs +++ b/tests/trae-imperative-reference.test.cjs @@ -101,9 +101,13 @@ test('AC-SPECIFIC: trae real dispatch axes fail CLOSED to inline (shouldFlattenD // this fail-closed default without a deliberate test update. assert.equal(shouldFlattenDispatch(TRAE_AXES.dispatch), true); - // Sanity: if backgroundDispatch WERE true (all else equal), the same shape - // would NOT flatten — proving backgroundDispatch is what flips the result. - assert.equal(shouldFlattenDispatch({ ...TRAE_AXES.dispatch, backgroundDispatch: true }), false); + // #2939: flipping ONLY backgroundDispatch to true is NO LONGER sufficient to + // background. trae's axes declare nested:'undocumented' (not true) and + // subagentToolkit:'undocumented' (not 'full'), so even with backgroundDispatch + // true the depth/nesting gate (canNest) fails closed to flatten. A future + // doc-sourcing pass must also establish nested:true + subagentToolkit:'full' + + // a sufficient maxDepth before trae may background. + assert.equal(shouldFlattenDispatch({ ...TRAE_AXES.dispatch, backgroundDispatch: true }), true); }); test('a partial/empty trae descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {