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 <noreply@anthropic.com>

* [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 <noreply@anthropic.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
SunnyShu
2026-08-07 09:59:27 +08:00
committed by GitHub
parent a466958d33
commit 4d87263b1b
3 changed files with 107 additions and 0 deletions
+8
View File
@@ -310,6 +310,14 @@ coarse command/lifecycle event system and show up in `openclaw hooks list` as
`plugin:<id>`. 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
@@ -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({
@@ -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) {