diff --git a/.changeset/serene-birds-rest.md b/.changeset/serene-birds-rest.md new file mode 100644 index 000000000..b5ecf5a08 --- /dev/null +++ b/.changeset/serene-birds-rest.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1946 +--- +**Host-integration descriptors now carry an `extensionEvents` vocabulary** — the extension-system event surface (OpenCode, pi) is a separate descriptor field from managed `hookEvents`, so OpenCode declares `extensionEvents:opencode` without conflicting with the hooksSurface:none invariant. (#1946) diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 0c13617f6..cc07f1e0e 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -61,6 +61,7 @@ }, "commandStyle": "slash-hyphen", "hooksSurface": "none", + "extensionEvents": "opencode", "sandboxTier": "none", "supportTier": 2, "installSurface": "settings-json", diff --git a/docs/adr/1239-gsd-embeddable-orchestration-engine.md b/docs/adr/1239-gsd-embeddable-orchestration-engine.md index 34201d562..0bc95130c 100644 --- a/docs/adr/1239-gsd-embeddable-orchestration-engine.md +++ b/docs/adr/1239-gsd-embeddable-orchestration-engine.md @@ -93,7 +93,8 @@ Phase A is **implemented** and Phases B–E have landed, so this ADR is **Accept - **`PROTOCOL_VERSION`** is an integer starting at `1`, **distinct** from the package `version` / `engines.gsd` semver (the `version`/`protocolVersion` overlap, resolved). - **`negotiateHostCapabilities(host, engine?)`** performs the in-process `initialize` exchange and enforces the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known`: an undeclared axis or an unknown / higher-`protocolVersion` value is **never** trusted — it degrades to the most-restrictive known value (fail-closed), never throws. - **`degradationFor`** is the typed Full/Degraded/Absent ladder table; **`profileOf` + `PROFILE_BASELINES`** classify each descriptor into `programmatic-cli` (9 hosts: claude, opencode, cursor, cline, hermes, qwen, kilo, trae, kimi), `declarative-cli` (7 hosts: codex, gemini, antigravity, augment, codebuddy, copilot, windsurf), or `ide` (defined as a baseline; no installed host yet — VS Code lands in Phase D). -- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); the `opencode-subset` `hookEvents` value remains reserved for the Phase D OpenCode hook-dialect consumer; `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes. +- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes. +- **`extensionEvents` vocabulary (amendment, #1943).** The OpenCode extension-system event subset is a SEPARATE descriptor field + closed vocabulary, **not** a `hookEvents` value. `hookEvents` is the *managed-hook* dialect only (`claude`/`gemini`) — the event names GSD writes into a declarative host's settings.json. `extensionEvents` is the *plugin/extension-system* event surface an imperative host exposes: `{ opencode, pi, none }` (OpenCode ~25 plugin events; pi ~30 fine-grained events; `none` = the host exposes no extension surface and the engine owns the bus, e.g. VS Code). The former `opencode-subset` `hookEvents` value was this concept misfiled; it is now `extensionEvents: opencode`. Keeping them separate preserves the `hooksSurface:"none" ⇔ no-hookEvents` invariant (OpenCode declares `extensionEvents`, not `hookEvents`). Resolved by `extensionEventSurfaceFor` in `src/host-integration.cts`, validated by `VALID_EXTENSION_EVENTS` in `capability-validator.cjs`. **Every per-host axis value is documentation-sourced, with citations.** Each of the 8 axes for all 16 installed CLIs was determined from that CLI's authoritative documentation (Context7 + the official dev docs/source), never inferred. The full per-CLI, per-axis matrix — value, source, and an evidence quote — is recorded in [`docs/reference/host-integration-capability-matrix.md`](reference/host-integration-capability-matrix.md), the deployment source-of-truth that Phases B–E build on. Where a CLI's docs genuinely do not state an axis, the descriptor carries the explicit `undocumented` sentinel (which `negotiateHostCapabilities` fail-closes on) rather than a guessed value — 22 such markers exist today, each with its search trail in the matrix. Two findings corrected this ADR's original appendix matrix: (1) current OpenAI **Codex** docs document slash-commands, so its `commandSurface` is `slash-file`, not `prose-only`; (2) several hosts run non-Node runtimes (opencode & kilo on **bun**; hermes & kimi on **python**; antigravity on **go**), so the `runtime` axis vocabulary was widened to `node|bun|sandboxed-web|python|go|rust|electron|other`. The documented `embeddingMode` split (9 imperative / 7 declarative, above) likewise reflects each CLI's real plugin/extension API, not a profile assumption. @@ -120,7 +121,7 @@ A plugin is a JS/TS module exporting an `async` function that returns a **hooks | 1 Command | slash-file commands projected to the xdg command dir (`gsd:`-namespaced); plugin may also surface entrypoints as custom `tool()`s and drive `tui.command.execute` | `commandSurface: slash-file` | none (full) | | 2 Dispatch | `mode: subagent` / `@`-mention; `subtask` is **synchronous-only** | `dispatch: { namedDispatch:true, nested:true, background:false, subagentToolkit:'full' }` | no background → waves run inline (the #853 flatten rule) | | 3 Model | per-agent `model` field on the agent `.md`; no provider `sendRequest` | `modelMode: passive` | tier routing degrades to per-agent model field | -| 4 Hooks | host `event` bus (~25 events) | `hookBus: host`; ADR-1016 dialect = **`opencode-subset`** | session/tool-scoped only — see gap below | +| 4 Hooks | host `event` bus (~25 events) | `hookBus: host`; `extensionEvents: opencode` (Phase D / #1943) | session/tool-scoped only — see gap below | | 5 State | filesystem `.planning/` + config under xdg `~/.config/opencode`; `opencode-jsonc` permissions sidecar (`permissionWriter: 'opencode'`) | `stateIO: filesystem` | `configHome` write-confinement applies | | 6 Artifact | native Agent Skills + `@agent` subagents + slash commands | — | none (full) | @@ -128,7 +129,7 @@ A plugin is a JS/TS module exporting an `async` function that returns a **hooks ### The load-bearing gap: the loop is phase-scoped, the bus is session-scoped -OpenCode's bus fires on **sessions, tools, files, and permissions** — never on **workflow phases**. GSD's 12 loop extension points (`plan:pre`, `verify:post`, `ship:post`…) have **no event on this bus**. So the imperative adapter for OpenCode cannot drive the loop *from host events*; the engine must own phase sequencing internally and treat OpenCode's bus as a **subset hook surface** (exactly what the ADR-1016 `opencode-subset` dialect already encodes). Concretely: +OpenCode's bus fires on **sessions, tools, files, and permissions** — never on **workflow phases**. GSD's 12 loop extension points (`plan:pre`, `verify:post`, `ship:post`…) have **no event on this bus**. So the imperative adapter for OpenCode cannot drive the loop *from host events*; the engine must own phase sequencing internally and treat OpenCode's bus as a **subset extension-event surface** — exactly what the `extensionEvents: opencode` vocabulary encodes (amendment #1943; formerly misfiled as a `hookEvents` value `opencode-subset`). Concretely: - **Steps, gates, and most contributions fire from GSD's own workflow/command invocation (point 1), engine-side** — not from the host bus. The plugin invokes `gsd-tools.cjs` (via `$` or the companion MCP server) and the engine runs the loop resolver. - **Only the contributions that align with a real host event bind to the bus.** The clean case is memory: a MemPalace-style capability's capture/recall already keys on `discuss:post`/`plan:post`/`verify:post`; those can *additionally* bind to `experimental.session.compacting` so memory persists across OpenCode's compaction — a concrete win the host gives us for free. diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 7715e6075..560716978 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -1690,6 +1690,7 @@ const capabilities = { }, "commandStyle": "slash-hyphen", "hooksSurface": "none", + "extensionEvents": "opencode", "sandboxTier": "none", "supportTier": 2, "installSurface": "settings-json", @@ -4227,6 +4228,7 @@ const runtimes = { }, "commandStyle": "slash-hyphen", "hooksSurface": "none", + "extensionEvents": "opencode", "sandboxTier": "none", "supportTier": 2, "installSurface": "settings-json", diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 6d936b90d..f8e352135 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -704,7 +704,12 @@ const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'mark const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']); const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']); const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']); -const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']); +const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']); +// extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943). +// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the +// plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the +// host exposes no extension surface (engine owns the bus, e.g. VS Code). +const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']); const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); @@ -978,7 +983,9 @@ function validateRuntimeBody(cap) { ); } - // hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5) + // hookEvents — optional; if present must be in closed enum (ADR-1016 Decision 5). + // Managed-hook dialect only (claude/gemini). The OpenCode extension-system event + // subset is NOT a hookEvents value — it is `extensionEvents` (ADR-1239 / #1943). if (r.hookEvents !== undefined) { if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') { errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name'); @@ -990,6 +997,20 @@ function validateRuntimeBody(cap) { } } + // extensionEvents — optional; the extension-system event dialect (ADR-1239 amendment / #1943). + // Distinct from hookEvents. Only imperative-embedding hosts (with a plugin/extension + // API) set it; declarative hosts do not. + if (r.extensionEvents !== undefined) { + if (r.extensionEvents === '__proto__' || r.extensionEvents === 'constructor' || r.extensionEvents === 'prototype') { + errors.push('runtime.extensionEvents "' + r.extensionEvents + '" is a reserved name'); + } else if (!VALID_EXTENSION_EVENTS.has(r.extensionEvents)) { + errors.push( + 'runtime.extensionEvents must be one of: ' + [...VALID_EXTENSION_EVENTS].join(', ') + + ' (got: ' + JSON.stringify(r.extensionEvents) + ')', + ); + } + } + // sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier) if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') { errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name'); @@ -2225,6 +2246,7 @@ module.exports = { VALID_COMMAND_STYLES, VALID_HOOKS_SURFACES, VALID_HOOK_EVENTS, + VALID_EXTENSION_EVENTS, VALID_SANDBOX_TIERS, VALID_ARTIFACT_KIND_NAMES, VALID_ARTIFACT_NESTINGS, diff --git a/src/host-integration-sdk.cts b/src/host-integration-sdk.cts index 73c52ece7..2f90c2553 100644 --- a/src/host-integration-sdk.cts +++ b/src/host-integration-sdk.cts @@ -40,6 +40,7 @@ const SDK = Object.freeze({ profileOf: hostIntegration.profileOf, degradationFor: hostIntegration.degradationFor, hookEventSurfaceFor: hostIntegration.hookEventSurfaceFor, + extensionEventSurfaceFor: hostIntegration.extensionEventSurfaceFor, shouldFlattenDispatch: hostIntegration.shouldFlattenDispatch, // ── Embedding + engine adapters ────────────────────────────────────────── diff --git a/src/host-integration.cts b/src/host-integration.cts index 3a93fe76b..1a792978c 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -513,39 +513,66 @@ function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean { } // --------------------------------------------------------------------------- -// Hook-event surface per hookEvents dialect (ADR-1239 Phase D / #1682) +// Managed-hook event surface per hookEvents dialect (ADR-1239 / ADR-1016) // --------------------------------------------------------------------------- -// The set of host-fireable hook events for each hookEvents dialect. This is the -// CONSUMER of the reserved 'opencode-subset' dialect (previously zero consumers): -// it lets the engine ask which events a host's bus actually exposes, so it knows -// workflow-phase hooks (plan:pre / verify:post / …) are NOT available on an -// opencode-subset host and the engine must own phase sequencing internally -// (ADR-1239 §OpenCode binding). 'claude' = full Claude surface; 'gemini' = -// Gemini's BeforeTool/AfterTool family; 'opencode-subset' = OpenCode's -// session/tool/file subset (no workflow-phase events). +// Host-fireable MANAGED-hook events per `hookEvents` dialect. `hookEvents` is the +// managed-hook dialect — the event names GSD writes into a DECLARATIVE host's +// settings.json (claude = SessionStart/PreToolUse/…; gemini = BeforeTool/AfterTool). +// This is DISTINCT from the extension-system event surface (below): a host's +// plugin/extension API fires a different, plugin-owned event set. The two must +// not be conflated (ADR-1239 amendment / #1943 — the former 'opencode-subset' +// `hookEvents` value was this conflation; it is now `extensionEvents: opencode`). const HOOK_EVENT_SURFACES: Readonly> = Object.freeze({ claude: Object.freeze(['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd', 'PreCompact']), gemini: Object.freeze(['SessionStart', 'BeforeTool', 'AfterTool', 'SessionEnd']), - 'opencode-subset': Object.freeze([ - 'session.created', 'session.idle', 'experimental.session.compacting', - 'tool.execute.before', 'tool.execute.after', 'file.edited', - ]), }); /** - * Resolve the host-fireable hook-event surface for a hookEvents dialect. + * Resolve the managed-hook event surface for a `hookEvents` dialect. * Returns null for unknown/missing dialects (fail-closed). Pure, never throws. - * - * A non-null result for 'opencode-subset' is what makes that dialect a CONSUMED - * value rather than reserved vocab: callers can ask `hookEventSurfaceFor('opencode-subset')` - * and learn the host fires no workflow-phase events. */ function hookEventSurfaceFor(hookEvents: unknown): readonly string[] | null { if (typeof hookEvents !== 'string') return null; return HOOK_EVENT_SURFACES[hookEvents] || null; } +// --------------------------------------------------------------------------- +// Extension-system event surface (ADR-1239 amendment / #1943) +// --------------------------------------------------------------------------- + +// The events a host's PLUGIN/EXTENSION API exposes — for imperative-embedding +// hosts that load GSD as a plugin. This is a SEPARATE vocabulary + descriptor +// field (`extensionEvents`) from `hookEvents`: hookEvents = the managed-hook +// dialect (declarative hosts' settings.json); extensionEvents = the plugin-owned +// event subset (imperative hosts' extension API). They are not the same thing. +// +// Values are documentation-sourced (ADR-1239 §research): OpenCode ~25 plugin +// events (session/tool/file/permission); pi ~30 fine-grained extension events; +// 'none' = the host exposes no extension surface and the engine owns the bus +// (VS Code). Declarative hosts (no plugin API) do not set `extensionEvents`. +const EXTENSION_EVENT_SURFACES: Readonly> = Object.freeze({ + opencode: Object.freeze([ + 'session.created', 'session.idle', 'experimental.session.compacting', + 'tool.execute.before', 'tool.execute.after', 'file.edited', + ]), + pi: Object.freeze(['tool_call']), + none: Object.freeze([]), +}); + +/** + * Resolve the extension-system event surface for an `extensionEvents` dialect. + * Returns null for unknown/missing dialects (fail-closed). Pure, never throws. + * + * A non-null result is what makes an `extensionEvents` value a CONSUMED value + * rather than reserved vocab. For 'opencode' it carries NO workflow-phase events + * — the engine owns phase sequencing internally on such hosts (ADR-1239 §OpenCode). + */ +function extensionEventSurfaceFor(extensionEvents: unknown): readonly string[] | null { + if (typeof extensionEvents !== 'string') return null; + return EXTENSION_EVENT_SURFACES[extensionEvents] || null; +} + // --------------------------------------------------------------------------- // Module export (CommonJS — matches existing src/*.cts pattern) // --------------------------------------------------------------------------- @@ -558,9 +585,11 @@ export = { PROFILE_BASELINES, DEFAULT_ENGINE, HOOK_EVENT_SURFACES, + EXTENSION_EVENT_SURFACES, degradationFor, profileOf, negotiateHostCapabilities, shouldFlattenDispatch, hookEventSurfaceFor, + extensionEventSurfaceFor, }; diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 2ae04f743..2f64d08f0 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -3200,6 +3200,11 @@ const { VALID_ARTIFACT_NESTINGS, } = require('../scripts/gen-capability-registry.cjs'); +// VALID_EXTENSION_EVENTS is imported from the validator directly (not from +// gen-capability-registry) to avoid changing gen-capability-registry.cjs — that +// file is an installed artifact captured by golden-install-parity (#1943). +const VALID_EXTENSION_EVENTS = capValidatorModule.VALID_EXTENSION_EVENTS; + const RUNTIME_IDS = [ 'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode', 'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes', @@ -3386,6 +3391,14 @@ describe('ADR-1016 phase 5a: sample axis value assertions', () => { ); }); + test('opencode: extensionEvents === opencode (the extension-system event dialect — #1943)', () => { + const { capMap } = loadAndValidate(new Set()); + const registry = buildRegistry(capMap); + const rt = registry.runtimes['opencode'].runtime; + assert.strictEqual(rt.extensionEvents, 'opencode', + 'opencode declares the extension-system event subset via extensionEvents (NOT hookEvents)'); + }); + test('kilo: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); @@ -3952,12 +3965,22 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => { assert.strictEqual(VALID_HOOKS_SURFACES.size, 6); }); - test('VALID_HOOK_EVENTS has exactly 3 values', () => { + test('VALID_HOOK_EVENTS has exactly 2 managed-hook dialects (claude/gemini)', () => { assert.ok(VALID_HOOK_EVENTS instanceof Set); - for (const v of ['claude', 'gemini', 'opencode-subset']) { + for (const v of ['claude', 'gemini']) { assert.ok(VALID_HOOK_EVENTS.has(v), 'VALID_HOOK_EVENTS must contain "' + v + '"'); } - assert.strictEqual(VALID_HOOK_EVENTS.size, 3); + assert.strictEqual(VALID_HOOK_EVENTS.size, 2); + assert.ok(!VALID_HOOK_EVENTS.has('opencode-subset'), + 'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)'); + }); + + test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/none — #1943)', () => { + assert.ok(VALID_EXTENSION_EVENTS instanceof Set); + for (const v of ['opencode', 'pi', 'none']) { + assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"'); + } + assert.strictEqual(VALID_EXTENSION_EVENTS.size, 3); }); test('VALID_SANDBOX_TIERS has exactly 2 values', () => { diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index a594a3f23..a08665e81 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -23,34 +23,60 @@ const { negotiateHostCapabilities, hookEventSurfaceFor, HOOK_EVENT_SURFACES, + extensionEventSurfaceFor, + EXTENSION_EVENT_SURFACES, } = hi; -describe('hookEventSurfaceFor (hookEvents dialect consumer — #1682)', () => { - test('returns the full Claude surface for "claude"', () => { +describe('hookEventSurfaceFor (MANAGED-hook dialect consumer — claude/gemini only)', () => { + test('returns the full Claude managed-hook surface for "claude"', () => { const s = hookEventSurfaceFor('claude'); assert.ok(s && s.includes('PreToolUse') && s.includes('PostToolUse') && s.includes('Stop')); }); - test('returns the Gemini BeforeTool/AfterTool surface for "gemini"', () => { + test('returns the Gemini BeforeTool/AfterTool managed-hook surface for "gemini"', () => { const s = hookEventSurfaceFor('gemini'); assert.ok(s && s.includes('BeforeTool') && s.includes('AfterTool')); }); - test('CONSUMES "opencode-subset": OpenCode session/tool/file subset with NO workflow-phase events', () => { - const s = hookEventSurfaceFor('opencode-subset'); - assert.ok(s, 'opencode-subset must resolve (non-null) — it is consumed, not reserved'); - assert.ok(s.includes('experimental.session.compacting')); - assert.ok(s.includes('session.idle')); - assert.ok(s.includes('tool.execute.before') && s.includes('tool.execute.after')); - assert.ok(!s.some((e) => /plan:|verify:|ship:|execute:/.test(e)), - 'opencode-subset fires no workflow-phase events (engine owns phase sequencing)'); + test('hookEvents is the MANAGED-hook dialect only — opencode-subset is NOT here (#1943)', () => { + assert.equal(hookEventSurfaceFor('opencode-subset'), null, + 'opencode-subset is not a hookEvents value — it moved to the extensionEvents vocabulary'); }); test('returns null for unknown / missing / non-string dialect (fail-closed)', () => { assert.equal(hookEventSurfaceFor('nope'), null); assert.equal(hookEventSurfaceFor(undefined), null); assert.equal(hookEventSurfaceFor(123), null); }); - test('HOOK_EVENT_SURFACES is frozen + covers exactly the 3 dialects', () => { + test('HOOK_EVENT_SURFACES is frozen + covers exactly the 2 managed-hook dialects', () => { assert.equal(Object.isFrozen(HOOK_EVENT_SURFACES), true); - assert.deepEqual(Object.keys(HOOK_EVENT_SURFACES).sort(), ['claude', 'gemini', 'opencode-subset']); + assert.deepEqual(Object.keys(HOOK_EVENT_SURFACES).sort(), ['claude', 'gemini']); + }); +}); + +describe('extensionEventSurfaceFor (extension-system event dialect — #1943)', () => { + test('opencode = OpenCode plugin event subset with NO workflow-phase events', () => { + const s = extensionEventSurfaceFor('opencode'); + assert.ok(s, 'opencode must resolve (non-null) — it is a consumed extensionEvents value'); + assert.ok(s.includes('experimental.session.compacting')); + assert.ok(s.includes('session.idle')); + assert.ok(s.includes('tool.execute.before') && s.includes('tool.execute.after')); + assert.ok(!s.some((e) => /plan:|verify:|ship:/.test(e)), + 'opencode extension events include no workflow-phase events (engine owns phase sequencing)'); + }); + test('pi resolves (extension-system dialect)', () => { + const s = extensionEventSurfaceFor('pi'); + assert.ok(Array.isArray(s), 'pi is a consumed extensionEvents value'); + }); + test('none = empty surface (host exposes no extension events; engine owns the bus)', () => { + assert.deepEqual(extensionEventSurfaceFor('none'), []); + }); + test('returns null for unknown / missing / non-string dialect (fail-closed)', () => { + assert.equal(extensionEventSurfaceFor('opencode-subset'), null, + 'the old opencode-subset name is gone — use extensionEventSurfaceFor("opencode")'); + assert.equal(extensionEventSurfaceFor('nope'), null); + assert.equal(extensionEventSurfaceFor(undefined), null); + }); + test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/none', () => { + assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true); + assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['none', 'opencode', 'pi']); }); }); diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 34ae424d6..8739e58e9 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -324,13 +324,13 @@ test('experimental.session.compacting injects the GSD state breadcrumb', async ( assert.ok(output.context.some((c) => /GSD/.test(c)), 'breadcrumb is GSD-tagged'); }); -test('plugin implements the full declared opencode-subset hook surface (Claude parity)', async (t) => { - const { hookEventSurfaceFor } = require('../gsd-core/bin/lib/host-integration.cjs'); - const surface = hookEventSurfaceFor('opencode-subset'); - assert.ok(surface, 'opencode-subset is a consumed dialect (non-null surface)'); +test('plugin implements the full declared opencode extension-event surface (Claude parity — #1943)', async (t) => { + const { extensionEventSurfaceFor } = require('../gsd-core/bin/lib/host-integration.cjs'); + const surface = extensionEventSurfaceFor('opencode'); + assert.ok(surface, 'opencode is a consumed extensionEvents dialect (non-null surface)'); // The engine — not the host bus — owns workflow-phase sequencing on this host. - assert.ok(!surface.some((e) => /plan:|verify:|ship:|execute:/.test(e)), - 'opencode-subset fires no workflow-phase events'); + assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e)), + 'opencode extension events include no workflow-phase events'); const { mod } = buildInstalledLayout(t, {}); const handlers = await mod.server({ directory: process.cwd() }); @@ -344,7 +344,7 @@ test('plugin implements the full declared opencode-subset hook surface (Claude p for (const ev of surface) { const covered = typeof handlers[ev] === 'function' || ev === 'session.created' || ev === 'session.idle' || ev === 'file.edited'; - assert.ok(covered, `plugin covers opencode-subset event: ${ev}`); + assert.ok(covered, `plugin covers opencode extension event: ${ev}`); } }); diff --git a/tests/sdk-smoke.test.cjs b/tests/sdk-smoke.test.cjs index abd3c6834..f78d88f20 100644 --- a/tests/sdk-smoke.test.cjs +++ b/tests/sdk-smoke.test.cjs @@ -23,7 +23,7 @@ test('SDK exports the full public surface a host-plugin author needs', () => { const fns = [ 'negotiateHostCapabilities', 'profileOf', 'degradationFor', - 'hookEventSurfaceFor', 'shouldFlattenDispatch', + 'hookEventSurfaceFor', 'extensionEventSurfaceFor', 'shouldFlattenDispatch', 'createDeclarativeAdapter', 'createImperativeAdapter', 'createModelAdapter', 'createHookBus', 'createStateIO', 'buildHandshakeRequest', 'handleHandshakeRequest',