diff --git a/src/plugins/hook-runner-global.test.ts b/src/plugins/hook-runner-global.test.ts index 8089feff430..f0901efdf4f 100644 --- a/src/plugins/hook-runner-global.test.ts +++ b/src/plugins/hook-runner-global.test.ts @@ -46,4 +46,54 @@ describe("hook-runner-global", () => { expect(modC.getGlobalHookRunner()).toBeNull(); expect(modC.getGlobalPluginRegistry()).toBeNull(); }); + + it("does not replace an existing runner that has typed hooks", async () => { + const mod = await importHookRunnerGlobalModule(); + const registryWithHooks = createMockPluginRegistry([ + { hookName: "llm_input", handler: vi.fn() }, + ]); + const emptyRegistry = createMockPluginRegistry([]); + + mod.initializeGlobalHookRunner(registryWithHooks); + const originalRunner = mod.getGlobalHookRunner(); + expect(originalRunner?.hasHooks("llm_input")).toBe(true); + + // Second call with empty registry should be a no-op + mod.initializeGlobalHookRunner(emptyRegistry); + expect(mod.getGlobalHookRunner()).toBe(originalRunner); + expect(mod.getGlobalPluginRegistry()).toBe(registryWithHooks); + expect(mod.getGlobalHookRunner()?.hasHooks("llm_input")).toBe(true); + }); + + it("allows replacing a runner that has no typed hooks", async () => { + const mod = await importHookRunnerGlobalModule(); + const emptyRegistry = createMockPluginRegistry([]); + const registryWithHooks = createMockPluginRegistry([ + { hookName: "llm_input", handler: vi.fn() }, + ]); + + mod.initializeGlobalHookRunner(emptyRegistry); + expect(mod.getGlobalHookRunner()?.hasHooks("llm_input")).toBe(false); + + // Second call with hooks should replace + mod.initializeGlobalHookRunner(registryWithHooks); + expect(mod.getGlobalHookRunner()?.hasHooks("llm_input")).toBe(true); + expect(mod.getGlobalPluginRegistry()).toBe(registryWithHooks); + }); + + it("allows re-initialization after reset", async () => { + const mod = await importHookRunnerGlobalModule(); + const registryA = createMockPluginRegistry([{ hookName: "llm_input", handler: vi.fn() }]); + const registryB = createMockPluginRegistry([{ hookName: "llm_output", handler: vi.fn() }]); + + mod.initializeGlobalHookRunner(registryA); + expect(mod.getGlobalHookRunner()?.hasHooks("llm_input")).toBe(true); + + mod.resetGlobalHookRunner(); + expect(mod.getGlobalHookRunner()).toBeNull(); + + mod.initializeGlobalHookRunner(registryB); + expect(mod.getGlobalHookRunner()?.hasHooks("llm_output")).toBe(true); + expect(mod.getGlobalHookRunner()?.hasHooks("llm_input")).toBe(false); + }); }); diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index b2613f3467f..ba7490a560f 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -35,6 +35,14 @@ function getHookRunnerGlobalState(): HookRunnerGlobalState { */ export function initializeGlobalHookRunner(registry: PluginRegistry): void { const state = getHookRunnerGlobalState(); + // Preserve an existing hook runner that has registered hooks. + // Subsequent ensureRuntimePluginsLoaded calls (e.g. from non-default agent runs) + // may build a fresh registry with fewer/no hooks due to cache key divergence; + // replacing the working runner would silently drop all plugin hooks. + if (state.hookRunner && state.registry && state.registry.typedHooks.length > 0) { + log.debug("hook runner already initialized with hooks; skipping re-initialization"); + return; + } state.registry = registry; state.hookRunner = createHookRunner(registry, { logger: {