import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import type { OhMyOpenCodeConfig, RuntimeFallbackConfig } from "../../config"
import {
  clearAllDelegatedChildSessionBootstrap,
  getDelegatedChildSessionBootstrap,
  registerDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import * as loggerModule from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import {
  releaseAllPromptAsyncReservationsForTesting,
  releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import type { RuntimeFallbackPluginInput } from "./types"

type RuntimeFallbackModule = typeof import("./hook")

describe("runtime-fallback", () => {
  let logCalls: Array<{ msg: string; data?: unknown }>
  let toastCalls: Array<{ title: string; message: string; variant: string }>
  let createRuntimeFallbackHook: RuntimeFallbackModule["createRuntimeFallbackHook"]

  beforeEach(async () => {
    mock.restore()
    logCalls = []
    toastCalls = []
    SessionCategoryRegistry.clear()
    clearAllDelegatedChildSessionBootstrap()
    releaseAllPromptAsyncReservationsForTesting()

    const cacheBuster = `${Date.now()}-${Math.random()}`

    mock.module("../../shared/logger", () => ({
      ...loggerModule,
      log: (msg: string, data?: unknown) => {
        logCalls.push({ msg, data })
      },
    }))

    const runtimeFallbackModule: RuntimeFallbackModule = await import(`./hook?test=${cacheBuster}`)
    createRuntimeFallbackHook = runtimeFallbackModule.createRuntimeFallbackHook
  })

  afterEach(() => {
    SessionCategoryRegistry.clear()
    clearAllDelegatedChildSessionBootstrap()
    releaseAllPromptAsyncReservationsForTesting()
    mock.restore()
  })

  function createMockPluginInput(overrides?: {
    session?: {
      messages?: (args: unknown) => Promise<unknown>
      promptAsync?: (args: unknown) => Promise<unknown>
      abort?: (args: unknown) => Promise<unknown>
      status?: () => Promise<unknown>
    }
  }): RuntimeFallbackPluginInput {
    return unsafeTestValue<RuntimeFallbackPluginInput>({
      client: {
        tui: {
          showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => {
            toastCalls.push({
              title: opts.body.title,
              message: opts.body.message,
              variant: opts.body.variant,
            })
          },
        },
        session: {
          messages: overrides?.session?.messages ?? (async () => ({ data: [] })),
          promptAsync: overrides?.session?.promptAsync ?? (async () => ({})),
          abort: overrides?.session?.abort ?? (async () => ({})),
          ...(overrides?.session?.status ? { status: overrides.session.status } : {}),
        },
      },
      directory: "/test/dir",
    })
  }

  function createMockConfig(overrides?: Partial<RuntimeFallbackConfig>): RuntimeFallbackConfig {
    return {
      enabled: true,
      retry_on_errors: [429, 503, 529],
      max_fallback_attempts: 3,
      cooldown_seconds: 60,
      notify_on_fallback: true,
      ...overrides,
    }
  }

  function createMockPluginConfigWithCategoryFallback(fallbackModels: string[]): OhMyOpenCodeConfig {
    return {
      git_master: {
        commit_footer: true,
        include_co_authored_by: true,
        git_env_prefix: "GIT_MASTER=1",
      },
      categories: {
        test: {
          fallback_models: fallbackModels,
        },
      },
    }
  }

  function createMockPluginConfigWithCategoryModel(
    categoryName: string,
    model: string,
    fallbackModels: string[],
    variant?: string,
  ): OhMyOpenCodeConfig {
    return {
      git_master: {
        commit_footer: true,
        include_co_authored_by: true,
        git_env_prefix: "GIT_MASTER=1",
      },
      categories: {
        [categoryName]: {
          model,
          fallback_models: fallbackModels,
          ...(variant ? { variant } : {}),
        },
      },
    }
  }

  describe("session.error handling", () => {
    test("should detect retryable error with status code 429", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-123"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit exceeded" } },
        },
      })

      const fallbackLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ sessionID, statusCode: 429 })
    })

    test("should detect retryable error with status code 503", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-503"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "openai/gpt-5.4" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 503, message: "Service unavailable" } },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })

    test("should detect retryable error with status code 529", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-529"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-3.1-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 529, message: "Overloaded" } },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })

    test("should skip non-retryable errors", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-400"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 400, message: "Bad request" } },
        },
      })

      const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable"))
      expect(skipLog).toBeDefined()
    })

    test("should log missing API key errors with classification details", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-missing-api-key"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "AI_LoadAPIKeyError",
              message:
                "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
            },
          },
        },
      })

      const sessionErrorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(sessionErrorLog).toBeDefined()
      expect(sessionErrorLog?.data).toMatchObject({
        sessionID,
        errorName: "AI_LoadAPIKeyError",
        errorType: "missing_api_key",
      })

      const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable"))
      expect(skipLog).toBeUndefined()
    })

    test("should trigger fallback for missing API key errors when fallback models are configured", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
      })
      const sessionID = "test-session-missing-api-key-fallback"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "AI_LoadAPIKeyError",
              message:
                "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
            },
          },
        },
      })

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "google/gemini-2.5-pro", to: "openai/gpt-5.4" })
    })

    test("should detect retryable error from message pattern 'rate limit'", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-pattern"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { message: "You have hit the rate limit" } },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })

    test("should trigger fallback for quota exhaustion to try next configured model", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["zai-coding-plan/glm-5.1"]),
      })
      const sessionID = "test-session-usage-limit"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "kimi-for-coding/k2p5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { message: "You've reached your usage limit for this month. Please upgrade to continue." },
          },
        },
      })

      // quota exhaustion now triggers fallback to the next model
      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
    })

    test("should continue fallback chain when fallback model is not found", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback([
          "anthropic/claude-opus-4.7",
          "openai/gpt-5.4",
        ]),
      })
      const sessionID = "test-session-model-not-found"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.7." } },
          },
        },
      })

      const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
      expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.7", to: "openai/gpt-5.4" })

      const nonRetryLog = logCalls.find(
        (c) => c.msg.includes("Error not retryable") && (c.data as { sessionID?: string } | undefined)?.sessionID === sessionID
      )
      expect(nonRetryLog).toBeUndefined()
    })

    test("should continue fallback chain when ProviderModelNotFoundError occurs", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback([
          "anthropic/claude-opus-4.7",
          "openai/gpt-5.4",
        ]),
      })
      const sessionID = "test-session-provider-model-not-found"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "AI_LoadAPIKeyError",
              message:
                "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
            },
          },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderModelNotFoundError",
              data: {
                providerID: "anthropic",
                modelID: "claude-opus-4.7",
                message: "Model not found: anthropic/claude-opus-4.7.",
              },
            },
          },
        },
      })

      const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
      expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.7", to: "openai/gpt-5.4" })
    })

    test("should bootstrap session.error fallback from session category model and preserve variant", async () => {
      const promptCalls: Array<Record<string, unknown>> = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }],
            }),
            promptAsync: async (args) => {
              promptCalls.push(args as Record<string, unknown>)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryModel(
            "quick",
            "anthropic/claude-haiku-4-5",
            ["openai/gpt-5.4(high)"],
          ),
        },
      )
      const sessionID = "test-session-category-bootstrap-session-error"
      SessionCategoryRegistry.register(sessionID, "quick")

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { statusCode: 429, message: "Rate limit exceeded" },
          },
        },
      })

      expect(promptCalls).toHaveLength(1)
      const promptBody = promptCalls[0]?.body as {
        model?: { providerID?: string; modelID?: string }
        variant?: string
      } | undefined
      expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
      expect(promptBody?.variant).toBe("high")

      const bootstrapLog = logCalls.find((call) =>
        call.msg.includes("Derived model from session category config for session.error"),
      )
      expect(bootstrapLog?.data).toMatchObject({
        sessionID,
        category: "quick",
        model: "anthropic/claude-haiku-4-5",
      })
    })

    test("should retry delegated child session from bootstrap when history has no user prompt", async () => {
      const promptCalls: Array<Record<string, unknown>> = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({ data: [] }),
            promptAsync: async (args) => {
              promptCalls.push(args as Record<string, unknown>)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryModel(
            "quick",
            "anthropic/claude-haiku-4-5",
            ["openai/gpt-5.4(high)"],
          ),
        },
      )
      const sessionID = "test-delegated-empty-history-bootstrap"
      registerDelegatedChildSessionBootstrap({
        sessionID,
        promptText: "inspect src/tools/delegate-task and report the issue",
        category: "quick",
        system: "delegated child system prompt",
        tools: { call_omo_agent: true, question: false, task: false },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { statusCode: 429, message: "Rate limit exceeded before history persisted" },
          },
        },
      })

      expect(promptCalls).toHaveLength(1)
      const promptBody = promptCalls[0]?.body as {
        model?: { providerID?: string; modelID?: string }
        parts?: Array<{ type?: string; text?: string }>
        system?: string
        tools?: Record<string, boolean>
        variant?: string
      } | undefined
      expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
      expect(promptBody?.variant).toBe("high")
      expect(promptBody?.system).toBe("delegated child system prompt")
      expect(promptBody?.tools?.question).toBe(false)
      expect(promptBody?.tools?.call_omo_agent).toBe(true)
      expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task")
      expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined()
    })

    test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => {
      const promptCalls: Array<Record<string, unknown>> = []
      const sessionID = "test-delegated-history-prefers-persisted-user"
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [
                {
                  info: { role: "user" },
                  parts: [{ type: "text", text: "persisted child task prompt" }],
                },
              ],
            }),
            promptAsync: async (args) => {
              promptCalls.push(args as Record<string, unknown>)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryModel(
            "test",
            "anthropic/claude-haiku-4-5",
            ["openai/gpt-5.4"],
          ),
        },
      )
      registerDelegatedChildSessionBootstrap({
        sessionID,
        promptText: "bootstrap copy should not be reused",
        system: "persisted delegated child system prompt",
        tools: { call_omo_agent: true, question: false, task: false },
      })
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { statusCode: 429, message: "Rate limit after prompt persisted" },
          },
        },
      })

      expect(promptCalls).toHaveLength(1)
      const promptBody = promptCalls[0]?.body as {
        parts?: Array<{ type?: string; text?: string }>
        system?: string
        tools?: Record<string, boolean>
      } | undefined
      expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt")
      expect(promptBody?.system).toBe("persisted delegated child system prompt")
      expect(promptBody?.tools?.question).toBe(false)
      expect(promptBody?.tools?.call_omo_agent).toBe(true)
      expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined()
    })

    test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
      })

      const sessionID = "test-session-copilot-auto-retry"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "github-copilot/claude-opus-4.7" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "github-copilot/claude-opus-4.7",
              status:
                "Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]",
            },
          },
        },
      })

      const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
      expect(signalLog).toBeDefined()

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "github-copilot/claude-opus-4.7", to: "openai/gpt-5.4" })
    })

    test("should trigger fallback on OpenAI auto-retry signal in message.updated", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-7"]),
      })

      const sessionID = "test-session-openai-auto-retry"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "openai/gpt-5.3-codex" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "openai/gpt-5.3-codex",
              status: "The usage limit has been reached [retrying in 27s attempt #6]",
            },
          },
        },
      })

      const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
      expect(signalLog).toBeDefined()

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-7" })
    })

    test("should trigger fallback on auto-retry signal in assistant text parts", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
      })

      const sessionID = "test-session-parts-auto-retry"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "quotio/claude-opus-4-7",
            },
            parts: [
              {
                type: "text",
                text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
              },
            ],
          },
        },
      })

      const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
      expect(signalLog).toBeDefined()

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-7", to: "openai/gpt-5.2" })
    })

    test("should trigger fallback when auto-retry text parts are nested under info.parts", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
      })

      const sessionID = "test-session-info-parts-auto-retry"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "quotio/claude-opus-4-7",
              parts: [
                {
                  type: "text",
                  text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
                },
              ],
            },
          },
        },
      })

      const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
      expect(signalLog).toBeDefined()

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-7", to: "openai/gpt-5.2" })
    })

    test("should trigger fallback on session.status auto-retry signal", async () => {
      const promptCalls: unknown[] = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [
                {
                  info: { role: "user" },
                  parts: [{ type: "text", text: "continue" }],
                },
              ],
            }),
            promptAsync: async (args) => {
              promptCalls.push(args)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
        }
      )

      const sessionID = "test-session-status-auto-retry"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
        },
      })

      await hook.event({
        event: {
          type: "session.status",
          properties: {
            sessionID,
            status: {
              type: "retry",
              next: 476,
              attempt: 1,
              message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]",
            },
          },
        },
      })

      const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal in session.status"))
      expect(signalLog).toBeDefined()

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-7", to: "openai/gpt-5.2" })
      expect(promptCalls.length).toBe(1)
    })

    test("should deduplicate session.status countdown updates for the same retry attempt", async () => {
      const promptCalls: unknown[] = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [
                {
                  info: { role: "user" },
                  parts: [{ type: "text", text: "continue" }],
                },
              ],
            }),
            promptAsync: async (args) => {
              promptCalls.push(args)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
        }
      )

      const sessionID = "test-session-status-dedup"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "quotio/claude-opus-4-7" } },
        },
      })

      await hook.event({
        event: {
          type: "session.status",
          properties: {
            sessionID,
            status: {
              type: "retry",
              next: 476,
              attempt: 1,
              message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]",
            },
          },
        },
      })

      await hook.event({
        event: {
          type: "session.status",
          properties: {
            sessionID,
            status: {
              type: "retry",
              next: 475,
              attempt: 1,
              message: "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 55s attempt #1]",
            },
          },
        },
      })

      expect(promptCalls.length).toBe(1)
    })

    test("should NOT trigger fallback on auto-retry signal when timeout_seconds is 0", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 0 }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-7"]),
      })

      const sessionID = "test-session-auto-retry-timeout-disabled"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "openai/gpt-5.3-codex" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "openai/gpt-5.3-codex",
              status: "The usage limit has been reached [retrying in 27s attempt #6]",
            },
          },
        },
      })

      // Should NOT detect provider auto-retry signal when timeout is disabled
      const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
      expect(signalLog).toBeUndefined()

      // Should NOT trigger fallback
      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeUndefined()
    })

    test("should log when no fallback models configured", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig(),
        pluginConfig: {
          git_master: {
            commit_footer: true,
            include_co_authored_by: true,
            git_env_prefix: "GIT_MASTER=1",
          },
        },
      })
      const sessionID = "test-session-no-fallbacks"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      const noFallbackLog = logCalls.find((c) => c.msg.includes("No fallback models configured"))
      expect(noFallbackLog).toBeDefined()
    })
  })

  describe("disabled hook", () => {
    test("should not process events when disabled", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ enabled: false }),
      })
      const sessionID = "test-session-disabled"

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429 } },
        },
      })

      const sessionErrorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(sessionErrorLog).toBeUndefined()
    })
  })

  describe("session lifecycle", () => {
    test("should create state on session.created", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-create"
      const model = "anthropic/claude-opus-4-5"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model } },
        },
      })

      const createLog = logCalls.find((c) => c.msg.includes("Session created with model"))
      expect(createLog).toBeDefined()
      expect(createLog?.data).toMatchObject({ sessionID, model })
    })

    test("should cleanup state on session.deleted", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-delete"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.deleted",
          properties: { info: { id: sessionID } },
        },
      })

      const deleteLog = logCalls.find((c) => c.msg.includes("Cleaning up session state"))
      expect(deleteLog).toBeDefined()
      expect(deleteLog?.data).toMatchObject({ sessionID })
    })

    test("should handle session.error without prior session.created", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-session-no-create"

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { statusCode: 429 },
            model: "anthropic/claude-opus-4-5",
          },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })
  })

  describe("error code extraction", () => {
    test("should extract status code from error object", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-extract-status"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "test-model" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { statusCode: 429, message: "Rate limit" },
          },
        },
      })

      const statusLog = logCalls.find((c) => c.data && typeof c.data === "object" && "statusCode" in c.data)
      expect(statusLog?.data).toMatchObject({ statusCode: 429 })
    })

    test("should extract status code from nested error.data", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-nested-status"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "test-model" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: { data: { statusCode: 503, message: "Service unavailable" } },
          },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })
  })

  describe("custom error codes", () => {
    test("should support custom retry_on_errors configuration", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ retry_on_errors: [500, 502] }),
      })
      const sessionID = "test-session-custom"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "test-model" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 500 } },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })
  })

  describe("message.updated handling", () => {
    test("should handle assistant message errors", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-message-updated"

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              error: { statusCode: 429, message: "Rate limit" },
              model: "anthropic/claude-opus-4-5",
            },
          },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("message.updated with assistant error"))
      expect(errorLog).toBeDefined()
    })

    test("should skip non-assistant message errors", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-message-user"

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "user",
              error: { statusCode: 429 },
              model: "anthropic/claude-opus-4-5",
            },
          },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("message.updated with assistant error"))
      expect(errorLog).toBeUndefined()
    })

    test("should trigger fallback when message.updated has missing API key error without model", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
      })
      const sessionID = "test-message-updated-missing-model"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              error: {
                name: "AI_LoadAPIKeyError",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "google/gemini-2.5-pro", to: "openai/gpt-5.4" })
    })

    test("should bootstrap message.updated fallback from session category model and preserve variant", async () => {
      const promptCalls: Array<Record<string, unknown>> = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }],
            }),
            promptAsync: async (args) => {
              promptCalls.push(args as Record<string, unknown>)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryModel(
            "quick",
            "anthropic/claude-haiku-4-5",
            ["openai/gpt-5.4(high)"],
          ),
        },
      )
      const sessionID = "test-session-category-bootstrap-message-updated"
      SessionCategoryRegistry.register(sessionID, "quick")

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              error: { statusCode: 429, message: "Rate limit exceeded" },
            },
          },
        },
      })

      expect(promptCalls).toHaveLength(1)
      const promptBody = promptCalls[0]?.body as {
        model?: { providerID?: string; modelID?: string }
        variant?: string
      } | undefined
      expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
      expect(promptBody?.variant).toBe("high")

      const bootstrapLog = logCalls.find((call) =>
        call.msg.includes("Derived model from session category config for message.updated"),
      )
      expect(bootstrapLog?.data).toMatchObject({
        sessionID,
        category: "quick",
        model: "anthropic/claude-haiku-4-5",
      })
    })

    test("should not advance fallback state from message.updated while retry is already in flight", async () => {
      const pending = new Promise<never>(() => {})

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async () => pending,
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5.4",
          ]),
        }
      )

      const sessionID = "test-message-updated-inflight-race"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      const sessionErrorPromise = hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 0))

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              error: {
                name: "ProviderAuthError",
                data: {
                  providerID: "google",
                  message:
                    "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
                },
              },
              model: "github-copilot/claude-opus-4.7",
            },
          },
        },
      })

      const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLogs).toHaveLength(1)

      void sessionErrorPromise
    })

    test("#given promptAsync fails after fallback retry may have been accepted #when the gate hold expires and the same error repeats #then the pending fallback state prevents a duplicate retry prompt", async () => {
      // given
      let promptCalls = 0
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async () => {
              promptCalls += 1
              throw new Error("JSON Parse error: Unexpected EOF")
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "provider-a/model-a",
            "provider-b/model-b",
          ]),
        }
      )
      const sessionID = "test-runtime-fallback-eof-preserves-pending"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      // when
      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            model: "google/gemini-2.5-pro",
            error: { statusCode: 429, message: "Rate limit" },
          },
        },
      })
      const released = releasePromptAsyncReservation(sessionID, "test:simulate-expired-hold", {
        reservedBy: "runtime-fallback:session.error",
      })
      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            model: "google/gemini-2.5-pro",
            error: { statusCode: 429, message: "Rate limit" },
          },
        },
      })

      // then
      expect(released).toBe(true)
      expect(promptCalls).toBe(1)
      const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped - awaiting fallback result"))
      expect(skipLog).toBeDefined()
    })

    test("should force advance fallback from message.updated when Copilot auto-retry signal appears during in-flight retry", async () => {
      const retriedModels: string[] = []
      const pending = new Promise<never>(() => {})

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }

              if (retriedModels.length === 1) {
                await pending
              }

              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5.4",
          ]),
        }
      )

      const sessionID = "test-message-updated-inflight-retry-signal"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      const sessionErrorPromise = hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 0))

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "github-copilot/claude-opus-4.7",
              status:
                "Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]",
            },
          },
        },
      })

      expect(retriedModels.length).toBeGreaterThanOrEqual(2)
      expect(retriedModels[0]).toBe("github-copilot/claude-opus-4.7")
      expect(retriedModels[1]).toBe("openai/gpt-5.4")

      const equivalentSkipLog = logCalls.find((c) => c.msg.includes("Skipping equivalent fallback model"))
      expect(equivalentSkipLog).toBeDefined()

      void sessionErrorPromise
    })

    test("should advance fallback after session timeout when Copilot retry emits no retryable events", async () => {
      const retriedModels: string[] = []
      const abortCalls: Array<{ path?: { id?: string } }> = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
            abort: async (args: unknown) => {
              abortCalls.push(args as { path?: { id?: string } })
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5.4",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-timeout-watchdog"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 50))

      expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
      expect(retriedModels).toContain("openai/gpt-5.4")
      expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)

      const timeoutLog = logCalls.find((c) => c.msg.includes("Session fallback timeout reached"))
      expect(timeoutLog).toBeDefined()
    })

    test("should keep session timeout active after chat.message model override", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5.4",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-timeout-after-chat-message"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      const output: { message: { model?: { providerID: string; modelID: string } }; parts: Array<{ type: string; text?: string }> } = {
        message: {},
        parts: [],
      }
      await hook["chat.message"]?.(
        {
          sessionID,
          model: { providerID: "github-copilot", modelID: "claude-opus-4.7" },
        },
        output
      )

      await new Promise((resolve) => setTimeout(resolve, 50))

      expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
      expect(retriedModels).toContain("openai/gpt-5.4")
    })

    test("should abort in-flight fallback request before advancing on timeout", async () => {
      const retriedModels: string[] = []
      const abortCalls: Array<{ path?: { id?: string } }> = []
      const never = new Promise<never>(() => {})

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }

              if (retriedModels.length === 1) {
                await never
              }

              return {}
            },
            abort: async (args: unknown) => {
              abortCalls.push(args as { path?: { id?: string } })
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5.4",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-timeout-abort-inflight"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      const sessionErrorPromise = hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 50))

      expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
      expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
      expect(retriedModels).toContain("openai/gpt-5.4")

      void sessionErrorPromise
    })

    test("should not advance fallback after session.stop cancels timeout-driven retry", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "anthropic/claude-opus-4-7",
            "openai/gpt-5.4",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-stop-cancels-timeout-fallback"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toContain("github-copilot/claude-opus-4.7")

      await hook.event({
        event: {
          type: "session.stop",
          properties: { sessionID },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 50))

      expect(retriedModels).toHaveLength(1)
    })

    test("should not trigger second fallback after successful assistant reply", async () => {
      const retriedModels: string[] = []
      const mockMessages = [
        { info: { role: "user" }, parts: [{ type: "text", text: "test" }] },
      ]

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: mockMessages,
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.3-codex",
            "anthropic/claude-opus-4-7",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-success-clears-timeout"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "openai/gpt-5.3-codex",
            },
          },
        },
      })

      mockMessages.push({
        info: { role: "assistant" },
        parts: [{ type: "text", text: "Got it - I'm here." }],
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "openai/gpt-5.3-codex",
              message: "Got it - I'm here.",
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 50))

      expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])
    })

    test("should not clear fallback timeout on assistant non-error update with Copilot retry signal", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.3-codex",
            "anthropic/claude-opus-4-7",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-copilot-retry-signal-no-error"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              status: "Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]",
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 60))

      expect(retriedModels).toContain("openai/gpt-5.3-codex")
    })

    test("should not clear fallback timeout on assistant non-error update with OpenAI retry signal", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "openai/gpt-5.3-codex",
            "anthropic/claude-opus-4-7",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-openai-retry-signal-no-error"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toEqual(["openai/gpt-5.3-codex"])

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              status: "The usage limit has been reached [retrying in 27s attempt #6]",
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 60))

      expect(retriedModels).toContain("anthropic/claude-opus-4-7")
    })

    test("should not clear fallback timeout on assistant non-error update without user-visible content", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.3-codex",
            "anthropic/claude-opus-4-7",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-no-content-non-error-update"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "github-copilot/claude-opus-4.7",
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 60))

      expect(retriedModels).toContain("openai/gpt-5.3-codex")
    })

    test("should not clear fallback timeout from info.message alone without persisted assistant text", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.3-codex",
            "anthropic/claude-opus-4-7",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-info-message-without-persisted-text"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              message: "Thinking: retrying provider request...",
            },
          },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 60))

      expect(retriedModels).toContain("openai/gpt-5.3-codex")
    })

    test("should keep timeout armed when session.idle fires before fallback result", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.3-codex",
            "anthropic/claude-opus-4-7",
          ]),
          session_timeout_ms: 20,
        }
      )

      const sessionID = "test-session-idle-before-fallback-result"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            error: {
              name: "ProviderAuthError",
              data: {
                providerID: "google",
                message:
                  "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
              },
            },
          },
        },
      })

      expect(retriedModels).toEqual(["github-copilot/claude-opus-4.7"])

      await hook.event({
        event: {
          type: "session.idle",
          properties: { sessionID },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 60))

      expect(retriedModels).toContain("openai/gpt-5.3-codex")
    })

    test("triggers fallback for quota exhaustion in error parts to try next model", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
        }
      )

      const sessionID = "test-session-error-content"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "minimax/minimax-text-01" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "minimax/minimax-text-01",
            },
            parts: [{ type: "error", text: "Upstream error from Minimax: insufficient balance (1008)" }],
          },
        },
      })

      // quota exhaustion now triggers fallback to next configured model
      expect(retriedModels.length).toBeGreaterThanOrEqual(1)
    })

    test("triggers fallback when message has mixed text and error parts", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-7"]),
        }
      )

      const sessionID = "test-session-mixed-content"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "google/gemini-2.5-pro",
            },
            parts: [
              { type: "text", text: "Hello" },
              { type: "error", text: "Rate limit exceeded" },
            ],
          },
        },
      })

      expect(retriedModels).toContain("anthropic/claude-opus-4-7")
    })

    test("does NOT trigger fallback for normal type:error-free messages", async () => {
      const retriedModels: string[] = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [
                { info: { role: "user" }, parts: [{ type: "text", text: "test" }] },
                { info: { role: "assistant" }, parts: [{ type: "text", text: "Normal response" }] },
              ],
            }),
            promptAsync: async (args: unknown) => {
              const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
              if (model?.providerID && model?.modelID) {
                retriedModels.push(`${model.providerID}/${model.modelID}`)
              }
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
        }
      )

      const sessionID = "test-session-normal-content"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "anthropic/claude-opus-4-5",
            },
            parts: [{ type: "text", text: "Normal response" }],
          },
        },
      })

      expect(retriedModels).toHaveLength(0)
    })
  })

  describe("edge cases", () => {
    test("should handle session.error without sessionID", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })

      await hook.event({
        event: {
          type: "session.error",
          properties: { error: { statusCode: 429 } },
        },
      })

      const skipLog = logCalls.find((c) => c.msg.includes("session.error without sessionID"))
      expect(skipLog).toBeDefined()
    })

    test("should handle error as string", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-error-string"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "test-model" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: "rate limit exceeded" },
        },
      })

      const errorLog = logCalls.find((c) => c.msg.includes("session.error received"))
      expect(errorLog).toBeDefined()
    })

    test("should handle null error", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
      const sessionID = "test-error-null"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "test-model" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: null },
        },
      })

      const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable"))
      expect(skipLog).toBeDefined()
    })
  })

  describe("model switching via chat.message", () => {
    test("should apply fallback model on next chat.message after error", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4", "google/gemini-3.1-pro"]),
      })
      const sessionID = "test-session-switch"
      SessionCategoryRegistry.register(sessionID, "test")

      //#given
      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      //#when
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      const output: { message: { model?: { providerID: string; modelID: string } }; parts: Array<{ type: string; text?: string }> } = {
        message: {},
        parts: [],
      }
      await hook["chat.message"]?.(
        { sessionID },
        output
      )

      expect(output.message.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
    })

    test("should notify when fallback occurs", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: true }),
        pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
      })
      const sessionID = "test-session-notify"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429 } },
        },
      })

      expect(toastCalls.length).toBe(1)
      expect(toastCalls[0]?.message.includes("gpt-5.4")).toBe(true)
    })
  })

  describe("fallback models configuration", () => {
    function createMockPluginConfigWithAgentFallback(agentName: string, fallbackModels: string[]): OhMyOpenCodeConfig {
      return {
        git_master: {
          commit_footer: true,
          include_co_authored_by: true,
          git_env_prefix: "GIT_MASTER=1",
        },
        agents: {
          [agentName]: {
            fallback_models: fallbackModels,
          },
        },
      }
    }

    test("should use agent-level fallback_models", async () => {
      const input = createMockPluginInput()
      const hook = createRuntimeFallbackHook(input, {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithAgentFallback("oracle", ["openai/gpt-5.4", "google/gemini-3.1-pro"]),
      })
      const sessionID = "test-agent-fallback"

      //#given - agent with custom fallback models
      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5", agent: "oracle" } },
        },
      })

      //#when - error occurs
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 503 }, agent: "oracle" },
        },
      })

      //#then - should prepare fallback to openai/gpt-5.4
      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ from: "anthropic/claude-opus-4-5", to: "openai/gpt-5.4" })
    })

    test("should detect agent from sessionID pattern", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithAgentFallback("sisyphus", ["openai/gpt-5.4"]),
      })
      const sessionID = "sisyphus-session-123"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429 } },
        },
      })

      //#then - should detect sisyphus from sessionID and use its fallback
      const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
      expect(fallbackLog).toBeDefined()
      expect(fallbackLog?.data).toMatchObject({ to: "openai/gpt-5.4" })
    })

    test("should preserve resolved agent during auto-retry", async () => {
      const promptCalls: Array<Record<string, unknown>> = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [
                {
                  info: { role: "user" },
                  parts: [{ type: "text", text: "test" }],
                },
              ],
            }),
            promptAsync: async (args: unknown) => {
              promptCalls.push(args as Record<string, unknown>)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", [
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.4",
          ]),
        },
      )
      const sessionID = "test-preserve-agent-on-retry"

      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            model: "anthropic/claude-opus-4-7",
            error: { statusCode: 503, message: "Service unavailable" },
            agent: "prometheus",
          },
        },
      })

      expect(promptCalls.length).toBe(1)
      const callBody = promptCalls[0]?.body as Record<string, unknown>
      expect(callBody?.agent).toBe("prometheus")
      expect(callBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
    })

    test("should not dispatch a second fallback prompt while the accepted retry session is still active", async () => {
      const sessionID = "test-runtime-fallback-active-gate"
      let sessionStatus = "idle"
      const promptCalls: Array<Record<string, unknown>> = []
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [
                {
                  info: { role: "user" },
                  parts: [{ type: "text", text: "retry this" }],
                },
              ],
            }),
            promptAsync: async (args: unknown) => {
              promptCalls.push(args as Record<string, unknown>)
              sessionStatus = "busy"
              return {}
            },
            status: async () => ({ data: { [sessionID]: { type: sessionStatus } } }),
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: createMockPluginConfigWithCategoryFallback([
            "github-copilot/claude-opus-4.7",
            "openai/gpt-5.4",
          ]),
        },
      )
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-7" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 503, message: "Service unavailable" } },
        },
      })
      await hook.event({
        event: {
          type: "session.error",
          properties: {
            sessionID,
            model: "github-copilot/claude-opus-4.7",
            error: { statusCode: 503, message: "Service unavailable" },
          },
        },
      })

      expect(promptCalls).toHaveLength(1)
    })
  })

  describe("cooldown mechanism", () => {
    test("should respect cooldown period before retrying failed model", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ cooldown_seconds: 60, notify_on_fallback: false }),
        pluginConfig: createMockPluginConfigWithCategoryFallback([
          "openai/gpt-5.4",
          "anthropic/claude-opus-4-5",
        ]),
      })
      const sessionID = "test-session-cooldown"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      //#when - first error occurs, switches to openai
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429 } },
        },
      })

      //#when - second error occurs immediately; tries to switch back to original model but should be in cooldown
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429 } },
        },
      })

      const cooldownSkipLog = logCalls.find((c) => c.msg.includes("Skipping fallback model in cooldown"))
      expect(cooldownSkipLog).toBeDefined()
    })
  })

  describe("max attempts limit", () => {
    test("should stop after max_fallback_attempts", async () => {
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ max_fallback_attempts: 2 }),
      })
      const sessionID = "test-session-max"

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
        },
      })

      //#when - multiple errors occur exceeding max attempts
      for (let i = 0; i < 5; i++) {
        await hook.event({
          event: {
            type: "session.error",
            properties: { sessionID, error: { statusCode: 429 } },
          },
        })
      }

      //#then - should have stopped after max attempts
      const maxLog = logCalls.find((c) => c.msg.includes("Max fallback attempts reached") || c.msg.includes("No fallback models"))
      expect(maxLog).toBeDefined()
    })
  })

  describe("race condition guards", () => {
    test("session.error is skipped while retry request is in flight", async () => {
      const never = new Promise<never>(() => {})

      //#given
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async () => never,
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: {
            git_master: {
              commit_footer: true,
              include_co_authored_by: true,
              git_env_prefix: "GIT_MASTER=1",
            },
            categories: {
              test: {
                fallback_models: ["provider-a/model-a", "provider-b/model-b"],
              },
            },
          },
        }
      )
      const sessionID = "test-race-retry-in-flight"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      //#when - first error starts retry (promptAsync hangs, keeping retryInFlight set)
      const firstErrorPromise = hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      await new Promise((resolve) => setTimeout(resolve, 0))

      //#when - second error fires while first retry is in flight
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Second rate limit" } },
        },
      })

      //#then
      const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped"))
      expect(skipLog).toBeDefined()
      expect(skipLog?.data).toMatchObject({ retryInFlight: true })

      const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
      expect(fallbackLogs).toHaveLength(1)

      void firstErrorPromise
    })

    test("consecutive session.errors advance chain normally when retry completes between them", async () => {
      //#given
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: {
          git_master: {
            commit_footer: true,
            include_co_authored_by: true,
            git_env_prefix: "GIT_MASTER=1",
          },
          categories: {
            test: {
              fallback_models: ["provider-a/model-a", "provider-b/model-b"],
            },
          },
        },
      })
      const sessionID = "test-race-chain-advance"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      //#when - two errors fire sequentially (retry completes immediately between them)
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, model: "provider-a/model-a", error: { statusCode: 429, message: "Rate limit again" } },
        },
      })

      //#then - both should advance the chain (no skip)
      const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
      expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
    })

    test("session.error is skipped while waiting for the dispatched fallback result", async () => {
      const promptCalls: Array<unknown> = []

      //#given
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              promptCalls.push(args)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: {
            git_master: {
              commit_footer: true,
              include_co_authored_by: true,
              git_env_prefix: "GIT_MASTER=1",
            },
            categories: {
              test: {
                fallback_models: ["provider-a/model-a", "provider-b/model-b"],
              },
            },
          },
        }
      )
      const sessionID = "test-race-awaiting-fallback-result"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      //#when - duplicate stale error fires after promptAsync resolved but before fallback output is visible
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      //#then
      expect(promptCalls).toHaveLength(1)
      const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
      expect(fallbackLogs).toHaveLength(1)
      const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped - awaiting fallback result"))
      expect(skipLog).toBeDefined()
    })

    test("#given a dispatched fallback retry #when stale original assistant error arrives before duplicate session.error #then only one assistant retry prompt is sent", async () => {
      const promptCalls: Array<unknown> = []

      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async (args: unknown) => {
              promptCalls.push(args)
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: {
            git_master: {
              commit_footer: true,
              include_co_authored_by: true,
              git_env_prefix: "GIT_MASTER=1",
            },
            categories: {
              test: {
                fallback_models: ["provider-a/model-a", "provider-b/model-b"],
              },
            },
          },
        }
      )
      const sessionID = "test-race-stale-message-update-before-error"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, model: "google/gemini-2.5-pro", error: { statusCode: 429, message: "Rate limit" } },
        },
      })
      await hook.event({
        event: {
          type: "message.updated",
          properties: {
            info: {
              sessionID,
              role: "assistant",
              model: "google/gemini-2.5-pro",
              error: { statusCode: 429, message: "Rate limit" },
            },
          },
        },
      })
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, model: "google/gemini-2.5-pro", error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      expect(promptCalls).toHaveLength(1)
      const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
      expect(fallbackLogs).toHaveLength(1)
      const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped - awaiting fallback result"))
      expect(skipLog).toBeDefined()
    })

    test("session.stop aborts when sessionAwaitingFallbackResult is set", async () => {
      const abortCalls: Array<{ path?: { id?: string } }> = []

      //#given
      const hook = createRuntimeFallbackHook(
        createMockPluginInput({
          session: {
            messages: async () => ({
              data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
            }),
            promptAsync: async () => ({}),
            abort: async (args: unknown) => {
              abortCalls.push(args as { path?: { id?: string } })
              return {}
            },
          },
        }),
        {
          config: createMockConfig({ notify_on_fallback: false }),
          pluginConfig: {
            git_master: {
              commit_footer: true,
              include_co_authored_by: true,
              git_env_prefix: "GIT_MASTER=1",
            },
            categories: {
              test: {
                fallback_models: ["provider-a/model-a", "provider-b/model-b"],
              },
            },
          },
        }
      )
      const sessionID = "test-race-stop-awaiting"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      //#when
      await hook.event({
        event: {
          type: "session.stop",
          properties: { sessionID },
        },
      })

      //#then
      expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
    })

    test("pendingFallbackModel advances chain on subsequent error even when persisted", async () => {
      //#given
      const hook = createRuntimeFallbackHook(createMockPluginInput(), {
        config: createMockConfig({ notify_on_fallback: false }),
        pluginConfig: {
          git_master: {
            commit_footer: true,
            include_co_authored_by: true,
            git_env_prefix: "GIT_MASTER=1",
          },
          categories: {
            test: {
              fallback_models: ["provider-a/model-a", "provider-b/model-b"],
            },
          },
        },
      })
      const sessionID = "test-race-pending-persists"
      SessionCategoryRegistry.register(sessionID, "test")

      await hook.event({
        event: {
          type: "session.created",
          properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
        },
      })

      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
        },
      })

      const autoRetryLog = logCalls.find((call) => call.msg.includes("No user message found for auto-retry"))
      expect(autoRetryLog).toBeDefined()

      //#when - second error fires after retry completed (retryInFlight cleared)
      await hook.event({
        event: {
          type: "session.error",
          properties: { sessionID, error: { statusCode: 429, message: "Rate limit again" } },
        },
      })

      //#then - chain advances normally (not skipped), consistent with consecutive errors test
      const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
      expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
    })
  })
})
