import type { HookDeps } from "./types" import type { AutoRetryHelpers } from "./auto-retry" import { HOOK_NAME } from "./constants" import { log } from "../../shared/logger" import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier" import { createFallbackState } from "./fallback-state" import { getFallbackModelsForSession } from "./fallback-models" import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { isAbortError } from "../../shared/is-abort-error" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { createSessionStatusHandler } from "./session-status-handler" export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys) const cancelledSessions = new Set() const resetRetryState = (sessionID: string) => { const state = sessionStates.get(sessionID) if (state) { sessionStates.set(sessionID, createFallbackState(state.originalModel)) } sessionRetryInFlight.delete(sessionID) sessionAwaitingFallbackResult.delete(sessionID) sessionStatusRetryKeys.delete(sessionID) helpers.clearSessionFallbackTimeout(sessionID) } const handleSessionCreated = (props: Record | undefined) => { const sessionInfo = props?.info as { id?: string; model?: string } | undefined const sessionID = sessionInfo?.id const model = sessionInfo?.model if (sessionID && model) { log(`[${HOOK_NAME}] Session created with model`, { sessionID, model }) sessionStates.set(sessionID, createFallbackState(model)) sessionLastAccess.set(sessionID, Date.now()) } } const handleSessionDeleted = (props: Record | undefined) => { const sessionInfo = props?.info as { id?: string } | undefined const sessionID = sessionInfo?.id if (sessionID) { log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID }) cancelledSessions.delete(sessionID) sessionStates.delete(sessionID) sessionLastAccess.delete(sessionID) sessionRetryInFlight.delete(sessionID) sessionAwaitingFallbackResult.delete(sessionID) helpers.clearSessionFallbackTimeout(sessionID) sessionStatusRetryKeys.delete(sessionID) SessionCategoryRegistry.remove(sessionID) } } const handleSessionStop = async (props: Record | undefined) => { const sessionID = props?.sessionID as string | undefined if (!sessionID) return if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) { await helpers.abortSessionRequest(sessionID, "session.stop") } cancelledSessions.add(sessionID) resetRetryState(sessionID) log(`[${HOOK_NAME}] Cleared fallback retry state on session.stop`, { sessionID }) } const handleMessageUpdated = (props: Record | undefined) => { const info = props?.info as Record | undefined const sessionID = info?.sessionID as string | undefined const role = info?.role as string | undefined if (!sessionID || role !== "user") return cancelledSessions.delete(sessionID) } const handleSessionIdle = (props: Record | undefined) => { const sessionID = props?.sessionID as string | undefined if (!sessionID) return if (cancelledSessions.has(sessionID)) { resetRetryState(sessionID) log(`[${HOOK_NAME}] Cleared fallback retry state for cancelled session on idle`, { sessionID }) return } if (sessionAwaitingFallbackResult.has(sessionID)) { log(`[${HOOK_NAME}] session.idle while awaiting fallback result; keeping timeout armed`, { sessionID }) return } const hadTimeout = sessionFallbackTimeouts.has(sessionID) helpers.clearSessionFallbackTimeout(sessionID) sessionRetryInFlight.delete(sessionID) sessionStatusRetryKeys.delete(sessionID) const state = sessionStates.get(sessionID) if (state?.pendingFallbackModel) { state.pendingFallbackModel = undefined } if (hadTimeout) { log(`[${HOOK_NAME}] Cleared fallback timeout after session completion`, { sessionID }) } } const handleSessionError = async (props: Record | undefined) => { const sessionID = props?.sessionID as string | undefined const error = props?.error const agent = props?.agent as string | undefined if (!sessionID) { log(`[${HOOK_NAME}] session.error without sessionID, skipping`) return } const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent) if (isAbortError(error)) { cancelledSessions.add(sessionID) resetRetryState(sessionID) log(`[${HOOK_NAME}] session.error matched cancellation; cleared retry state`, { sessionID, resolvedAgent }) return } if (sessionRetryInFlight.has(sessionID)) { log(`[${HOOK_NAME}] session.error skipped - retry in flight`, { sessionID, retryInFlight: true, }) return } sessionAwaitingFallbackResult.delete(sessionID) helpers.clearSessionFallbackTimeout(sessionID) log(`[${HOOK_NAME}] session.error received`, { sessionID, agent, resolvedAgent, statusCode: extractStatusCode(error, config.retry_on_errors), errorName: extractErrorName(error), errorType: classifyErrorType(error), }) if (!isRetryableError(error, config.retry_on_errors)) { log(`[${HOOK_NAME}] Error not retryable, skipping fallback`, { sessionID, retryable: false, statusCode: extractStatusCode(error, config.retry_on_errors), errorName: extractErrorName(error), errorType: classifyErrorType(error), }) return } let state = sessionStates.get(sessionID) const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig) if (fallbackModels.length === 0) { log(`[${HOOK_NAME}] No fallback models configured`, { sessionID, agent }) return } if (!state) { const initialModel = resolveFallbackBootstrapModel({ sessionID, source: "session.error", eventModel: props?.model as string | undefined, resolvedAgent, pluginConfig, }) if (!initialModel) { log(`[${HOOK_NAME}] No model info available, cannot fallback`, { sessionID }) return } state = createFallbackState(initialModel) sessionStates.set(sessionID, state) sessionLastAccess.set(sessionID, Date.now()) } else { sessionLastAccess.set(sessionID, Date.now()) } await dispatchFallbackRetry(deps, helpers, { sessionID, state, fallbackModels, resolvedAgent, source: "session.error", }) } return async ({ event }: { event: { type: string; properties?: unknown } }) => { if (!config.enabled) return const props = event.properties as Record | undefined if (event.type === "session.created") { handleSessionCreated(props); return } if (event.type === "session.deleted") { handleSessionDeleted(props); return } if (event.type === "session.stop") { await handleSessionStop(props); return } if (event.type === "message.updated") { handleMessageUpdated(props); return } if (event.type === "session.idle") { handleSessionIdle(props); return } if (event.type === "session.status") { await sessionStatusHandler(props); return } if (event.type === "session.error") { await handleSessionError(props); return } } }