feat(#1943): extensionEvents vocabulary — separate from hookEvents (extension-system surface) (#1946)

* feat(#1943): extensionEvents vocabulary — separate from hookEvents (extension-system surface)

* fix(#1943): re-export VALID_EXTENSION_EVENTS from gen-capability-registry (test import path)

* fix(#1943): regenerate capability-registry.cjs + add changeset fragment

* fix(#1943): import VALID_EXTENSION_EVENTS from validator, not gen-capability-registry (golden parity)
This commit is contained in:
Tom Boucher
2026-07-02 21:45:35 -04:00
committed by GitHub
parent d3d689a5e9
commit b51cbf96cf
11 changed files with 157 additions and 47 deletions

View File

@@ -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', () => {

View File

@@ -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']);
});
});

View File

@@ -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}`);
}
});

View File

@@ -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',