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:
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user