2026-05-16 16:21:48 +09:00
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"
2026-04-10 15:53:32 +09:00
import * as loggerModule from "../../shared/logger"
2026-02-04 15:25:25 +09:00
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
2026-05-19 16:02:18 +09:00
import {
releaseAllPromptAsyncReservationsForTesting ,
releasePromptAsyncReservation ,
} from "../shared/prompt-async-gate"
2026-05-17 00:08:30 +09:00
import type { RuntimeFallbackPluginInput } from "./types"
2026-02-03 11:28:22 +09:00
2026-04-10 15:53:32 +09:00
type RuntimeFallbackModule = typeof import ( "./hook" )
2026-02-03 11:28:22 +09:00
describe ( "runtime-fallback" , ( ) = > {
let logCalls : Array < { msg : string ; data? : unknown } >
let toastCalls : Array < { title : string ; message : string ; variant : string } >
2026-04-10 15:53:32 +09:00
let createRuntimeFallbackHook : RuntimeFallbackModule [ "createRuntimeFallbackHook" ]
2026-02-03 11:28:22 +09:00
2026-04-10 15:53:32 +09:00
beforeEach ( async ( ) = > {
mock . restore ( )
2026-02-03 11:28:22 +09:00
logCalls = [ ]
toastCalls = [ ]
2026-02-04 15:25:25 +09:00
SessionCategoryRegistry . clear ( )
2026-05-16 16:21:48 +09:00
clearAllDelegatedChildSessionBootstrap ( )
2026-05-19 16:02:18 +09:00
releaseAllPromptAsyncReservationsForTesting ( )
2026-04-10 15:53:32 +09:00
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
2026-02-03 11:28:22 +09:00
} )
afterEach ( ( ) = > {
2026-02-04 15:25:25 +09:00
SessionCategoryRegistry . clear ( )
2026-05-16 16:21:48 +09:00
clearAllDelegatedChildSessionBootstrap ( )
2026-05-19 16:02:18 +09:00
releaseAllPromptAsyncReservationsForTesting ( )
2026-04-10 15:53:32 +09:00
mock . restore ( )
2026-02-03 11:28:22 +09:00
} )
2026-02-11 16:59:26 -05:00
function createMockPluginInput ( overrides ? : {
session ? : {
messages ? : ( args : unknown ) = > Promise < unknown >
promptAsync ? : ( args : unknown ) = > Promise < unknown >
2026-02-12 13:10:47 -05:00
abort ? : ( args : unknown ) = > Promise < unknown >
2026-05-15 11:49:55 +09:00
status ? : ( ) = > Promise < unknown >
2026-02-11 16:59:26 -05:00
}
2026-05-17 00:08:30 +09:00
} ) : RuntimeFallbackPluginInput {
return unsafeTestValue < RuntimeFallbackPluginInput > ( {
2026-02-03 11:28:22 +09:00
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 ,
} )
} ,
} ,
2026-02-11 16:59:26 -05:00
session : {
messages : overrides?.session?.messages ? ? ( async ( ) = > ( { data : [ ] } ) ) ,
promptAsync : overrides?.session?.promptAsync ? ? ( async ( ) = > ( { } ) ) ,
2026-02-12 13:10:47 -05:00
abort : overrides?.session?.abort ? ? ( async ( ) = > ( { } ) ) ,
2026-05-15 11:49:55 +09:00
. . . ( overrides ? . session ? . status ? { status : overrides.session.status } : { } ) ,
2026-02-11 16:59:26 -05:00
} ,
2026-02-03 11:28:22 +09:00
} ,
directory : "/test/dir" ,
2026-05-12 14:22:05 +09:00
} )
2026-02-03 11:28:22 +09:00
}
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 ,
}
}
2026-02-04 15:25:25 +09:00
function createMockPluginConfigWithCategoryFallback ( fallbackModels : string [ ] ) : OhMyOpenCodeConfig {
return {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-02-04 15:25:25 +09:00
categories : {
test : {
fallback_models : fallbackModels ,
} ,
} ,
}
}
2026-03-12 01:43:17 +09:00
function createMockPluginConfigWithCategoryModel (
categoryName : string ,
model : string ,
fallbackModels : string [ ] ,
variant? : string ,
) : OhMyOpenCodeConfig {
return {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-03-12 01:43:17 +09:00
categories : {
[ categoryName ] : {
model ,
fallback_models : fallbackModels ,
. . . ( variant ? { variant } : { } ) ,
} ,
} ,
}
}
2026-02-03 11:28:22 +09:00
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" ,
2026-03-07 02:33:32 +09:00
properties : { info : { id : sessionID , model : "openai/gpt-5.4" } } ,
2026-02-03 11:28:22 +09:00
} ,
} )
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" ,
2026-02-26 21:01:05 +09:00
properties : { info : { id : sessionID , model : "google/gemini-3.1-pro" } } ,
2026-02-03 11:28:22 +09:00
} ,
} )
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 ( )
} )
2026-02-12 13:10:47 -05:00
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 } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" ] ) ,
2026-02-12 13:10:47 -05:00
} )
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 ( )
2026-03-07 02:33:32 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "google/gemini-2.5-pro" , to : "openai/gpt-5.4" } )
2026-02-12 13:10:47 -05:00
} )
2026-02-03 11:28:22 +09:00
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 ( )
} )
2026-04-21 18:30:04 +09:00
test ( "should trigger fallback for quota exhaustion to try next configured model" , async ( ) = > {
2026-03-29 04:53:36 +09:00
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." } ,
} ,
} ,
} )
2026-04-21 18:30:04 +09:00
// quota exhaustion now triggers fallback to the next model
2026-03-29 04:53:36 +09:00
const fallbackLog = logCalls . find ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
2026-04-21 18:30:04 +09:00
expect ( fallbackLog ) . toBeDefined ( )
2026-03-29 04:53:36 +09:00
} )
2026-02-12 13:10:47 -05:00
test ( "should continue fallback chain when fallback model is not found" , async ( ) = > {
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( { notify_on_fallback : false } ) ,
pluginConfig : createMockPluginConfigWithCategoryFallback ( [
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4.7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
} )
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 ,
2026-05-16 02:29:48 -04:00
// 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" ,
2026-04-17 14:51:52 +09:00
error : { name : "UnknownError" , data : { message : "Model not found: anthropic/claude-opus-4.7." } } ,
2026-02-12 13:10:47 -05:00
} ,
} ,
} )
const fallbackLogs = logCalls . filter ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
expect ( fallbackLogs . length ) . toBeGreaterThanOrEqual ( 2 )
2026-04-17 14:51:52 +09:00
expect ( fallbackLogs [ 1 ] ? . data ) . toMatchObject ( { from : "anthropic/claude-opus-4.7" , to : "openai/gpt-5.4" } )
2026-02-12 13:10:47 -05:00
const nonRetryLog = logCalls . find (
( c ) = > c . msg . includes ( "Error not retryable" ) && ( c . data as { sessionID? : string } | undefined ) ? . sessionID === sessionID
)
expect ( nonRetryLog ) . toBeUndefined ( )
} )
2026-03-12 01:43:17 +09:00
test ( "should continue fallback chain when ProviderModelNotFoundError occurs" , async ( ) = > {
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( { notify_on_fallback : false } ) ,
pluginConfig : createMockPluginConfigWithCategoryFallback ( [
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4.7" ,
2026-03-12 01:43:17 +09:00
"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 ,
2026-05-16 02:29:48 -04:00
// 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" ,
2026-03-12 01:43:17 +09:00
error : {
name : "ProviderModelNotFoundError" ,
data : {
providerID : "anthropic" ,
2026-04-17 14:51:52 +09:00
modelID : "claude-opus-4.7" ,
message : "Model not found: anthropic/claude-opus-4.7." ,
2026-03-12 01:43:17 +09:00
} ,
} ,
} ,
} ,
} )
const fallbackLogs = logCalls . filter ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
expect ( fallbackLogs . length ) . toBeGreaterThanOrEqual ( 2 )
2026-04-17 14:51:52 +09:00
expect ( fallbackLogs [ 1 ] ? . data ) . toMatchObject ( { from : "anthropic/claude-opus-4.7" , to : "openai/gpt-5.4" } )
2026-03-12 01:43:17 +09:00
} )
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" ,
} )
} )
2026-05-16 16:21:48 +09:00
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" ,
2026-05-17 00:08:30 +09:00
system : "delegated child system prompt" ,
tools : { call_omo_agent : true , question : false , task : false } ,
2026-05-16 16:21:48 +09:00
} )
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 } >
2026-05-17 00:08:30 +09:00
system? : string
tools? : Record < string , boolean >
2026-05-16 16:21:48 +09:00
variant? : string
} | undefined
expect ( promptBody ? . model ) . toEqual ( { providerID : "openai" , modelID : "gpt-5.4" } )
expect ( promptBody ? . variant ) . toBe ( "high" )
2026-05-17 00:08:30 +09:00
expect ( promptBody ? . system ) . toBe ( "delegated child system prompt" )
expect ( promptBody ? . tools ? . question ) . toBe ( false )
expect ( promptBody ? . tools ? . call_omo_agent ) . toBe ( true )
2026-05-16 16:21:48 +09:00
expect ( promptBody ? . parts ? . [ 0 ] ? . text ) . toContain ( "inspect src/tools/delegate-task" )
2026-05-18 13:12:49 +09:00
expect ( getDelegatedChildSessionBootstrap ( sessionID ) ) . toBeUndefined ( )
2026-05-16 16:21:48 +09:00
} )
2026-05-17 00:08:30 +09:00
test ( "should use persisted user prompt while preserving delegated bootstrap launch context" , async ( ) = > {
2026-05-16 16:21:48 +09:00
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" ,
2026-05-17 00:08:30 +09:00
system : "persisted delegated child system prompt" ,
tools : { call_omo_agent : true , question : false , task : false } ,
2026-05-16 16:21:48 +09:00
} )
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 } >
2026-05-17 00:08:30 +09:00
system? : string
tools? : Record < string , boolean >
2026-05-16 16:21:48 +09:00
} | undefined
expect ( promptBody ? . parts ? . [ 0 ] ? . text ) . toBe ( "persisted child task prompt" )
2026-05-17 00:08:30 +09:00
expect ( promptBody ? . system ) . toBe ( "persisted delegated child system prompt" )
expect ( promptBody ? . tools ? . question ) . toBe ( false )
expect ( promptBody ? . tools ? . call_omo_agent ) . toBe ( true )
2026-05-16 16:21:48 +09:00
expect ( getDelegatedChildSessionBootstrap ( sessionID ) ) . toBeUndefined ( )
} )
2026-02-12 13:10:47 -05:00
test ( "should trigger fallback on Copilot auto-retry signal in message.updated" , async ( ) = > {
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( { notify_on_fallback : false } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" ] ) ,
2026-02-12 13:10:47 -05:00
} )
const sessionID = "test-session-copilot-auto-retry"
SessionCategoryRegistry . register ( sessionID , "test" )
await hook . event ( {
event : {
type : "session.created" ,
2026-04-17 14:51:52 +09:00
properties : { info : { id : sessionID , model : "github-copilot/claude-opus-4.7" } } ,
2026-02-12 13:10:47 -05:00
} ,
} )
await hook . event ( {
event : {
type : "message.updated" ,
properties : {
info : {
sessionID ,
role : "assistant" ,
2026-04-17 14:51:52 +09:00
model : "github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
status :
"Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]" ,
} ,
} ,
} ,
} )
2026-02-12 17:13:34 -05:00
const signalLog = logCalls . find ( ( c ) = > c . msg . includes ( "Detected provider auto-retry signal" ) )
2026-02-12 13:10:47 -05:00
expect ( signalLog ) . toBeDefined ( )
const fallbackLog = logCalls . find ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
expect ( fallbackLog ) . toBeDefined ( )
2026-04-17 14:51:52 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "github-copilot/claude-opus-4.7" , to : "openai/gpt-5.4" } )
2026-02-12 13:10:47 -05:00
} )
2026-02-12 17:13:34 -05:00
test ( "should trigger fallback on OpenAI auto-retry signal in message.updated" , async ( ) = > {
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
2026-04-08 13:40:40 +09:00
config : createMockConfig ( { notify_on_fallback : false , timeout_seconds : 30 } ) ,
2026-04-17 14:51:52 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "anthropic/claude-opus-4-7" ] ) ,
2026-02-12 17:13:34 -05:00
} )
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 ( )
2026-04-17 14:51:52 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "openai/gpt-5.3-codex" , to : "anthropic/claude-opus-4-7" } )
2026-02-12 17:13:34 -05:00
} )
2026-03-04 20:07:06 +01:00
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" ,
2026-04-17 14:51:52 +09:00
properties : { info : { id : sessionID , model : "quotio/claude-opus-4-7" } } ,
2026-03-04 20:07:06 +01:00
} ,
} )
await hook . event ( {
event : {
type : "message.updated" ,
properties : {
info : {
sessionID ,
role : "assistant" ,
2026-04-17 14:51:52 +09:00
model : "quotio/claude-opus-4-7" ,
2026-03-04 20:07:06 +01:00
} ,
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 ( )
2026-04-17 14:51:52 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "quotio/claude-opus-4-7" , to : "openai/gpt-5.2" } )
2026-03-04 20:07:06 +01:00
} )
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" ,
2026-04-17 14:51:52 +09:00
properties : { info : { id : sessionID , model : "quotio/claude-opus-4-7" } } ,
2026-03-04 20:07:06 +01:00
} ,
} )
await hook . event ( {
event : {
type : "message.updated" ,
properties : {
info : {
sessionID ,
role : "assistant" ,
2026-04-17 14:51:52 +09:00
model : "quotio/claude-opus-4-7" ,
2026-03-04 20:07:06 +01:00
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 ( )
2026-04-17 14:51:52 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "quotio/claude-opus-4-7" , to : "openai/gpt-5.2" } )
2026-03-04 20:07:06 +01:00
} )
2026-03-04 18:35:09 +01:00
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" ,
2026-04-17 14:51:52 +09:00
properties : { info : { id : sessionID , model : "quotio/claude-opus-4-7" } } ,
2026-03-04 18:35:09 +01:00
} ,
} )
await hook . event ( {
event : {
type : "session.status" ,
properties : {
sessionID ,
status : {
type : "retry" ,
2026-03-04 20:52:15 +01:00
next : 476 ,
2026-03-04 18:35:09 +01:00
attempt : 1 ,
2026-04-17 14:51:52 +09:00
message : "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]" ,
2026-03-04 18:35:09 +01:00
} ,
} ,
} ,
} )
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 ( )
2026-04-17 14:51:52 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "quotio/claude-opus-4-7" , to : "openai/gpt-5.2" } )
2026-03-04 18:35:09 +01:00
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" ,
2026-04-17 14:51:52 +09:00
properties : { info : { id : sessionID , model : "quotio/claude-opus-4-7" } } ,
2026-03-04 18:35:09 +01:00
} ,
} )
await hook . event ( {
event : {
type : "session.status" ,
properties : {
sessionID ,
status : {
type : "retry" ,
2026-03-04 20:52:15 +01:00
next : 476 ,
2026-03-04 18:35:09 +01:00
attempt : 1 ,
2026-04-17 14:51:52 +09:00
message : "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]" ,
2026-03-04 18:35:09 +01:00
} ,
} ,
} ,
} )
await hook . event ( {
event : {
type : "session.status" ,
properties : {
sessionID ,
status : {
type : "retry" ,
2026-03-04 20:52:15 +01:00
next : 475 ,
2026-03-04 18:35:09 +01:00
attempt : 1 ,
2026-04-17 14:51:52 +09:00
message : "All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 55s attempt #1]" ,
2026-03-04 18:35:09 +01:00
} ,
} ,
} ,
} )
expect ( promptCalls . length ) . toBe ( 1 )
} )
2026-02-12 17:49:13 -05:00
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 } ) ,
2026-04-17 14:51:52 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "anthropic/claude-opus-4-7" ] ) ,
2026-02-12 17:49:13 -05:00
} )
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 ( )
} )
2026-02-03 11:28:22 +09:00
test ( "should log when no fallback models configured" , async ( ) = > {
2026-02-11 16:59:26 -05:00
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( ) ,
2026-03-29 04:53:36 +09:00
pluginConfig : {
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
} ,
2026-02-11 16:59:26 -05:00
} )
2026-02-03 11:28:22 +09:00
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 ( )
} )
2026-02-12 13:10:47 -05:00
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 } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" ] ) ,
2026-02-12 13:10:47 -05:00
} )
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 ( )
2026-03-07 02:33:32 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "google/gemini-2.5-pro" , to : "openai/gpt-5.4" } )
2026-02-12 13:10:47 -05:00
} )
2026-03-12 01:43:17 +09:00
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" ,
} )
} )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
"anthropic/claude-opus-4-7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
}
)
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." ,
} ,
} ,
2026-04-17 14:51:52 +09:00
model : "github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
} ,
} ,
} ,
} )
const fallbackLogs = logCalls . filter ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
expect ( fallbackLogs ) . toHaveLength ( 1 )
void sessionErrorPromise
} )
2026-05-19 16:02:18 +09:00
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 ( )
} )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
"anthropic/claude-opus-4-7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
}
)
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" ,
2026-04-17 14:51:52 +09:00
model : "github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
status :
"Too Many Requests: quota exceeded [retrying in ~2 weeks attempt #1]" ,
} ,
} ,
} ,
} )
expect ( retriedModels . length ) . toBeGreaterThanOrEqual ( 2 )
2026-04-17 14:51:52 +09:00
expect ( retriedModels [ 0 ] ) . toBe ( "github-copilot/claude-opus-4.7" )
2026-04-10 23:34:38 +02:00
expect ( retriedModels [ 1 ] ) . toBe ( "openai/gpt-5.4" )
const equivalentSkipLog = logCalls . find ( ( c ) = > c . msg . includes ( "Skipping equivalent fallback model" ) )
expect ( equivalentSkipLog ) . toBeDefined ( )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
"anthropic/claude-opus-4-7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
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 ) )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toContain ( "github-copilot/claude-opus-4.7" )
2026-04-10 23:34:38 +02:00
expect ( retriedModels ) . toContain ( "openai/gpt-5.4" )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
"anthropic/claude-opus-4-7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
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 ,
2026-04-17 14:51:52 +09:00
model : { providerID : "github-copilot" , modelID : "claude-opus-4.7" } ,
2026-02-12 13:10:47 -05:00
} ,
output
)
await new Promise ( ( resolve ) = > setTimeout ( resolve , 50 ) )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toContain ( "github-copilot/claude-opus-4.7" )
2026-04-10 23:34:38 +02:00
expect ( retriedModels ) . toContain ( "openai/gpt-5.4" )
2026-02-12 13:10:47 -05:00
} )
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
"anthropic/claude-opus-4-7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
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 )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toContain ( "github-copilot/claude-opus-4.7" )
2026-04-10 23:34:38 +02:00
expect ( retriedModels ) . toContain ( "openai/gpt-5.4" )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
"anthropic/claude-opus-4-7" ,
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-12 13:10:47 -05:00
] ) ,
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." ,
} ,
} ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toContain ( "github-copilot/claude-opus-4.7" )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
"openai/gpt-5.3-codex" ,
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4-7" ,
2026-02-12 13:10:47 -05:00
] ) ,
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." ,
} ,
} ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toEqual ( [ "github-copilot/claude-opus-4.7" ] )
2026-02-12 13:10:47 -05:00
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 ) )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toEqual ( [ "github-copilot/claude-opus-4.7" ] )
2026-02-12 13:10:47 -05:00
} )
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
"openai/gpt-5.3-codex" ,
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4-7" ,
2026-02-12 13:10:47 -05:00
] ) ,
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." ,
} ,
} ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toEqual ( [ "github-copilot/claude-opus-4.7" ] )
2026-02-12 13:10:47 -05:00
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" )
} )
2026-02-12 17:13:34 -05:00
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" ,
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4-7" ,
2026-02-12 17:13:34 -05:00
] ) ,
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 ) )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toContain ( "anthropic/claude-opus-4-7" )
2026-02-12 17:13:34 -05:00
} )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
"openai/gpt-5.3-codex" ,
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4-7" ,
2026-02-12 13:10:47 -05:00
] ) ,
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." ,
} ,
} ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toEqual ( [ "github-copilot/claude-opus-4.7" ] )
2026-02-12 13:10:47 -05:00
await hook . event ( {
event : {
type : "message.updated" ,
properties : {
info : {
sessionID ,
role : "assistant" ,
2026-04-17 14:51:52 +09:00
model : "github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
} ,
} ,
} ,
} )
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
"openai/gpt-5.3-codex" ,
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4-7" ,
2026-02-12 13:10:47 -05:00
] ) ,
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." ,
} ,
} ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toEqual ( [ "github-copilot/claude-opus-4.7" ] )
2026-02-12 13:10:47 -05:00
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 ( [
2026-04-17 14:51:52 +09:00
"github-copilot/claude-opus-4.7" ,
2026-02-12 13:10:47 -05:00
"openai/gpt-5.3-codex" ,
2026-04-17 14:51:52 +09:00
"anthropic/claude-opus-4-7" ,
2026-02-12 13:10:47 -05:00
] ) ,
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." ,
} ,
} ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toEqual ( [ "github-copilot/claude-opus-4.7" ] )
2026-02-12 13:10:47 -05:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID } ,
} ,
} )
await new Promise ( ( resolve ) = > setTimeout ( resolve , 60 ) )
expect ( retriedModels ) . toContain ( "openai/gpt-5.3-codex" )
} )
2026-02-19 12:13:42 +09:00
2026-04-21 18:30:04 +09:00
test ( "triggers fallback for quota exhaustion in error parts to try next model" , async ( ) = > {
2026-02-19 12:13:42 +09:00
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 } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" ] ) ,
2026-02-19 12:13:42 +09:00
}
)
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)" } ] ,
} ,
} ,
} )
2026-04-21 18:30:04 +09:00
// quota exhaustion now triggers fallback to next configured model
expect ( retriedModels . length ) . toBeGreaterThanOrEqual ( 1 )
2026-02-19 12:13:42 +09:00
} )
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 } ) ,
2026-04-17 14:51:52 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "anthropic/claude-opus-4-7" ] ) ,
2026-02-19 12:13:42 +09:00
}
)
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" } ,
] ,
} ,
} ,
} )
2026-04-17 14:51:52 +09:00
expect ( retriedModels ) . toContain ( "anthropic/claude-opus-4-7" )
2026-02-19 12:13:42 +09:00
} )
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 } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" ] ) ,
2026-02-19 12:13:42 +09:00
}
)
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 )
} )
2026-02-03 11:28:22 +09:00
} )
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 ( )
} )
} )
2026-02-03 12:09:04 +09:00
describe ( "model switching via chat.message" , ( ) = > {
2026-02-04 15:25:25 +09:00
test ( "should apply fallback model on next chat.message after error" , async ( ) = > {
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( { notify_on_fallback : false } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" , "google/gemini-3.1-pro" ] ) ,
2026-02-04 15:25:25 +09:00
} )
2026-02-03 12:09:04 +09:00
const sessionID = "test-session-switch"
2026-02-04 15:25:25 +09:00
SessionCategoryRegistry . register ( sessionID , "test" )
2026-02-03 12:09:04 +09:00
2026-02-04 15:25:25 +09:00
//#given
2026-02-03 12:09:04 +09:00
await hook . event ( {
event : {
type : "session.created" ,
properties : { info : { id : sessionID , model : "anthropic/claude-opus-4-5" } } ,
} ,
} )
2026-02-04 15:25:25 +09:00
//#when
2026-02-03 12:09:04 +09:00
await hook . event ( {
event : {
type : "session.error" ,
2026-02-04 15:25:25 +09:00
properties : { sessionID , error : { statusCode : 429 , message : "Rate limit" } } ,
2026-02-03 12:09:04 +09:00
} ,
} )
2026-02-12 13:10:47 -05:00
const output : { message : { model ? : { providerID : string ; modelID : string } } ; parts : Array < { type : string ; text? : string } > } = {
message : { } ,
parts : [ ] ,
}
2026-02-04 15:25:25 +09:00
await hook [ "chat.message" ] ? . (
2026-02-11 16:59:26 -05:00
{ sessionID } ,
2026-02-04 15:25:25 +09:00
output
)
2026-03-07 02:33:32 +09:00
expect ( output . message . model ) . toEqual ( { providerID : "openai" , modelID : "gpt-5.4" } )
2026-02-03 12:09:04 +09:00
} )
test ( "should notify when fallback occurs" , async ( ) = > {
2026-02-04 15:25:25 +09:00
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( { notify_on_fallback : true } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithCategoryFallback ( [ "openai/gpt-5.4" ] ) ,
2026-02-04 15:25:25 +09:00
} )
2026-02-03 12:09:04 +09:00
const sessionID = "test-session-notify"
2026-02-04 15:25:25 +09:00
SessionCategoryRegistry . register ( sessionID , "test" )
2026-02-03 12:09:04 +09:00
await hook . event ( {
event : {
type : "session.created" ,
properties : { info : { id : sessionID , model : "anthropic/claude-opus-4-5" } } ,
} ,
} )
await hook . event ( {
event : {
type : "session.error" ,
2026-02-04 15:25:25 +09:00
properties : { sessionID , error : { statusCode : 429 } } ,
2026-02-03 12:09:04 +09:00
} ,
} )
2026-02-04 15:25:25 +09:00
expect ( toastCalls . length ) . toBe ( 1 )
2026-03-07 02:33:32 +09:00
expect ( toastCalls [ 0 ] ? . message . includes ( "gpt-5.4" ) ) . toBe ( true )
2026-02-03 12:09:04 +09:00
} )
} )
describe ( "fallback models configuration" , ( ) = > {
2026-02-10 00:25:47 +09:00
function createMockPluginConfigWithAgentFallback ( agentName : string , fallbackModels : string [ ] ) : OhMyOpenCodeConfig {
return {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-02-10 00:25:47 +09:00
agents : {
[ agentName ] : {
fallback_models : fallbackModels ,
} ,
} ,
}
}
2026-02-03 12:09:04 +09:00
test ( "should use agent-level fallback_models" , async ( ) = > {
const input = createMockPluginInput ( )
2026-02-10 00:25:47 +09:00
const hook = createRuntimeFallbackHook ( input , {
config : createMockConfig ( { notify_on_fallback : false } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithAgentFallback ( "oracle" , [ "openai/gpt-5.4" , "google/gemini-3.1-pro" ] ) ,
2026-02-10 00:25:47 +09:00
} )
2026-02-03 12:09:04 +09:00
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" } ,
} ,
} )
2026-03-07 02:33:32 +09:00
//#then - should prepare fallback to openai/gpt-5.4
2026-02-10 00:25:47 +09:00
const fallbackLog = logCalls . find ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
2026-02-03 12:09:04 +09:00
expect ( fallbackLog ) . toBeDefined ( )
2026-03-07 02:33:32 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { from : "anthropic/claude-opus-4-5" , to : "openai/gpt-5.4" } )
2026-02-03 12:09:04 +09:00
} )
test ( "should detect agent from sessionID pattern" , async ( ) = > {
2026-02-10 00:25:47 +09:00
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
config : createMockConfig ( { notify_on_fallback : false } ) ,
2026-03-07 02:33:32 +09:00
pluginConfig : createMockPluginConfigWithAgentFallback ( "sisyphus" , [ "openai/gpt-5.4" ] ) ,
2026-02-10 00:25:47 +09:00
} )
2026-02-03 12:09:04 +09:00
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 } } ,
} ,
} )
2026-02-10 00:25:47 +09:00
//#then - should detect sisyphus from sessionID and use its fallback
const fallbackLog = logCalls . find ( ( c ) = > c . msg . includes ( "Preparing fallback" ) )
expect ( fallbackLog ) . toBeDefined ( )
2026-03-07 02:33:32 +09:00
expect ( fallbackLog ? . data ) . toMatchObject ( { to : "openai/gpt-5.4" } )
2026-02-03 12:09:04 +09:00
} )
2026-02-11 16:59:26 -05:00
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 } ) ,
2026-04-10 23:34:38 +02:00
pluginConfig : createMockPluginConfigWithAgentFallback ( "prometheus" , [
"github-copilot/claude-opus-4.7" ,
"openai/gpt-5.4" ,
] ) ,
2026-02-11 16:59:26 -05:00
} ,
)
const sessionID = "test-preserve-agent-on-retry"
await hook . event ( {
event : {
type : "session.error" ,
properties : {
sessionID ,
2026-04-17 14:51:52 +09:00
model : "anthropic/claude-opus-4-7" ,
2026-02-11 16:59:26 -05:00
error : { statusCode : 503 , message : "Service unavailable" } ,
agent : "prometheus" ,
} ,
} ,
} )
expect ( promptCalls . length ) . toBe ( 1 )
const callBody = promptCalls [ 0 ] ? . body as Record < string , unknown >
2026-04-07 10:08:04 +09:00
expect ( callBody ? . agent ) . toBe ( "prometheus" )
2026-04-10 23:34:38 +02:00
expect ( callBody ? . model ) . toEqual ( { providerID : "openai" , modelID : "gpt-5.4" } )
2026-02-11 16:59:26 -05:00
} )
2026-05-15 11:49:55 +09:00
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 )
} )
2026-02-03 12:09:04 +09:00
} )
describe ( "cooldown mechanism" , ( ) = > {
test ( "should respect cooldown period before retrying failed model" , async ( ) = > {
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
2026-02-04 15:25:25 +09:00
config : createMockConfig ( { cooldown_seconds : 60 , notify_on_fallback : false } ) ,
pluginConfig : createMockPluginConfigWithCategoryFallback ( [
2026-03-07 02:33:32 +09:00
"openai/gpt-5.4" ,
2026-02-04 15:25:25 +09:00
"anthropic/claude-opus-4-5" ,
] ) ,
2026-02-03 12:09:04 +09:00
} )
const sessionID = "test-session-cooldown"
2026-02-04 15:25:25 +09:00
SessionCategoryRegistry . register ( sessionID , "test" )
2026-02-03 12:09:04 +09:00
await hook . event ( {
event : {
type : "session.created" ,
properties : { info : { id : sessionID , model : "anthropic/claude-opus-4-5" } } ,
} ,
} )
2026-02-04 15:25:25 +09:00
//#when - first error occurs, switches to openai
2026-02-03 12:09:04 +09:00
await hook . event ( {
event : {
type : "session.error" ,
properties : { sessionID , error : { statusCode : 429 } } ,
} ,
} )
2026-05-16 02:29:48 -04:00
// Simulate the fallback session completing before the next error arrives
await hook . event ( { event : { type : "session.idle" , properties : { sessionID } } } )
2026-02-04 15:25:25 +09:00
//#when - second error occurs immediately; tries to switch back to original model but should be in cooldown
2026-02-03 12:09:04 +09:00
await hook . event ( {
event : {
type : "session.error" ,
2026-05-16 02:29:48 -04:00
// model matches pendingFallbackModel so the awaiting-fallback gate lets this through
properties : { sessionID , model : "openai/gpt-5.4" , error : { statusCode : 429 } } ,
2026-02-03 12:09:04 +09:00
} ,
} )
2026-02-04 15:25:25 +09:00
const cooldownSkipLog = logCalls . find ( ( c ) = > c . msg . includes ( "Skipping fallback model in cooldown" ) )
expect ( cooldownSkipLog ) . toBeDefined ( )
2026-02-03 12:09:04 +09:00
} )
} )
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 ( )
} )
} )
2026-02-21 05:59:30 +09:00
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 : {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-02-21 05:59:30 +09:00
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 : {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-02-21 05:59:30 +09:00
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" ,
2026-05-14 01:03:19 +09:00
properties : { sessionID , model : "provider-a/model-a" , error : { statusCode : 429 , message : "Rate limit again" } } ,
2026-02-21 05:59:30 +09:00
} ,
} )
//#then - both should advance the chain (no skip)
const fallbackLogs = logCalls . filter ( ( call ) = > call . msg . includes ( "Preparing fallback" ) )
expect ( fallbackLogs . length ) . toBeGreaterThanOrEqual ( 2 )
} )
2026-05-14 01:03:19 +09:00
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 ( )
} )
2026-05-18 17:48:04 +09:00
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 ( )
} )
2026-02-21 05:59:30 +09:00
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 : {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-02-21 05:59:30 +09:00
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 )
} )
2026-05-15 19:13:48 +09:00
test ( "pendingFallbackModel advances chain on subsequent error even when persisted" , async ( ) = > {
2026-02-21 05:59:30 +09:00
//#given
2026-05-15 19:13:48 +09:00
const hook = createRuntimeFallbackHook ( createMockPluginInput ( ) , {
2026-02-21 05:59:30 +09:00
config : createMockConfig ( { notify_on_fallback : false } ) ,
pluginConfig : {
2026-03-29 04:53:36 +09:00
git_master : {
commit_footer : true ,
include_co_authored_by : true ,
git_env_prefix : "GIT_MASTER=1" ,
} ,
2026-02-21 05:59:30 +09:00
categories : {
test : {
fallback_models : [ "provider-a/model-a" , "provider-b/model-b" ] ,
} ,
} ,
} ,
} )
2026-05-15 19:13:48 +09:00
const sessionID = "test-race-pending-persists"
SessionCategoryRegistry . register ( sessionID , "test" )
2026-02-21 05:59:30 +09:00
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" } } ,
} ,
} )
2026-05-16 02:29:48 -04:00
const autoRetryLog = logCalls . find ( ( call ) = >
call . msg . includes ( "No user message parts found for auto-retry" ) &&
call . msg . includes ( "using synthetic continuation" ) ,
)
2026-05-15 19:13:48 +09:00
expect ( autoRetryLog ) . toBeDefined ( )
2026-02-21 05:59:30 +09:00
2026-05-16 02:29:48 -04:00
// Simulate the fallback session completing before the next error arrives
await hook . event ( { event : { type : "session.idle" , properties : { sessionID } } } )
2026-02-21 05:59:30 +09:00
//#when - second error fires after retry completed (retryInFlight cleared)
await hook . event ( {
event : {
type : "session.error" ,
2026-05-16 02:29:48 -04:00
// 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" } } ,
2026-02-21 05:59:30 +09:00
} ,
} )
//#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 )
} )
} )
2026-02-03 11:28:22 +09:00
} )