From 4d87263b1b60f595de7462d80dd3563f323073d3 Mon Sep 17 00:00:00 2001 From: SunnyShu Date: Fri, 7 Aug 2026 09:59:27 +0800 Subject: [PATCH] fix(plugins): warn when registerHook uses a typed hook event name (#118601) * [AI] fix(plugins): warn when registerHook uses a typed hook event name api.registerHook registers into the legacy internal-hook path, while typed lifecycle events (before_tool_call, message_received, ...) are dispatched exclusively by the typed hook runner. Registrations under typed names silently never fired since #117372 moved the write to legacyInternalHooks. Emit a registration diagnostic pointing to the public api.on(...) API so plugin authors see the no-op at load time instead of trusting a false "loaded". Related to #116965 Co-Authored-By: deepseek-v4-flash * [AI] fix(plugins): point registerHook comment at public api.on API Condense the inline comment in createToolHookRegistrars and replace the internal registerTypedHook migration reference with the public api.on(...) API, matching the emitted warning and hooks docs. Behavior and tests unchanged. --------- Co-authored-by: deepseek-v4-flash Co-authored-by: Patrick Erichsen --- docs/automation/hooks.md | 8 ++ src/plugins/loader.registration.test-utils.ts | 83 +++++++++++++++++++ .../registry-registrars-tools-hooks.ts | 16 ++++ 3 files changed, 107 insertions(+) diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index d0087d8c4f33..7d7c633c0668 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -310,6 +310,14 @@ coarse command/lifecycle event system and show up in `openclaw hooks list` as `plugin:`. Use those for side effects and compatibility with hook packs, not for ordered middleware or policy gates. +The legacy Plugin SDK `api.registerHook` registers into the internal event +system only (`command:new`, `gateway:startup`, `message:received`, ...). Typed +lifecycle event names such as `before_tool_call`, `message_received`, or +`session_start` are dispatched exclusively by the typed hook runner and are +**not** invoked through `registerHook`. Registering a typed name with +`registerHook` emits a registration warning pointing to the public `api.on(...)` +API as the replacement; it never silently no-ops. + For the complete plugin hook reference, see [Plugin hooks](/plugins/hooks). ## Configuration diff --git a/src/plugins/loader.registration.test-utils.ts b/src/plugins/loader.registration.test-utils.ts index c7af149c21cb..4fcb97a2d793 100644 --- a/src/plugins/loader.registration.test-utils.ts +++ b/src/plugins/loader.registration.test-utils.ts @@ -116,6 +116,89 @@ describe("loadOpenClawPlugins", () => { ); }); + it("warns when registerHook is used with a typed hook event name", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "typed-name-legacy-register", + filename: "typed-name-legacy-register.cjs", + body: `module.exports = { + id: "typed-name-legacy-register", + register(api) { + api.registerHook(["before_tool_call", "message_received"], () => {}, { + name: "typed-name-legacy-register", + }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["typed-name-legacy-register"], + }, + }, + onlyPluginIds: ["typed-name-legacy-register"], + }); + + expect(registry.legacyInternalHooks.map((entry) => entry.event)).toEqual([ + "before_tool_call", + "message_received", + ]); + expect( + registry.diagnostics + .filter( + (diagnostic) => + diagnostic.pluginId === "typed-name-legacy-register" && + diagnostic.level === "warn" && + diagnostic.message.includes("dispatched by the typed hook runner only"), + ) + .map((diagnostic) => diagnostic.message), + ).toEqual([ + expect.stringContaining('Use api.on("before_tool_call", ...)'), + expect.stringContaining('Use api.on("message_received", ...)'), + ]); + }); + + it("keeps legacy type:action events diagnostic-free in registerHook", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "legacy-type-action-register", + filename: "legacy-type-action-register.cjs", + body: `module.exports = { + id: "legacy-type-action-register", + register(api) { + api.registerHook("gateway:startup", () => {}, { name: "legacy-startup" }); + api.registerHook("command:new", () => {}, { name: "legacy-command" }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["legacy-type-action-register"], + }, + }, + onlyPluginIds: ["legacy-type-action-register"], + }); + + expect(registry.legacyInternalHooks.map((entry) => entry.event)).toEqual([ + "gateway:startup", + "command:new", + ]); + expect( + registry.diagnostics.some((diagnostic) => + diagnostic.message.includes("dispatched by the typed hook runner only"), + ), + ).toBe(false); + }); + it("runs consecutive plugin hook handlers with shared mutable context but isolated plugin config", async () => { useNoBundledPlugins(); const first = writePlugin({ diff --git a/src/plugins/registry-registrars-tools-hooks.ts b/src/plugins/registry-registrars-tools-hooks.ts index 8c69b3169890..8d31b0b8bf13 100644 --- a/src/plugins/registry-registrars-tools-hooks.ts +++ b/src/plugins/registry-registrars-tools-hooks.ts @@ -312,6 +312,22 @@ export function createToolHookRegistrars(state: PluginRegistryState) { pluginConfig: unknown, ) => { const normalizedEvents = normalizeStringEntries(Array.isArray(events) ? events : [events]); + // Typed lifecycle names (before_tool_call, message_received, ...) are dispatched only by + // the typed hook runner; registerHook uses the legacy internal-hook path so they never + // fire. Warn so authors move to `api.on(...)` instead of trusting a false "loaded". + for (const event of normalizedEvents) { + if (isPluginHookName(event)) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: + `hook event "${event}" is dispatched by the typed hook runner only; ` + + `api.registerHook registrations for it are not invoked. ` + + `Use api.on("${event}", ...) instead.`, + }); + } + } const entry = opts?.entry ?? null; const hookName = entry?.hook.name ?? opts?.name?.trim(); if (!hookName) {