fix(model-fallback): clone session fallback chains

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Choi Kijin / 최 기진 / チョイ キジン
2026-04-28 21:43:10 +09:00
parent 2237c1accd
commit 185665707c
2 changed files with 26 additions and 42 deletions
@@ -39,7 +39,7 @@ export function createModelFallbackStateController(input: {
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
sessionFallbackChains.set(sessionID, fallbackChain?.length ? fallbackChain : [])
sessionFallbackChains.set(sessionID, fallbackChain?.length ? [...fallbackChain] : [])
}
function clearSessionFallbackChain(sessionID: string): void {
@@ -47,7 +47,8 @@ export function createModelFallbackStateController(input: {
}
function getSessionFallbackChain(sessionID: string): FallbackEntry[] | undefined {
return sessionFallbackChains.get(sessionID)
const fallbackChain = sessionFallbackChains.get(sessionID)
return fallbackChain ? [...fallbackChain] : undefined
}
function setPendingModelFallback(
@@ -61,7 +62,7 @@ export function createModelFallbackStateController(input: {
const fallbackChain = sessionFallbackChains.get(sessionID) ?? requirements?.fallbackChain
if (!fallbackChain?.length) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
log(`[model-fallback] No fallback chain for agent: ${agentName} (key: ${agentKey})`)
return false
}
@@ -74,12 +75,12 @@ export function createModelFallbackStateController(input: {
attemptCount: 0,
pending: true,
})
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
log(`[model-fallback] Set pending fallback for session: ${sessionID}, agent: ${agentName}`)
return true
}
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
log(`[model-fallback] Pending fallback already armed for session: ${sessionID}`)
return false
}
@@ -87,10 +88,10 @@ export function createModelFallbackStateController(input: {
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
log(`[model-fallback] Fallback chain exhausted for session: ${sessionID}`)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
log(`[model-fallback] Re-armed pending fallback for session: ${sessionID}`)
return true
}
@@ -101,7 +102,7 @@ export function createModelFallbackStateController(input: {
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) return fallback
log("[model-fallback] No more fallbacks for session: " + sessionID)
log(`[model-fallback] No more fallbacks for session: ${sessionID}`)
pendingModelFallbacks.delete(sessionID)
return null
}
+17 -34
View File
@@ -66,6 +66,7 @@ async function importFreshModelFallbackHookModule() {
const {
clearPendingModelFallback,
createModelFallbackHook,
getSessionFallbackChain,
setSessionFallbackChain,
setPendingModelFallback,
} = await importFreshModelFallbackHookModule()
@@ -85,7 +86,6 @@ describe("model fallback hook", () => {
})
test("applies pending fallback on chat.message by overriding model", async () => {
//#given
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
@@ -110,13 +110,11 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.(
{ sessionID: "ses_model_fallback_main" },
output,
)
//#then
expect(output.message["model"]).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-7",
@@ -124,7 +122,6 @@ describe("model fallback hook", () => {
})
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
@@ -145,16 +142,13 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when - first retry is applied
await hook["chat.message"]?.({ sessionID }, firstOutput)
//#then
expect(firstOutput.message["model"]).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-7",
})
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
).toBe(true)
@@ -167,7 +161,6 @@ describe("model fallback hook", () => {
}
await hook["chat.message"]?.({ sessionID }, secondOutput)
//#then - chain should progress to entry[1], not repeat entry[0]
expect(secondOutput.message["model"]).toEqual({
providerID: "opencode-go",
modelID: "kimi-k2.5",
@@ -176,11 +169,9 @@ describe("model fallback hook", () => {
})
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(modelFallback, sessionID)
//#when
const firstSet = setPendingModelFallback(
modelFallback,
sessionID,
@@ -196,14 +187,28 @@ describe("model fallback hook", () => {
"claude-opus-4-7-thinking",
)
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(modelFallback, sessionID)
})
test("isolates stored fallback chains from caller mutations on set and get", () => {
const sessionID = "ses_model_fallback_defensive_copy"
const originalChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
]
setSessionFallbackChain(modelFallback, sessionID, originalChain)
originalChain.push({ providers: ["google"], model: "gemini-2.5-pro" })
const retrieved = getSessionFallbackChain(modelFallback, sessionID)
retrieved?.push({ providers: ["openai"], model: "gpt-5.4" })
expect(getSessionFallbackChain(modelFallback, sessionID)).toEqual([
{ providers: ["anthropic"], model: "claude-opus-4-7" },
])
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(modelFallback, sessionID)
@@ -236,10 +241,8 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "opencode",
modelID: "kimi-k2.5-free",
@@ -248,7 +251,6 @@ describe("model fallback hook", () => {
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(modelFallback, sessionID)
@@ -282,10 +284,8 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.2",
@@ -295,7 +295,6 @@ describe("model fallback hook", () => {
})
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
//#given
const sessionID = "ses_model_fallback_preferred_provider"
clearPendingModelFallback(modelFallback, sessionID)
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
@@ -328,10 +327,8 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "provider-x",
modelID: "fallback-model",
@@ -340,12 +337,10 @@ describe("model fallback hook", () => {
})
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(modelFallback, sessionID)
setSessionFallbackChain(modelFallback, sessionID, undefined)
//#when
const set = setPendingModelFallback(
modelFallback,
sessionID,
@@ -354,13 +349,11 @@ describe("model fallback hook", () => {
"claude-sonnet-4-6",
)
//#then
expect(set).toBe(false)
clearPendingModelFallback(modelFallback, sessionID)
})
test("shows toast when fallback is applied", async () => {
//#given
const toastCalls: Array<{ title: string; message: string }> = []
const hook = createModelFallbackHook({
toast: async ({ title, message }) => {
@@ -390,16 +383,13 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID: "ses_model_fallback_toast" }, output)
//#then
expect(toastCalls.length).toBe(1)
expect(toastCalls[0]?.title).toBe("Model fallback")
})
test("transforms model names for github-copilot provider via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_ghcp"
clearPendingModelFallback(modelFallback, sessionID)
@@ -410,7 +400,6 @@ describe("model fallback hook", () => {
) => Promise<void>
}
// Set a custom fallback chain that routes through github-copilot
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
])
@@ -431,10 +420,8 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then - model name should be transformed from hyphen to dot notation
expect(output.message["model"]).toEqual({
providerID: "github-copilot",
modelID: "claude-sonnet-4.6",
@@ -444,7 +431,6 @@ describe("model fallback hook", () => {
})
test("preserves canonical google preview model names via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_google"
clearPendingModelFallback(modelFallback, sessionID)
@@ -455,7 +441,6 @@ describe("model fallback hook", () => {
) => Promise<void>
}
// Set a custom fallback chain that routes through google
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
])
@@ -476,10 +461,8 @@ describe("model fallback hook", () => {
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then: model name should remain gemini-3.1-pro-preview because no google transform exists for this ID
expect(output.message["model"]).toEqual({
providerID: "google",
modelID: "gemini-3.1-pro-preview",