From 765182dcc67696743364fdb6958f4f8190e80994 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Mar 2026 16:11:33 +0530 Subject: [PATCH] fix: skip session:patch hook clone without listeners --- src/gateway/server-methods/sessions.ts | 31 ++++++++++--------- ...sessions.gateway-server-sessions-a.test.ts | 28 +++++++++++++++++ src/hooks/internal-hooks.ts | 17 ++++++---- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 54387419419..639f229ab53 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -19,6 +19,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { + hasInternalHookListeners, triggerInternalHook, type SessionPatchHookContext, type SessionPatchHookEvent, @@ -899,20 +900,22 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const hookContext: SessionPatchHookContext = structuredClone({ - sessionEntry: applied.entry, - patch: p, - cfg, - }); - const hookEvent: SessionPatchHookEvent = { - type: "session", - action: "patch", - sessionKey: target.canonicalKey ?? key, - context: hookContext, - timestamp: new Date(), - messages: [], - }; - void triggerInternalHook(hookEvent); + if (hasInternalHookListeners("session", "patch")) { + const hookContext: SessionPatchHookContext = structuredClone({ + sessionEntry: applied.entry, + patch: p, + cfg, + }); + const hookEvent: SessionPatchHookEvent = { + type: "session", + action: "patch", + sessionKey: target.canonicalKey ?? key, + context: hookContext, + timestamp: new Date(), + messages: [], + }; + void triggerInternalHook(hookEvent); + } const parsed = parseAgentSessionKey(target.canonicalKey ?? key); const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index cb7ee0b8f1a..225a31e886b 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -34,6 +34,7 @@ const bootstrapCacheMocks = vi.hoisted(() => ({ })); const sessionHookMocks = vi.hoisted(() => ({ + hasInternalHookListeners: vi.fn(() => true), triggerInternalHook: vi.fn(async (_event: unknown) => {}), })); @@ -96,6 +97,7 @@ vi.mock("../hooks/internal-hooks.js", async () => { ); return { ...actual, + hasInternalHookListeners: sessionHookMocks.hasInternalHookListeners, triggerInternalHook: sessionHookMocks.triggerInternalHook, }; }); @@ -260,6 +262,8 @@ describe("gateway server sessions", () => { sessionCleanupMocks.clearSessionQueues.mockClear(); sessionCleanupMocks.stopSubagentsForRequester.mockClear(); bootstrapCacheMocks.clearBootstrapSnapshot.mockReset(); + sessionHookMocks.hasInternalHookListeners.mockReset(); + sessionHookMocks.hasInternalHookListeners.mockReturnValue(true); sessionHookMocks.triggerInternalHook.mockClear(); subagentLifecycleHookMocks.runSubagentEnded.mockClear(); subagentLifecycleHookState.hasSubagentEndedHook = true; @@ -1938,6 +1942,30 @@ describe("gateway server sessions", () => { ws.close(); }); + test("session:patch skips clone and dispatch when no hooks listen", async () => { + const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone"); + sessionHookMocks.hasInternalHookListeners.mockReturnValue(false); + + const { ws } = await openClient(); + const patched = await rpcReq(ws, "sessions.patch", { + key: "agent:main:main", + label: "no-hook-listener", + }); + + expect(patched.ok).toBe(true); + expect(structuredCloneSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.any(Object), + patch: expect.any(Object), + sessionEntry: expect.any(Object), + }), + ); + expect(sessionHookMocks.triggerInternalHook).not.toHaveBeenCalled(); + + structuredCloneSpy.mockRestore(); + ws.close(); + }); + test("session:patch hook mutations cannot change the response path", async () => { await createSessionStoreDir(); await writeSessionStore({ diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 9d31416293f..224fe32f797 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -268,6 +268,12 @@ export function getRegisteredEventKeys(): string[] { return Array.from(handlers.keys()); } +export function hasInternalHookListeners(type: InternalHookEventType, action: string): boolean { + return ( + (handlers.get(type)?.length ?? 0) > 0 || (handlers.get(`${type}:${action}`)?.length ?? 0) > 0 + ); +} + /** * Trigger a hook event * @@ -281,15 +287,14 @@ export function getRegisteredEventKeys(): string[] { * @param event - The event to trigger */ export async function triggerInternalHook(event: InternalHookEvent): Promise { - const typeHandlers = handlers.get(event.type) ?? []; - const specificHandlers = handlers.get(`${event.type}:${event.action}`) ?? []; - - const allHandlers = [...typeHandlers, ...specificHandlers]; - - if (allHandlers.length === 0) { + if (!hasInternalHookListeners(event.type, event.action)) { return; } + const typeHandlers = handlers.get(event.type) ?? []; + const specificHandlers = handlers.get(`${event.type}:${event.action}`) ?? []; + const allHandlers = [...typeHandlers, ...specificHandlers]; + for (const handler of allHandlers) { try { await handler(event);