import { describe, expect, it } from "bun:test" import type { HookDeps, RuntimeFallbackPluginInput } from "./types" import type { AutoRetryHelpers } from "./auto-retry" import { createFallbackState } from "./fallback-state" import { createEventHandler } from "./event-handler" function createContext(): RuntimeFallbackPluginInput { return { client: { session: { abort: async () => ({}), messages: async () => ({ data: [] }), promptAsync: async () => ({}), }, tui: { showToast: async () => ({}), }, }, directory: "/test/dir", } } function createDeps(): HookDeps { return { ctx: createContext(), config: { enabled: true, retry_on_errors: [429, 503, 529], max_fallback_attempts: 3, cooldown_seconds: 60, timeout_seconds: 30, notify_on_fallback: false, }, options: undefined, pluginConfig: {}, sessionStates: new Map(), sessionLastAccess: new Map(), sessionRetryInFlight: new Set(), sessionAwaitingFallbackResult: new Set(), sessionFallbackTimeouts: new Map(), sessionStatusRetryKeys: new Map(), } } function createHelpers(deps: HookDeps, abortCalls: string[], clearCalls: string[]): AutoRetryHelpers { return { abortSessionRequest: async (sessionID: string) => { abortCalls.push(sessionID) }, clearSessionFallbackTimeout: (sessionID: string) => { clearCalls.push(sessionID) deps.sessionFallbackTimeouts.delete(sessionID) }, scheduleSessionFallbackTimeout: () => {}, autoRetryWithFallback: async () => {}, resolveAgentForSessionFromContext: async () => undefined, cleanupStaleSessions: () => {}, } } describe("createEventHandler", () => { it("#given a session retry dedupe key #when session.stop fires #then the retry dedupe key is cleared", async () => { // given const sessionID = "session-stop" const deps = createDeps() const abortCalls: string[] = [] const clearCalls: string[] = [] const state = createFallbackState("google/gemini-2.5-pro") state.pendingFallbackModel = "openai/gpt-5.4" deps.sessionStates.set(sessionID, state) deps.sessionRetryInFlight.add(sessionID) deps.sessionStatusRetryKeys.set(sessionID, "retry:1") const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls)) // when await handler({ event: { type: "session.stop", properties: { sessionID } } }) // then expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false) expect(clearCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID]) }) it("#given a session retry dedupe key without a pending fallback result #when session.idle fires #then the retry dedupe key is cleared", async () => { // given const sessionID = "session-idle" const deps = createDeps() const abortCalls: string[] = [] const clearCalls: string[] = [] const state = createFallbackState("google/gemini-2.5-pro") state.pendingFallbackModel = "openai/gpt-5.4" deps.sessionStates.set(sessionID, state) deps.sessionRetryInFlight.add(sessionID) deps.sessionFallbackTimeouts.set(sessionID, 1) deps.sessionStatusRetryKeys.set(sessionID, "retry:1") const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls)) // when await handler({ event: { type: "session.idle", properties: { sessionID } } }) // then expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false) expect(clearCalls).toEqual([sessionID]) expect(abortCalls).toEqual([]) expect(state.pendingFallbackModel).toBe(undefined) }) it("#given a cancelled session #when session.error receives an abort error #then fallback retry state is reset", async () => { const sessionID = "session-cancelled" const deps = createDeps() const abortCalls: string[] = [] const clearCalls: string[] = [] const state = createFallbackState("google/gemini-2.5-pro") state.currentModel = "openai/gpt-5.4" state.fallbackIndex = 1 state.attemptCount = 2 state.pendingFallbackModel = "openai/gpt-5.4" state.failedModels.set("google/gemini-2.5-pro", Date.now()) deps.sessionStates.set(sessionID, state) deps.sessionRetryInFlight.add(sessionID) deps.sessionAwaitingFallbackResult.add(sessionID) deps.sessionStatusRetryKeys.set(sessionID, "retry:2") const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls)) await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "AbortError" } } } }) const resetState = deps.sessionStates.get(sessionID) expect(resetState?.originalModel).toBe("google/gemini-2.5-pro") expect(resetState?.currentModel).toBe("google/gemini-2.5-pro") expect(resetState?.fallbackIndex).toBe(-1) expect(resetState?.attemptCount).toBe(0) expect(resetState?.pendingFallbackModel).toBe(undefined) expect(resetState?.failedModels.size).toBe(0) expect(deps.sessionRetryInFlight.has(sessionID)).toBe(false) expect(deps.sessionAwaitingFallbackResult.has(sessionID)).toBe(false) expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false) expect(clearCalls).toEqual([sessionID]) expect(abortCalls).toEqual([]) }) it("#given a cancelled session #when session.idle fires #then fallback retry state stays cleared", async () => { const sessionID = "session-cancelled-idle" const deps = createDeps() const abortCalls: string[] = [] const clearCalls: string[] = [] const state = createFallbackState("google/gemini-2.5-pro") state.currentModel = "openai/gpt-5.4" state.fallbackIndex = 1 state.attemptCount = 2 state.pendingFallbackModel = "openai/gpt-5.4" deps.sessionStates.set(sessionID, state) const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls)) await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } }) clearCalls.length = 0 await handler({ event: { type: "session.idle", properties: { sessionID } } }) const resetState = deps.sessionStates.get(sessionID) expect(resetState?.currentModel).toBe("google/gemini-2.5-pro") expect(resetState?.attemptCount).toBe(0) expect(clearCalls).toEqual([sessionID]) expect(abortCalls).toEqual([]) }) })