Jaden f6d5f6f79f fix(model-fallback): apply transformModelForProvider in getNextFallback
The getNextFallback function returned raw model names from the
hardcoded fallback chain without transforming them for the target
provider. For example, github-copilot requires dot notation
(claude-sonnet-4.6) but the fallback chain stores hyphen notation
(claude-sonnet-4-6).

The background-agent retry handler already calls
transformModelForProvider correctly, but the sync chat.message
hook in model-fallback was missing it — a copy-paste omission.

Add transformModelForProvider call in getNextFallback and a test
verifying github-copilot model name transformation.
2026-02-25 17:15:13 +09:00

188 lines
5.4 KiB
TypeScript

import { beforeEach, describe, expect, test } from "bun:test"
import {
clearPendingModelFallback,
createModelFallbackHook,
setSessionFallbackChain,
setPendingModelFallback,
} from "./hook"
describe("model fallback hook", () => {
beforeEach(() => {
clearPendingModelFallback("ses_model_fallback_main")
clearPendingModelFallback("ses_model_fallback_ghcp")
})
test("applies pending fallback on chat.message by overriding model", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
const set = setPendingModelFallback(
"ses_model_fallback_main",
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
variant: "max",
},
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-6",
})
})
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
const sessionID = "ses_model_fallback_main"
expect(
setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "anthropic", "claude-opus-4-6-thinking"),
).toBe(true)
const firstOutput = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
variant: "max",
},
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-6",
})
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "anthropic", "claude-opus-4-6"),
).toBe(true)
const secondOutput = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
},
parts: [{ type: "text", text: "continue" }],
}
await hook["chat.message"]?.({ sessionID }, secondOutput)
//#then - chain should progress to entry[1], not repeat entry[0]
expect(secondOutput.message["model"]).toEqual({
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
expect(secondOutput.message["variant"]).toBeUndefined()
})
test("shows toast when fallback is applied", async () => {
//#given
const toastCalls: Array<{ title: string; message: string }> = []
const hook = createModelFallbackHook({
toast: async ({ title, message }) => {
toastCalls.push({ title, message })
},
}) as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
const set = setPendingModelFallback(
"ses_model_fallback_toast",
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
variant: "max",
},
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(sessionID)
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
// Set a custom fallback chain that routes through github-copilot
setSessionFallbackChain(sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
])
const set = setPendingModelFallback(
sessionID,
"Atlas (Plan Executor)",
"github-copilot",
"claude-sonnet-4-6",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "github-copilot", modelID: "claude-sonnet-4-6" },
},
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",
})
clearPendingModelFallback(sessionID)
})
})