From bbcd1852150d9e0f9e02b534faa6640681c0561c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 22 Apr 2026 11:36:07 -0700 Subject: [PATCH] refactor(hooks): centralize bundled subagent hook wiring --- extensions/discord/index.ts | 23 +-------- extensions/discord/src/subagent-hooks.test.ts | 20 ++++---- extensions/discord/src/subagent-hooks.ts | 6 --- extensions/discord/subagent-hooks-api.ts | 30 +++++++++++- extensions/feishu/index.ts | 23 +-------- extensions/feishu/src/subagent-hooks.test.ts | 48 +++++++++---------- extensions/feishu/src/subagent-hooks.ts | 7 --- extensions/feishu/subagent-hooks-api.ts | 31 ++++++++++++ 8 files changed, 97 insertions(+), 91 deletions(-) create mode 100644 extensions/feishu/subagent-hooks-api.ts diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 9a5d809135c..92c8ee99992 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,13 +1,5 @@ import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; - -type DiscordSubagentHooksModule = typeof import("./subagent-hooks-api.js"); - -let discordSubagentHooksPromise: Promise | null = null; - -function loadDiscordSubagentHooksModule() { - discordSubagentHooksPromise ??= import("./subagent-hooks-api.js"); - return discordSubagentHooksPromise; -} +import { registerDiscordSubagentHooks } from "./subagent-hooks-api.js"; export default defineBundledChannelEntry({ id: "discord", @@ -27,17 +19,6 @@ export default defineBundledChannelEntry({ exportName: "inspectDiscordReadOnlyAccount", }, registerFull(api) { - api.on("subagent_spawning", async (event) => { - const { handleDiscordSubagentSpawning } = await loadDiscordSubagentHooksModule(); - return await handleDiscordSubagentSpawning(api, event); - }); - api.on("subagent_ended", async (event) => { - const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule(); - handleDiscordSubagentEnded(event); - }); - api.on("subagent_delivery_target", async (event) => { - const { handleDiscordSubagentDeliveryTarget } = await loadDiscordSubagentHooksModule(); - return handleDiscordSubagentDeliveryTarget(event); - }); + registerDiscordSubagentHooks(api); }, }); diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 65607cd480c..672705d36fa 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -38,7 +38,7 @@ const hookMocks = vi.hoisted(() => ({ unbindThreadBindingsBySessionKey: vi.fn(() => []), })); -let registerDiscordSubagentHooks: typeof import("./subagent-hooks.js").registerDiscordSubagentHooks; +let registerDiscordSubagentHooks: typeof import("../subagent-hooks-api.js").registerDiscordSubagentHooks; vi.mock("./accounts.js", () => ({ resolveDiscordAccount: hookMocks.resolveDiscordAccount, @@ -66,7 +66,7 @@ function registerHandlersForTest( }); } -function resolveSubagentDeliveryTargetForTest(requesterOrigin: { +async function resolveSubagentDeliveryTargetForTest(requesterOrigin: { channel: string; accountId: string; to: string; @@ -74,7 +74,7 @@ function resolveSubagentDeliveryTargetForTest(requesterOrigin: { }) { const handlers = registerHandlersForTest(); const handler = getRequiredHookHandler(handlers, "subagent_delivery_target"); - return handler( + return await handler( { childSessionKey: "agent:main:subagent:child", requesterSessionKey: "agent:main:main", @@ -167,7 +167,7 @@ async function expectSubagentSpawningError(params?: { describe("discord subagent hook handlers", () => { beforeAll(async () => { - ({ registerDiscordSubagentHooks } = await import("./subagent-hooks.js")); + ({ registerDiscordSubagentHooks } = await import("../subagent-hooks-api.js")); }); beforeEach(() => { @@ -303,11 +303,11 @@ describe("discord subagent hook handlers", () => { expect(errorText).toMatch(/unable to create or bind/i); }); - it("unbinds thread routing on subagent_ended", () => { + it("unbinds thread routing on subagent_ended", async () => { const handlers = registerHandlersForTest(); const handler = getRequiredHookHandler(handlers, "subagent_ended"); - handler( + await handler( { targetSessionKey: "agent:main:subagent:child", targetKind: "subagent", @@ -328,11 +328,11 @@ describe("discord subagent hook handlers", () => { }); }); - it("resolves delivery target from matching bound thread", () => { + it("resolves delivery target from matching bound thread", async () => { hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([ { accountId: "work", threadId: "777" }, ]); - const result = resolveSubagentDeliveryTargetForTest({ + const result = await resolveSubagentDeliveryTargetForTest({ channel: "discord", accountId: "work", to: "channel:123", @@ -354,12 +354,12 @@ describe("discord subagent hook handlers", () => { }); }); - it("keeps original routing when delivery target is ambiguous", () => { + it("keeps original routing when delivery target is ambiguous", async () => { hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([ { accountId: "work", threadId: "777" }, { accountId: "work", threadId: "888" }, ]); - const result = resolveSubagentDeliveryTargetForTest({ + const result = await resolveSubagentDeliveryTargetForTest({ channel: "discord", accountId: "work", to: "channel:123", diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index 2e6e7a54fbc..00c4ef93d8a 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -212,9 +212,3 @@ export function handleDiscordSubagentDeliveryTarget( }, }; } - -export function registerDiscordSubagentHooks(api: OpenClawPluginApi) { - api.on("subagent_spawning", (event) => handleDiscordSubagentSpawning(api, event)); - api.on("subagent_ended", (event) => handleDiscordSubagentEnded(event)); - api.on("subagent_delivery_target", (event) => handleDiscordSubagentDeliveryTarget(event)); -} diff --git a/extensions/discord/subagent-hooks-api.ts b/extensions/discord/subagent-hooks-api.ts index 5bc81f666c6..18c594b1222 100644 --- a/extensions/discord/subagent-hooks-api.ts +++ b/extensions/discord/subagent-hooks-api.ts @@ -1,5 +1,31 @@ -// Subagent hooks live behind a dedicated barrel so the bundled entry can lazy -// load only the handlers it needs. +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract"; + +type DiscordSubagentHooksModule = typeof import("./src/subagent-hooks.js"); + +let discordSubagentHooksPromise: Promise | null = null; + +function loadDiscordSubagentHooksModule() { + discordSubagentHooksPromise ??= import("./src/subagent-hooks.js"); + return discordSubagentHooksPromise; +} + +// Subagent hooks live behind a dedicated barrel so the bundled entry can +// register one stable hook wiring path while keeping the handler module lazy. +export function registerDiscordSubagentHooks(api: OpenClawPluginApi): void { + api.on("subagent_spawning", async (event) => { + const { handleDiscordSubagentSpawning } = await loadDiscordSubagentHooksModule(); + return await handleDiscordSubagentSpawning(api, event); + }); + api.on("subagent_ended", async (event) => { + const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule(); + handleDiscordSubagentEnded(event); + }); + api.on("subagent_delivery_target", async (event) => { + const { handleDiscordSubagentDeliveryTarget } = await loadDiscordSubagentHooksModule(); + return handleDiscordSubagentDeliveryTarget(event); + }); +} + export { handleDiscordSubagentDeliveryTarget, handleDiscordSubagentEnded, diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 9a54b361366..cd004c37eff 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -3,15 +3,7 @@ import { loadBundledEntryExportSync, } from "openclaw/plugin-sdk/channel-entry-contract"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract"; - -type FeishuSubagentHooksModule = typeof import("./api.js"); - -let feishuSubagentHooksPromise: Promise | null = null; - -function loadFeishuSubagentHooksModule() { - feishuSubagentHooksPromise ??= import("./api.js"); - return feishuSubagentHooksPromise; -} +import { registerFeishuSubagentHooks } from "./subagent-hooks-api.js"; function registerFeishuDocTools(api: OpenClawPluginApi) { const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, { @@ -79,18 +71,7 @@ export default defineBundledChannelEntry({ exportName: "setFeishuRuntime", }, registerFull(api) { - api.on("subagent_spawning", async (event, ctx) => { - const { handleFeishuSubagentSpawning } = await loadFeishuSubagentHooksModule(); - return await handleFeishuSubagentSpawning(event, ctx); - }); - api.on("subagent_delivery_target", async (event) => { - const { handleFeishuSubagentDeliveryTarget } = await loadFeishuSubagentHooksModule(); - return handleFeishuSubagentDeliveryTarget(event); - }); - api.on("subagent_ended", async (event) => { - const { handleFeishuSubagentEnded } = await loadFeishuSubagentHooksModule(); - handleFeishuSubagentEnded(event); - }); + registerFeishuSubagentHooks(api); registerFeishuDocTools(api); registerFeishuChatTools(api); registerFeishuWikiTools(api); diff --git a/extensions/feishu/src/subagent-hooks.test.ts b/extensions/feishu/src/subagent-hooks.test.ts index a379ec68883..84ef8a8697c 100644 --- a/extensions/feishu/src/subagent-hooks.test.ts +++ b/extensions/feishu/src/subagent-hooks.test.ts @@ -4,7 +4,7 @@ import { registerHookHandlersForTest, } from "../../../test/helpers/plugins/subagent-hooks.js"; import type { ClawdbotConfig, OpenClawPluginApi } from "../runtime-api.js"; -import { registerFeishuSubagentHooks } from "./subagent-hooks.js"; +import { registerFeishuSubagentHooks } from "../subagent-hooks-api.js"; import { createFeishuThreadBindingManager, __testing as threadBindingTesting, @@ -51,7 +51,7 @@ describe("feishu subagent hook handlers", () => { expect(result).toEqual({ status: "ok", threadBindingReady: true }); const deliveryTargetHandler = getRequiredHookHandler(handlers, "subagent_delivery_target"); - expect( + await expect( deliveryTargetHandler( { childSessionKey: "agent:main:subagent:child", @@ -65,7 +65,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toEqual({ + ).resolves.toEqual({ origin: { channel: "feishu", accountId: "work", @@ -89,7 +89,7 @@ describe("feishu subagent hook handlers", () => { }, }); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:chat-dm-child", @@ -103,7 +103,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toEqual({ + ).resolves.toEqual({ origin: { channel: "feishu", accountId: "work", @@ -136,7 +136,7 @@ describe("feishu subagent hook handlers", () => { ); expect(result).toEqual({ status: "ok", threadBindingReady: true }); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:topic-child", @@ -151,7 +151,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toEqual({ + ).resolves.toEqual({ origin: { channel: "feishu", accountId: "work", @@ -205,7 +205,7 @@ describe("feishu subagent hook handlers", () => { parentConversationId: "oc_group_chat", }, ]); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:sender-child", @@ -220,7 +220,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toEqual({ + ).resolves.toEqual({ origin: { channel: "feishu", accountId: "work", @@ -267,7 +267,7 @@ describe("feishu subagent hook handlers", () => { {}, ); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:shared", @@ -281,7 +281,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toEqual({ + ).resolves.toEqual({ origin: { channel: "feishu", accountId: "work", @@ -335,7 +335,7 @@ describe("feishu subagent hook handlers", () => { error: expect.stringContaining("direct messages or topic conversations"), }); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:ambiguous-child", @@ -350,7 +350,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toBeUndefined(); + ).resolves.toBeUndefined(); }); it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => { @@ -398,7 +398,7 @@ describe("feishu subagent hook handlers", () => { error: expect.stringContaining("direct messages or topic conversations"), }); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:mixed-topic-child", @@ -413,7 +413,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toBeUndefined(); + ).resolves.toBeUndefined(); }); it("no-ops for non-Feishu channels and non-threaded spawns", async () => { @@ -456,7 +456,7 @@ describe("feishu subagent hook handlers", () => { ), ).resolves.toBeUndefined(); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:child", @@ -470,9 +470,9 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toBeUndefined(); + ).resolves.toBeUndefined(); - expect( + await expect( endedHandler( { targetSessionKey: "agent:main:subagent:child", @@ -482,7 +482,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toBeUndefined(); + ).resolves.toBeUndefined(); }); it("returns an error for unsupported non-topic Feishu group conversations", async () => { @@ -532,7 +532,7 @@ describe("feishu subagent hook handlers", () => { {}, ); - endedHandler( + await endedHandler( { targetSessionKey: "agent:main:subagent:child", targetKind: "subagent", @@ -542,7 +542,7 @@ describe("feishu subagent hook handlers", () => { {}, ); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:child", @@ -556,7 +556,7 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toBeUndefined(); + ).resolves.toBeUndefined(); }); it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => { @@ -584,7 +584,7 @@ describe("feishu subagent hook handlers", () => { error: expect.stringContaining("monitor is not active"), }); - expect( + await expect( deliveryHandler( { childSessionKey: "agent:main:subagent:no-manager", @@ -598,6 +598,6 @@ describe("feishu subagent hook handlers", () => { }, {}, ), - ).toBeUndefined(); + ).resolves.toBeUndefined(); }); }); diff --git a/extensions/feishu/src/subagent-hooks.ts b/extensions/feishu/src/subagent-hooks.ts index 251c38fb2dc..59184cd82ba 100644 --- a/extensions/feishu/src/subagent-hooks.ts +++ b/extensions/feishu/src/subagent-hooks.ts @@ -2,7 +2,6 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; -import type { OpenClawPluginApi } from "../runtime-api.js"; import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js"; import { normalizeFeishuTarget } from "./targets.js"; import { getFeishuThreadBindingManager } from "./thread-bindings.js"; @@ -396,9 +395,3 @@ export function handleFeishuSubagentEnded(event: FeishuSubagentEndedEvent) { const manager = getFeishuThreadBindingManager(event.accountId); manager?.unbindBySessionKey(event.targetSessionKey); } - -export function registerFeishuSubagentHooks(api: OpenClawPluginApi) { - api.on("subagent_spawning", (event, ctx) => handleFeishuSubagentSpawning(event, ctx)); - api.on("subagent_delivery_target", (event) => handleFeishuSubagentDeliveryTarget(event)); - api.on("subagent_ended", (event) => handleFeishuSubagentEnded(event)); -} diff --git a/extensions/feishu/subagent-hooks-api.ts b/extensions/feishu/subagent-hooks-api.ts new file mode 100644 index 00000000000..320ab989113 --- /dev/null +++ b/extensions/feishu/subagent-hooks-api.ts @@ -0,0 +1,31 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract"; + +type FeishuSubagentHooksModule = typeof import("./src/subagent-hooks.js"); + +let feishuSubagentHooksPromise: Promise | null = null; + +function loadFeishuSubagentHooksModule() { + feishuSubagentHooksPromise ??= import("./src/subagent-hooks.js"); + return feishuSubagentHooksPromise; +} + +export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void { + api.on("subagent_spawning", async (event, ctx) => { + const { handleFeishuSubagentSpawning } = await loadFeishuSubagentHooksModule(); + return await handleFeishuSubagentSpawning(event, ctx); + }); + api.on("subagent_delivery_target", async (event) => { + const { handleFeishuSubagentDeliveryTarget } = await loadFeishuSubagentHooksModule(); + return handleFeishuSubagentDeliveryTarget(event); + }); + api.on("subagent_ended", async (event) => { + const { handleFeishuSubagentEnded } = await loadFeishuSubagentHooksModule(); + handleFeishuSubagentEnded(event); + }); +} + +export { + handleFeishuSubagentDeliveryTarget, + handleFeishuSubagentEnded, + handleFeishuSubagentSpawning, +} from "./src/subagent-hooks.js";