9bd2a9d7a9
When the working directory contains a .git folder the OpenCode server
normalises the project root to the git root before persisting messages.
This creates a race: the 429/503/529 error event can fire before the
user's message is committed to storage, so session.messages returns []
and getLastUserRetryParts returns an empty array. The previous code
treated that as a silent no-op (cleared all retry state, Sisyphus stalled).
Fix: when fetchedParts is empty, emit a structured log explaining the
.git-directory race and fall back to a synthetic { type:"text", text:"continue" }
part — matching the pattern already used by autoContinueAfterFallback in
event.ts. The fallback dispatch always proceeds regardless of whether
the messages API can return user parts.
Update four tests that fired two consecutive session.error events relying
on the old silent-stop behaviour: add top-level model fields to the second
error so the awaiting-fallback gate recognises it as coming from the
dispatched fallback model and lets it through normally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3195 lines
104 KiB
TypeScript
3195 lines
104 KiB
TypeScript
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,
|
|
// model at the top level so the awaiting-fallback gate recognises this
|
|
// as an error from the fallback model we just dispatched
|
|
model: "anthropic/claude-opus-4.7",
|
|
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,
|
|
// model at the top level so the awaiting-fallback gate recognises this
|
|
// as an error from the fallback model we just dispatched
|
|
model: "anthropic/claude-opus-4.7",
|
|
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 } },
|
|
},
|
|
})
|
|
|
|
// Simulate the fallback session completing before the next error arrives
|
|
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
|
|
//#when - second error occurs immediately; tries to switch back to original model but should be in cooldown
|
|
await hook.event({
|
|
event: {
|
|
type: "session.error",
|
|
// model matches pendingFallbackModel so the awaiting-fallback gate lets this through
|
|
properties: { sessionID, model: "openai/gpt-5.4", 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 parts found for auto-retry") &&
|
|
call.msg.includes("using synthetic continuation"),
|
|
)
|
|
expect(autoRetryLog).toBeDefined()
|
|
|
|
// Simulate the fallback session completing before the next error arrives
|
|
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
|
|
//#when - second error fires after retry completed (retryInFlight cleared)
|
|
await hook.event({
|
|
event: {
|
|
type: "session.error",
|
|
// model matches pendingFallbackModel so the awaiting-fallback gate lets this through
|
|
properties: { sessionID, model: "provider-a/model-a", 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)
|
|
})
|
|
})
|
|
})
|