Files
openclaw/src/plugins/hook-runner-global.compose.test.ts
Alex Knight 23eadfa277 fix(plugins): compose live hook registry view for tool-call hooks
Local extension before_tool_call/after_tool_call hooks registered but
never fired after a scoped mid-run plugin activation (harness or memory
ensure) rebound the global hook runner to a narrow registry, dropping
hooks unique to the broader registry (#91918).

The runner is now created once and resolves hooks live on every dispatch
from the composed set of currently-live registries (the most recently
initialized registry, the active registry, and the pinned channel and
http-route surfaces) instead of freezing one registry. The loader's
one-shot preserve gate is removed since activation order no longer
matters. Per-plugin ownership prefers loaded records so a failed scoped
reload cannot shadow a healthy pinned registration (including a
fail-closed tool-call gate), and the explicitly initialized registry
stays highest precedence so SDK callers keep an authoritative registry.
Reuses the live-registry collector the agent-event bridge already uses
so both dispatch surfaces agree on what is live.
2026-06-18 09:39:44 +10:00

129 lines
5.2 KiB
TypeScript

/**
* Composition rules for the global hook runner's live registry view (#91918).
* These exercise the ownership/precedence/liveness decisions directly with
* mock registries, complementing the real-load kill-chain coverage in
* loader.hook-runner-live-view.test.ts.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getGlobalHookRunner,
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "./hook-runner-global.js";
import { addTestHook, createMockPluginRegistry } from "./hooks.test-helpers.js";
import type { PluginRegistry } from "./registry.js";
import {
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "./runtime.js";
function runner() {
const value = getGlobalHookRunner();
if (!value) {
throw new Error("Expected global hook runner");
}
return value;
}
afterEach(() => {
resetGlobalHookRunner();
resetPluginRuntimeStateForTest();
});
describe("global hook runner composition (#91918)", () => {
it("prefers a loaded registration over a failed scoped reload of the same plugin", () => {
const boot = createMockPluginRegistry([
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "gate" },
]);
// Scoped reload where the gate plugin failed to register: record present,
// status not loaded, no hooks.
const scopedFailure = createMockPluginRegistry([]);
scopedFailure.plugins[0].id = "gate";
scopedFailure.plugins[0].status = "error";
setActivePluginRegistry(boot);
pinActivePluginChannelRegistry(boot);
initializeGlobalHookRunner(boot);
expect(runner().hasHooks("before_tool_call")).toBe(true);
setActivePluginRegistry(scopedFailure);
initializeGlobalHookRunner(scopedFailure);
// The pinned boot registry still owns the loaded gate, so the fail-closed
// tool-call hook is not shadowed by the errored scoped record.
expect(runner().hasHooks("before_tool_call")).toBe(true);
});
it("prefers a loaded source that carries the hook over a loaded-but-hookless record", () => {
// Pinned boot registry: plugin C loaded WITH a fail-closed tool-call gate.
const boot = createMockPluginRegistry([
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "C" },
]);
// Scoped reload where C is present and loaded but registered no hooks
// (e.g. a setup-runtime channel load registers the channel, not api.on).
const scopedHookless = createMockPluginRegistry([]);
scopedHookless.plugins[0].id = "C";
scopedHookless.plugins[0].status = "loaded";
pinActivePluginChannelRegistry(boot);
setActivePluginRegistry(scopedHookless);
initializeGlobalHookRunner(scopedHookless);
// The hookless scoped record is highest precedence but must not shadow the
// pinned registration that actually carries C's gate.
expect(runner().hasHooks("before_tool_call")).toBe(true);
});
it("keeps a pinned registry with zero channels visible to hook dispatch", () => {
const hookOnlyPinned = createMockPluginRegistry([
{ hookName: "subagent_ended", handler: vi.fn(), pluginId: "hooky" },
]);
const channelActive = createMockPluginRegistry([
{ hookName: "message_sent", handler: vi.fn(), pluginId: "chan" },
]);
// Give the active registry a channel so the channel-presentation selector
// would prefer it and evict the zero-channel pinned registry — the raw
// live-registry collector must keep the pinned one regardless.
(channelActive.channels as unknown[]).push({});
setActivePluginRegistry(channelActive);
pinActivePluginChannelRegistry(hookOnlyPinned);
initializeGlobalHookRunner(channelActive);
expect(runner().hasHooks("subagent_ended")).toBe(true);
expect(runner().hasHooks("message_sent")).toBe(true);
});
it("lets an explicitly initialized registry win ownership over the active registry", () => {
const activeRegistry = createMockPluginRegistry([
{ hookName: "message_received", handler: vi.fn(), pluginId: "foo" },
]);
const sdkRegistry = createMockPluginRegistry([
{ hookName: "message_sent", handler: vi.fn(), pluginId: "foo" },
]);
setActivePluginRegistry(activeRegistry);
initializeGlobalHookRunner(sdkRegistry);
// Last-initialized highest precedence: the SDK registry owns plugin "foo",
// so its hook dispatches and the active registry's "foo" hook is shadowed.
expect(runner().hasHooks("message_sent")).toBe(true);
expect(runner().hasHooks("message_received")).toBe(false);
});
it("dispatches hooks pushed into a registry after initialization", () => {
const registry: PluginRegistry = createMockPluginRegistry([
{ hookName: "message_received", handler: vi.fn(), pluginId: "p" },
]);
setActivePluginRegistry(registry);
initializeGlobalHookRunner(registry);
// Read once so any internal caching would have settled.
expect(runner().hasHooks("message_received")).toBe(true);
expect(runner().hasHooks("message_sent")).toBe(false);
addTestHook({ registry, pluginId: "p", hookName: "message_sent", handler: vi.fn() });
// Live composition: the late registration is visible without re-init.
expect(runner().hasHooks("message_sent")).toBe(true);
});
});