refactor(model-fallback): fully encapsulate session state in factory closure

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-18 02:35:46 +09:00
parent e2f5c0d361
commit 5e4102566c
23 changed files with 271 additions and 123 deletions
+44 -33
View File
@@ -70,22 +70,23 @@ const {
setPendingModelFallback,
} = await importFreshModelFallbackHookModule()
type ModelFallbackHook = ReturnType<typeof createModelFallbackHook>
describe("model fallback hook", () => {
let modelFallback: ModelFallbackHook
beforeEach(() => {
modelFallback = createModelFallbackHook()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
readConnectedProvidersCacheMock.mockClear()
readProviderModelsCacheMock.mockClear()
selectFallbackProviderMock.mockClear()
clearPendingModelFallback("ses_model_fallback_main")
clearPendingModelFallback("ses_model_fallback_ghcp")
clearPendingModelFallback("ses_model_fallback_google")
})
test("applies pending fallback on chat.message by overriding model", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -93,6 +94,7 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
modelFallback,
"ses_model_fallback_main",
"Sisyphus - Ultraworker",
"anthropic",
@@ -123,7 +125,7 @@ describe("model fallback hook", () => {
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -132,7 +134,7 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_main"
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
).toBe(true)
const firstOutput = {
@@ -154,7 +156,7 @@ describe("model fallback hook", () => {
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
).toBe(true)
const secondOutput = {
@@ -176,16 +178,18 @@ describe("model fallback hook", () => {
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
//#when
const firstSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-7-thinking",
)
const secondSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
@@ -195,28 +199,29 @@ describe("model fallback hook", () => {
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
@@ -239,28 +244,29 @@ describe("model fallback hook", () => {
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"quotio",
@@ -285,28 +291,29 @@ describe("model fallback hook", () => {
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
//#given
const sessionID = "ses_model_fallback_preferred_provider"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["provider-y"], model: "fallback-model" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"provider-x",
@@ -329,17 +336,18 @@ describe("model fallback hook", () => {
providerID: "provider-x",
modelID: "fallback-model",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
//#given
const sessionID = "ses_model_fallback_explicit_none"
clearPendingModelFallback(sessionID)
setSessionFallbackChain(sessionID, undefined)
clearPendingModelFallback(modelFallback, sessionID)
setSessionFallbackChain(modelFallback, sessionID, undefined)
//#when
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Junior",
"anthropic",
@@ -348,7 +356,7 @@ describe("model fallback hook", () => {
//#then
expect(set).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("shows toast when fallback is applied", async () => {
@@ -366,6 +374,7 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
hook,
"ses_model_fallback_toast",
"Sisyphus - Ultraworker",
"anthropic",
@@ -392,9 +401,9 @@ describe("model fallback hook", () => {
test("transforms model names for github-copilot provider via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_ghcp"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -402,11 +411,12 @@ describe("model fallback hook", () => {
}
// Set a custom fallback chain that routes through github-copilot
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Atlas - Plan Executor",
"github-copilot",
@@ -430,15 +440,15 @@ describe("model fallback hook", () => {
modelID: "claude-sonnet-4.6",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("preserves canonical google preview model names via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_google"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -446,11 +456,12 @@ describe("model fallback hook", () => {
}
// Set a custom fallback chain that routes through google
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Oracle",
"google",
@@ -474,7 +485,7 @@ describe("model fallback hook", () => {
modelID: "gemini-3.1-pro-preview",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
})