* 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>
142 lines
7.4 KiB
JavaScript
142 lines
7.4 KiB
JavaScript
// allow-test-rule: AC2 requires asserting no `runtime === 'opencode'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2087)
|
|
'use strict';
|
|
|
|
/**
|
|
* opencode imperative reference host — ADR-1239 Phase D / #2087 (EoS/opencode).
|
|
*
|
|
* Proves opencode 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, that opencode's
|
|
* SYNCHRONOUS dispatch force-flattens (#2598 retracts #2087's background
|
|
* "upgrade" — the capability is behind an opt-in flag, not default-on), and that
|
|
* the migration retired the hardcoded
|
|
* `runtime === 'opencode'` / `isOpencode` branches (folded into descriptor-driven
|
|
* `runtime.hostBehaviors` + the combined-family engine install path).
|
|
*/
|
|
|
|
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,
|
|
shouldFlattenDispatch,
|
|
extensionEventSurfaceFor,
|
|
PROFILE_BASELINES,
|
|
UNDOCUMENTED,
|
|
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
|
|
|
const OC_CAP = JSON.parse(
|
|
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'opencode', 'capability.json'), 'utf8'),
|
|
);
|
|
const OC_AXES = OC_CAP.runtime.hostIntegration;
|
|
|
|
// -- AC2: driven through the public interface (imperative adapter) -----------
|
|
|
|
test('createImperativeAdapter classifies opencode as imperative + composes the registry', () => {
|
|
const adapter = createImperativeAdapter({ runtime: 'opencode' });
|
|
assert.equal(adapter.kind, 'imperative');
|
|
assert.equal(adapter.runtime, 'opencode');
|
|
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
|
assert.equal(typeof adapter.install, 'function');
|
|
assert.equal(typeof adapter.uninstall, 'function');
|
|
});
|
|
|
|
test('opencode axes classify as the programmatic-cli reference profile', () => {
|
|
assert.equal(profileOf(OC_AXES), 'programmatic-cli');
|
|
});
|
|
|
|
// -- AC4: dispatch is synchronous — the #2087 "upgrade" is retracted (#2598) --
|
|
|
|
test('opencode descriptor declares background dispatch false/false (#2598)', () => {
|
|
// #2087 set these true, reading OpenCode v1.15/v1.17 as "background subagents
|
|
// enabled by default in all modes". That reading does not hold against current
|
|
// upstream `dev`, where the capability is opt-in:
|
|
// experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS")
|
|
// `enabledByExperimental` falls back to the `experimental` flag and `bool()`
|
|
// defaults false, so the Task tool's `background` parameter is hidden from the
|
|
// model unless an operator opts in. Upstream #29638 (OPEN) confirms the session
|
|
// loop still `tasks.pop()`s one subtask at a time.
|
|
assert.equal(OC_AXES.dispatch.background, false,
|
|
'background subagents are behind an opt-in experimental flag, not default-on');
|
|
assert.equal(OC_AXES.dispatch.backgroundDispatch, false,
|
|
'concurrent dispatch cannot be relied on, so it must not be declared');
|
|
});
|
|
|
|
test('synchronous dispatch force-flattens; the retracted axes would not have', () => {
|
|
// Declaring a capability the host lacks is the failure mode #2598 closes:
|
|
// negotiation is built to fail CLOSED, so an unavailable concurrency
|
|
// 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)');
|
|
// #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), 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)', () => {
|
|
const surface = extensionEventSurfaceFor('opencode');
|
|
assert.ok(surface, 'opencode is a consumed extensionEvents dialect');
|
|
for (const ev of ['permission.asked', 'permission.replied', 'session.error']) {
|
|
assert.ok(surface.includes(ev), `#2087 adds ${ev} to the opencode extension-event surface`);
|
|
}
|
|
// The engine still owns phase sequencing — no workflow-phase events on the bus.
|
|
assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e)));
|
|
});
|
|
|
|
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
|
|
|
test('negotiateHostCapabilities never throws for opencode, even fully corrupted', () => {
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: UNDOCUMENTED }));
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: 'future-unknown' }));
|
|
});
|
|
|
|
test('a partial/empty opencode 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 hardcoded branches are retired ---------------------------------
|
|
|
|
test('opencode descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
|
const hb = OC_CAP.runtime.hostBehaviors;
|
|
assert.ok(hb && typeof hb === 'object');
|
|
assert.equal(hb.combinedFamilyInstall, true, 'commands+skills+plugin install runs through the engine (adapter)');
|
|
assert.equal(hb.reapplyCommand, '/gsd-update --reapply');
|
|
assert.equal(hb.attributionConfigResolver, 'opencode');
|
|
// #2329: OpenCode discovers commands from the PLURAL `commands/` dir; the
|
|
// singular `command/` made all /gsd-* commands invisible to OpenCode.
|
|
assert.equal(hb.flatCommandDir, 'commands');
|
|
assert.equal(hb.frontmatterDialect, 'opencode');
|
|
assert.equal(hb.skipHomePrefixSubstitution, true);
|
|
assert.equal(hb.skipSettingsUi, true);
|
|
assert.equal(hb.skipUpdateBannerCommand, true);
|
|
assert.equal(hb.skipCodexSkillsManifest, true);
|
|
assert.equal(hb.nativePlugin.file, 'gsd-core.js');
|
|
assert.equal(hb.nativePlugin.source, '.opencode/plugins/gsd-core.js');
|
|
});
|
|
|
|
test('no `runtime === "opencode"` string-equality branch remains in the install source (AC2)', () => {
|
|
const strip = (src) => src
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/[^\r\n]*/g, '')
|
|
.replace(/`[^`]*`/g, '');
|
|
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) {
|
|
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
|
const offenders = strip(src).match(/runtime\s*[!=]==\s*'opencode'/g) || [];
|
|
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='opencode' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
|
}
|
|
});
|