diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 3cde65522..0492783e6 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -744,4 +744,66 @@ describe("createEventHandler - session recovery compaction", () => { //#then - continue is still sent even when compaction fails expect(callOrder).toEqual(["summarize", "prompt"]) }) + + it("continues dispatching later event hooks when an earlier hook throws", async () => { + //#given + const runtimeFallbackCalls: EventInput[] = [] + + const eventHandler = createEventHandler({ + ctx: { + directory: "/tmp", + client: { + session: { + abort: async () => ({}), + prompt: async () => ({}), + }, + }, + } as any, + pluginConfig: {} as any, + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: { + tmuxSessionManager: { + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + } as any, + hooks: { + autoUpdateChecker: { + event: async () => { + throw new Error("upstream hook failed") + }, + }, + runtimeFallback: { + event: async (input: EventInput) => { + runtimeFallbackCalls.push(input) + }, + }, + stopContinuationGuard: { isStopped: () => false }, + } as any, + }) + + //#when + let thrownError: unknown + try { + await eventHandler({ + event: { + type: "session.error", + properties: { + sessionID: "ses_hook_isolation", + error: { name: "Error", message: "retry me" }, + }, + }, + } as any) + } catch (error) { + thrownError = error + } + + //#then + expect(thrownError).toBeUndefined() + expect(runtimeFallbackCalls).toHaveLength(1) + expect(runtimeFallbackCalls[0]?.event.type).toBe("session.error") + }) }) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index e6dbf1c4a..52c3f403f 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -193,32 +193,68 @@ export function createEventHandler(args: { return "opencode"; }; + const getEventSessionID = (input: EventInput): string | undefined => { + const properties = input.event.properties; + if ( + !properties || + typeof properties !== "object" || + !("sessionID" in properties) || + typeof properties.sessionID !== "string" + ) { + return undefined; + } + return properties.sessionID; + }; + + const runEventHookSafely = async ( + hookName: string, + handler: ((input: EventInput) => unknown | Promise) | null | undefined, + input: EventInput, + ): Promise => { + if (!handler) return; + + try { + await Promise.resolve(handler(input)); + } catch (error) { + log("[event] hook execution failed", { + hook: hookName, + eventType: input.event.type, + sessionID: getEventSessionID(input), + error, + }); + } + }; + const dispatchToHooks = async (input: EventInput): Promise => { - await Promise.resolve(hooks.autoUpdateChecker?.event?.(input)); - await Promise.resolve(hooks.legacyPluginToast?.event?.(input)); - await Promise.resolve(hooks.claudeCodeHooks?.event?.(input)); - await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input)); - await Promise.resolve(hooks.sessionNotification?.(input)); - await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input)); - await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input)); - await Promise.resolve(hooks.contextWindowMonitor?.event?.(input)); - await Promise.resolve(hooks.preemptiveCompaction?.event?.(input)); - await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input)); - await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input)); - await Promise.resolve(hooks.rulesInjector?.event?.(input)); - await Promise.resolve(hooks.thinkMode?.event?.(input)); - await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input)); - await Promise.resolve(hooks.runtimeFallback?.event?.(input)); - await Promise.resolve(hooks.agentUsageReminder?.event?.(input)); - await Promise.resolve(hooks.categorySkillReminder?.event?.(input)); - await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput)); - await Promise.resolve(hooks.ralphLoop?.event?.(input)); - await Promise.resolve(hooks.stopContinuationGuard?.event?.(input)); - await Promise.resolve(hooks.compactionContextInjector?.event?.(input)); - await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input)); - await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input)); - await Promise.resolve(hooks.atlasHook?.handler?.(input)); - await Promise.resolve(hooks.autoSlashCommand?.event?.(input)); + await runEventHookSafely("autoUpdateChecker", hooks.autoUpdateChecker?.event, input); + await runEventHookSafely("legacyPluginToast", hooks.legacyPluginToast?.event, input); + await runEventHookSafely("claudeCodeHooks", hooks.claudeCodeHooks?.event, input); + await runEventHookSafely("backgroundNotificationHook", hooks.backgroundNotificationHook?.event, input); + await runEventHookSafely("sessionNotification", hooks.sessionNotification, input); + await runEventHookSafely("todoContinuationEnforcer", hooks.todoContinuationEnforcer?.handler, input); + await runEventHookSafely("unstableAgentBabysitter", hooks.unstableAgentBabysitter?.event, input); + await runEventHookSafely("contextWindowMonitor", hooks.contextWindowMonitor?.event, input); + await runEventHookSafely("preemptiveCompaction", hooks.preemptiveCompaction?.event, input); + await runEventHookSafely("directoryAgentsInjector", hooks.directoryAgentsInjector?.event, input); + await runEventHookSafely("directoryReadmeInjector", hooks.directoryReadmeInjector?.event, input); + await runEventHookSafely("rulesInjector", hooks.rulesInjector?.event, input); + await runEventHookSafely("thinkMode", hooks.thinkMode?.event, input); + await runEventHookSafely( + "anthropicContextWindowLimitRecovery", + hooks.anthropicContextWindowLimitRecovery?.event, + input, + ); + await runEventHookSafely("runtimeFallback", hooks.runtimeFallback?.event, input); + await runEventHookSafely("agentUsageReminder", hooks.agentUsageReminder?.event, input); + await runEventHookSafely("categorySkillReminder", hooks.categorySkillReminder?.event, input); + await runEventHookSafely("interactiveBashSession", hooks.interactiveBashSession?.event, input as EventInput); + await runEventHookSafely("ralphLoop", hooks.ralphLoop?.event, input); + await runEventHookSafely("stopContinuationGuard", hooks.stopContinuationGuard?.event, input); + await runEventHookSafely("compactionContextInjector", hooks.compactionContextInjector?.event, input); + await runEventHookSafely("compactionTodoPreserver", hooks.compactionTodoPreserver?.event, input); + await runEventHookSafely("writeExistingFileGuard", hooks.writeExistingFileGuard?.event, input); + await runEventHookSafely("atlasHook", hooks.atlasHook?.handler, input); + await runEventHookSafely("autoSlashCommand", hooks.autoSlashCommand?.event, input); }; const recentSyntheticIdles = new Map();