diff --git a/.changeset/humble-deer-travel.md b/.changeset/humble-deer-travel.md new file mode 100644 index 000000000..48d203b75 --- /dev/null +++ b/.changeset/humble-deer-travel.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2682 +--- +**OpenCode no longer declares background subagent dispatch it does not have** — `capabilities/opencode/capability.json` advertised `dispatch.background` and `dispatch.backgroundDispatch` as `true`, but OpenCode's native subagent dispatch is synchronous: the Task tool's `background` parameter is hidden from the model behind the opt-in `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag, which defaults to false, and the session loop still handles one subtask at a time. Since `negotiateHostCapabilities` and every `degradationFor` consumer trusts these per-field values, declaring an absent capability overstated it — the opposite of the fail-closed posture the negotiation exists to enforce. Both fields are now `false`, and the host-integration capability matrix carries the corrected values with current upstream citations. (#2598) diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 46c8d5f2f..fed24b2c4 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -75,9 +75,9 @@ "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", - "background": true, + "background": false, "subagentToolkit": "full", - "backgroundDispatch": true, + "backgroundDispatch": false, "isolation": "orchestrator-worktree" }, "modelMode": "active", diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 0fd35e98f..794cd3691 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -142,9 +142,9 @@ Documentation gaps: | dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" | | dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — | | dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — | -| dispatch.background | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/task.ts (v1.15.0, commit 22de34c4d) + src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) | "New in v1.15.0: experimental background subagents — the Task tool gains a `background` parameter (`Schema.optional(Schema.Boolean)`) that launches subagents asynchronously with completion notifications. v1.17: `BACKGROUND_SUBAGENTS_ENABLED = true` (\"feat: enable background subagents by default\") — default-on, concurrent execution in all modes. (#2087, superseding the stale sst/opencode#5887 snapshot)" | +| dispatch.background | false | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts ; https://github.com/anomalyco/opencode/issues/29638 | "`experimentalBackgroundSubagents: enabledByExperimental(\"OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS\")` — `enabledByExperimental` falls back to the `experimental` flag, and `bool()` defaults to `false`, so the Task tool's `background` parameter is hidden from the model unless the operator opts in by env var. #29638 (OPEN) confirms the session loop still `tasks.pop()`s one subtask at a time. (#2598 — corrects #2087, whose \"v1.17 default-on in all modes\" reading does not hold against current `dev`)" | | dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" | -| dispatch.backgroundDispatch | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) + src/server/routes/instance/httpapi/handlers/experimental.ts | "v1.17 `BACKGROUND_SUBAGENTS_ENABLED = true` enables background subagent execution by default in all modes; the experimental capabilities endpoint exposes `{ backgroundSubagents: true }`. Background-spawned subagents run concurrently without blocking the main interaction flow. (#2087)" | +| dispatch.backgroundDispatch | false | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts ; https://github.com/anomalyco/opencode/issues/29638 ; https://github.com/anomalyco/opencode/issues/14195 | "Concurrent dispatch requires the opt-in `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag (default `false`), so it cannot be relied on. #14195: \"the session loop does `tasks.pop()` to grab a single subtask, `await`s it, then `continue`s the loop — so even 3 simultaneous Task calls run sequentially.\" Declaring `true` would overstate the capability, against the fail-closed posture negotiation is built for. (#2598 — corrects #2087)" | | dispatch.isolation | orchestrator-worktree | https://opencode.ai/docs/cli ; opencode.ai/docs/plugins ; opencode issues #14195/#29638/#5887 | "`opencode run --dir ` sets an explicit working root at the process level" — native subagent dispatch is synchronous-only, so GSD creates + manages the worktree and process-spawns the executor into it via `--dir` (#2584) | Sources consulted: diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 076142d30..90983aacb 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -2174,9 +2174,9 @@ const capabilities = { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", - "background": true, + "background": false, "subagentToolkit": "full", - "backgroundDispatch": true, + "backgroundDispatch": false, "isolation": "orchestrator-worktree" }, "modelMode": "active", @@ -5411,9 +5411,9 @@ const runtimes = { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", - "background": true, + "background": false, "subagentToolkit": "full", - "backgroundDispatch": true, + "backgroundDispatch": false, "isolation": "orchestrator-worktree" }, "modelMode": "active", diff --git a/tests/fix-2598-opencode-background-dispatch.test.cjs b/tests/fix-2598-opencode-background-dispatch.test.cjs new file mode 100644 index 000000000..82fe1f128 --- /dev/null +++ b/tests/fix-2598-opencode-background-dispatch.test.cjs @@ -0,0 +1,91 @@ +/** + * #2598 — the OpenCode descriptor declared background/concurrent subagent + * dispatch that OpenCode does not actually provide by default. + * + * `capabilities/opencode/capability.json` carried + * `runtime.hostIntegration.dispatch.background: true` and + * `dispatch.backgroundDispatch: true`. OpenCode's native subagent dispatch + * (Task tool / `@`-mention / `subtask`) is synchronous: the `background` + * parameter is hidden from the model behind the opt-in + * `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag, which defaults to false + * (`enabledByExperimental(...)` over a `bool()` that defaults false), and the + * session loop still `tasks.pop()`s one subtask at a time (upstream #14195, + * #29638 — the latter still open). + * + * `negotiateHostCapabilities` and every `degradationFor`/`shouldFlattenDispatch` + * consumer TRUSTS these per-field values, so declaring a capability the host + * lacks overstates it — the opposite of the fail-closed posture the negotiation + * exists to enforce. + * + * History note: these fields were flipped to `true` by #2087 citing a reading of + * OpenCode v1.17 as "background subagents enabled by default in all modes". + * That reading does not hold against current upstream `dev`, where the flag is + * opt-in. This test pins the corrected values so a future descriptor edit cannot + * silently re-assert an unsupported capability. + */ + +// allow-test-rule: runtime-contract-is-the-product #2598 — the descriptor JSON and the +// host-integration matrix ARE the negotiated contract; asserting their values is behavioral. + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const DESCRIPTOR = path.join(ROOT, 'capabilities', 'opencode', 'capability.json'); +const MATRIX = path.join(ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md'); + +function opencodeDispatch() { + const parsed = JSON.parse(fs.readFileSync(DESCRIPTOR, 'utf8')); + return parsed.runtime.hostIntegration.dispatch; +} + +describe('#2598: OpenCode does not declare background/concurrent subagent dispatch', () => { + test('descriptor declares background: false', () => { + assert.equal( + opencodeDispatch().background, + false, + 'OpenCode subagent dispatch is synchronous unless an experimental opt-in flag is set', + ); + }); + + test('descriptor declares backgroundDispatch: false', () => { + assert.equal( + opencodeDispatch().backgroundDispatch, + false, + 'concurrent dispatch requires an opt-in flag, so it must not be declared as available', + ); + }); + + test('the capabilities that ARE real are left intact', () => { + // Narrow the blast radius: this fix must not quietly downgrade neighbouring + // sub-fields that were never in question. + const d = opencodeDispatch(); + assert.equal(d.namedDispatch, true, 'named subagent dispatch is genuinely supported'); + assert.equal(d.subagentToolkit, 'full', 'the general subagent has full tool access'); + assert.equal(d.isolation, 'orchestrator-worktree', + 'isolation is orchestrator-managed via `opencode run --dir`, unaffected by #2598'); + }); + + test('the host-integration matrix agrees with the descriptor', () => { + // ADR-1239 designates the matrix the deployment source-of-truth; a + // descriptor/matrix disagreement is how this defect survived in the first + // place (the matrix said true, the ADR binding table said false). + const matrix = fs.readFileSync(MATRIX, 'utf8'); + const section = matrix.slice(matrix.indexOf('## opencode')); + const end = section.indexOf('\n## '); + const opencodeSection = end === -1 ? section : section.slice(0, end); + + for (const field of ['dispatch.background', 'dispatch.backgroundDispatch']) { + const row = opencodeSection.split('\n').find((l) => l.startsWith(`| ${field} |`)); + assert.ok(row, `matrix must document ${field} for opencode`); + const value = row.split('|')[2].trim(); + assert.equal(value, 'false', `matrix ${field} must match the descriptor`); + } + }); +}); diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs index 66ba16cd9..88ddbaad7 100644 --- a/tests/host-integration-descriptors.test.cjs +++ b/tests/host-integration-descriptors.test.cjs @@ -268,8 +268,8 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { // ─── shouldFlattenDispatch per-host (#853 discriminator) ───────────────────── - // Expected: false (may background) for codex, cursor, kimi, and opencode; - // true (must inline) for the other 13. + // Expected: false (may background) for codex, cursor, kimi, and kimi-code; + // true (must inline) for the other 14. const EXPECTED_FLATTEN = { antigravity: true, augment: true, @@ -290,9 +290,12 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { // kimi-cli per Kimi Code docs (dispatch.background/backgroundDispatch both // true) → NOT force-flattened. 'kimi-code': false, - // #2087: OpenCode background subagents (v1.15 param, v1.17 default-on) → - // dispatch.background/backgroundDispatch true → NOT force-flattened. - opencode: false, + // #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). + // #2087 read v1.15/v1.17 as default-on; that does not hold against current + // `dev`, so dispatch.background/backgroundDispatch are false → force-flattened. + opencode: true, // #2102: pi's dispatch.background/backgroundDispatch are both false // (undocumented background-subagent primitive) → force-flattened. pi: true, diff --git a/tests/opencode-imperative-reference.test.cjs b/tests/opencode-imperative-reference.test.cjs index ee90025cd..164c325c8 100644 --- a/tests/opencode-imperative-reference.test.cjs +++ b/tests/opencode-imperative-reference.test.cjs @@ -6,9 +6,10 @@ * * 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 the Context7- - * verified dispatch UPGRADE (background subagents, v1.15/v1.17) changes - * `shouldFlattenDispatch`, and that the migration retired the hardcoded + * 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). */ @@ -48,21 +49,34 @@ test('opencode axes classify as the programmatic-cli reference profile', () => { assert.equal(profileOf(OC_AXES), 'programmatic-cli'); }); -// -- AC4: the Context7-verified UPGRADE (background dispatch) ----------------- +// -- AC4: dispatch is synchronous — the #2087 "upgrade" is retracted (#2598) -- -test('opencode descriptor declares background dispatch true/true (v1.15/v1.17 upgrade)', () => { - assert.equal(OC_AXES.dispatch.background, true, 'background subagents (v1.15 param, v1.17 default-on)'); - assert.equal(OC_AXES.dispatch.backgroundDispatch, true); +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('background UPGRADE changes shouldFlattenDispatch: false now (may background), true for the old axes', () => { - // Post-upgrade: opencode may run subagents concurrently → NOT force-flattened. - assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), false, - 'with background:true+backgroundDispatch:true, GSD must NOT force-flatten opencode dispatch'); - // Pin the behavioral change: the pre-#2087 axes DID force-flatten. - const preUpgrade = { ...OC_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' }; - assert.equal(shouldFlattenDispatch(preUpgrade), true, - 'pre-upgrade (background:false) opencode was force-flattened — this is the behavioral change #2087 lands'); +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)'); + // Pin the retracted contract so a silent re-flip is caught: had the #2087 + // values been accurate, dispatch would NOT have been flattened. + 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'); }); test('opencode extension-event surface includes the #2087 additions (permission + session.error)', () => {