Merge pull request #2302 from RaviTharuma/fix/runtime-fallback-cooldown-session-status

Fix cooldown fallback switching across runtime/model fallback hooks
This commit is contained in:
YeonGyu-Kim
2026-03-09 13:38:46 +09:00
committed by GitHub
29 changed files with 1416 additions and 77 deletions
+116 -1
View File
@@ -140,6 +140,121 @@ describe("model fallback hook", () => {
expect(secondOutput.message["variant"]).toBeUndefined() expect(secondOutput.message["variant"]).toBeUndefined()
}) })
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
//#when
const firstSet = setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
const secondSet = setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
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>
}
setSessionFallbackChain(sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-6" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6",
),
).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
},
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",
})
clearPendingModelFallback(sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
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>
}
setSessionFallbackChain(sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"quotio",
"claude-opus-4-6",
),
).toBe(true)
const output = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
})
test("shows toast when fallback is applied", async () => { test("shows toast when fallback is applied", async () => {
//#given //#given
const toastCalls: Array<{ title: string; message: string }> = [] const toastCalls: Array<{ title: string; message: string }> = []
@@ -199,7 +314,7 @@ describe("model fallback hook", () => {
sessionID, sessionID,
"Atlas (Plan Executor)", "Atlas (Plan Executor)",
"github-copilot", "github-copilot",
"claude-sonnet-4-6", "claude-sonnet-4-5",
) )
expect(set).toBe(true) expect(set).toBe(true)
+23 -1
View File
@@ -39,6 +39,12 @@ const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>() const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>() const sessionFallbackChains = new Map<string, FallbackEntry[]>()
function canonicalizeModelID(modelID: string): string {
return modelID
.toLowerCase()
.replace(/\./g, "-")
}
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void { export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return if (!sessionID) return
if (!fallbackChain || fallbackChain.length === 0) { if (!fallbackChain || fallbackChain.length === 0) {
@@ -77,6 +83,11 @@ export function setPendingModelFallback(
const existing = pendingModelFallbacks.get(sessionID) const existing = pendingModelFallbacks.get(sessionID)
if (existing) { if (existing) {
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
// Preserve progression across repeated session.error retries in same session. // Preserve progression across repeated session.error retries in same session.
// We only mark the next turn as pending fallback application. // We only mark the next turn as pending fallback application.
existing.providerID = currentProviderID existing.providerID = currentProviderID
@@ -140,13 +151,24 @@ export function getNextFallback(
} }
const providerID = selectFallbackProvider(fallback.providers, state.providerID) const providerID = selectFallbackProvider(fallback.providers, state.providerID)
const modelID = transformModelForProvider(providerID, fallback.model)
const isNoOpFallback =
providerID.toLowerCase() === state.providerID.toLowerCase() &&
canonicalizeModelID(modelID) === canonicalizeModelID(state.modelID)
if (isNoOpFallback) {
log("[model-fallback] Skipping no-op fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
continue
}
state.pending = false state.pending = false
log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
return { return {
providerID, providerID,
modelID: transformModelForProvider(providerID, fallback.model), modelID,
variant: fallback.variant, variant: fallback.variant,
} }
} }
+4
View File
@@ -26,6 +26,10 @@ export const RETRYABLE_ERROR_PATTERNS = [
/rate.?limit/i, /rate.?limit/i,
/too.?many.?requests/i, /too.?many.?requests/i,
/quota.?exceeded/i, /quota.?exceeded/i,
/quota\s+will\s+reset\s+after/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/exhausted\s+your\s+capacity/i,
/usage\s+limit\s+has\s+been\s+reached/i, /usage\s+limit\s+has\s+been\s+reached/i,
/service.?unavailable/i, /service.?unavailable/i,
/overloaded/i, /overloaded/i,
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { extractAutoRetrySignal, isRetryableError } from "./error-classifier"
describe("runtime-fallback error classifier", () => {
test("detects cooling-down auto-retry status signals", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("detects single-word cooldown auto-retry status signals", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6 are cooldown [retrying in 7m 56s attempt #1]",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("treats cooling-down retry messages as retryable", () => {
//#given
const error = {
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const retryable = isRetryableError(error, [400, 403, 408, 429, 500, 502, 503, 504, 529])
//#then
expect(retryable).toBe(true)
})
test("ignores non-retry assistant status text", () => {
//#given
const info = {
status: "Thinking...",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeUndefined()
})
})
@@ -102,7 +102,7 @@ export interface AutoRetrySignal {
export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [ export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined), (combined) => /retrying\s+in/i.test(combined),
(combined) => (combined) =>
/(?:too\s+many\s+requests|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached)/i.test(combined), /(?:too\s+many\s+requests|quota\s*exceeded|quota\s+will\s+reset\s+after|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
] ]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined { export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
+87 -1
View File
@@ -2,13 +2,15 @@ import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry" import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants" import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier" import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state" import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models" import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
const sessionStatusRetryKeys = new Map<string, string>()
const handleSessionCreated = (props: Record<string, unknown> | undefined) => { const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined const sessionInfo = props?.info as { id?: string; model?: string } | undefined
@@ -33,6 +35,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
sessionRetryInFlight.delete(sessionID) sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID) sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID) helpers.clearSessionFallbackTimeout(sessionID)
sessionStatusRetryKeys.delete(sessionID)
SessionCategoryRegistry.remove(sessionID) SessionCategoryRegistry.remove(sessionID)
} }
} }
@@ -182,6 +185,88 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
} }
} }
const handleSessionStatus = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
const agent = props?.agent as string | undefined
const model = props?.model as string | undefined
if (!sessionID || status?.type !== "retry") return
const retryMessage = typeof status.message === "string" ? status.message : ""
const retrySignal = extractAutoRetrySignal({ status: retryMessage, message: retryMessage })
if (!retrySignal) return
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
return
}
sessionStatusRetryKeys.set(sessionID, retryKey)
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] session.status retry skipped — retry already in flight`, { sessionID })
return
}
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) return
let state = sessionStates.get(sessionID)
if (!state) {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
const inferredModel = model || (agentConfig?.model as string | undefined)
if (!inferredModel) {
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
return
}
state = createFallbackState(inferredModel)
sessionStates.set(sessionID, state)
}
sessionLastAccess.set(sessionID, Date.now())
if (state.pendingFallbackModel) {
log(`[${HOOK_NAME}] session.status retry skipped (pending fallback in progress)`, {
sessionID,
pendingFallbackModel: state.pendingFallbackModel,
})
return
}
log(`[${HOOK_NAME}] Detected provider auto-retry signal in session.status`, {
sessionID,
model: state.currentModel,
retryAttempt: status.attempt,
})
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && config.notify_on_fallback) {
await deps.ctx.client.tui
.showToast({
body: {
title: "Model Fallback",
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
}
if (result.success && result.newModel) {
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.status")
}
if (!result.success) {
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
}
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => { return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (!config.enabled) return if (!config.enabled) return
@@ -191,6 +276,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
if (event.type === "session.deleted") { handleSessionDeleted(props); return } if (event.type === "session.deleted") { handleSessionDeleted(props); return }
if (event.type === "session.stop") { await handleSessionStop(props); return } if (event.type === "session.stop") { await handleSessionStop(props); return }
if (event.type === "session.idle") { handleSessionIdle(props); return } if (event.type === "session.idle") { handleSessionIdle(props); return }
if (event.type === "session.status") { await handleSessionStatus(props); return }
if (event.type === "session.error") { await handleSessionError(props); return } if (event.type === "session.error") { await handleSessionError(props); return }
} }
} }
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test } from "bun:test"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
describe("runtime-fallback fallback-models", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
})
test("uses category fallback_models when session category is registered", () => {
//#given
const sessionID = "ses_runtime_fallback_category"
SessionCategoryRegistry.register(sessionID, "quick")
const pluginConfig = {
categories: {
quick: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
})
test("uses agent-specific fallback_models when agent is resolved", () => {
//#given
const pluginConfig = {
agents: {
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession("ses_runtime_fallback_agent", "oracle", pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
})
test("does not fall back to another agent chain when agent cannot be resolved", () => {
//#given
const pluginConfig = {
agents: {
sisyphus: {
fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"],
},
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession("ses_runtime_fallback_unknown", undefined, pluginConfig)
//#then
expect(result).toEqual([])
})
})
+2 -14
View File
@@ -1,5 +1,5 @@
import type { OhMyOpenCodeConfig } from "../../config" import type { OhMyOpenCodeConfig } from "../../config"
import { AGENT_NAMES, agentPattern } from "./agent-resolver" import { agentPattern } from "./agent-resolver"
import { HOOK_NAME } from "./constants" import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -51,19 +51,7 @@ export function getFallbackModelsForSession(
if (result) return result if (result) return result
} }
const sisyphusFallback = tryGetFallbackFromAgent("sisyphus") log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
if (sisyphusFallback) {
log(`[${HOOK_NAME}] Using sisyphus fallback models (no agent detected)`, { sessionID })
return sisyphusFallback
}
for (const agentName of AGENT_NAMES) {
const result = tryGetFallbackFromAgent(agentName)
if (result) {
log(`[${HOOK_NAME}] Using ${agentName} fallback models (no agent detected)`, { sessionID })
return result
}
}
return [] return []
} }
+213
View File
@@ -387,6 +387,219 @@ describe("runtime-fallback", () => {
expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" }) expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" })
}) })
test("should trigger fallback on auto-retry signal in assistant text parts", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
})
const sessionID = "test-session-parts-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
},
parts: [
{
type: "text",
text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
},
],
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
})
test("should trigger fallback when auto-retry text parts are nested under info.parts", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
})
const sessionID = "test-session-info-parts-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
parts: [
{
type: "text",
text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
},
],
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
})
test("should trigger fallback on session.status auto-retry signal", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-status-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal in session.status"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
expect(promptCalls.length).toBe(1)
})
test("should deduplicate session.status countdown updates for the same retry attempt", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-status-dedup"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 475,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]",
},
},
},
})
expect(promptCalls.length).toBe(1)
})
test("should NOT trigger fallback on auto-retry signal when timeout_seconds is 0", async () => { test("should NOT trigger fallback on auto-retry signal when timeout_seconds is 0", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), { const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 0 }), config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 0 }),
@@ -57,10 +57,20 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
return async (props: Record<string, unknown> | undefined) => { return async (props: Record<string, unknown> | undefined) => {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = info?.sessionID as string | undefined
const retrySignalResult = extractAutoRetrySignal(info)
const retrySignal = retrySignalResult?.signal
const timeoutEnabled = config.timeout_seconds > 0 const timeoutEnabled = config.timeout_seconds > 0
const parts = props?.parts as Array<{ type?: string; text?: string }> | undefined const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
const parts = eventParts && eventParts.length > 0 ? eventParts : infoParts
const retrySignalResult = extractAutoRetrySignal(info)
const partsText = (parts ?? [])
.filter((p) => typeof p?.text === "string")
.map((p) => (p.text ?? "").trim())
.filter((text) => text.length > 0)
.join("\n")
const retrySignalFromParts = partsText
? extractAutoRetrySignal({ message: partsText, status: partsText, summary: partsText })?.signal
: undefined
const retrySignal = retrySignalResult?.signal ?? retrySignalFromParts
const errorContentResult = containsErrorContent(parts) const errorContentResult = containsErrorContent(parts)
const error = info?.error ?? const error = info?.error ??
(retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined) ?? (retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined) ??
+233 -17
View File
@@ -1,12 +1,17 @@
import { afterEach, describe, expect, test } from "bun:test" declare const require: (name: string) => any
const { afterEach, describe, expect, mock, test } = require("bun:test")
mock.module("../shared/connected-providers-cache", () => ({
readConnectedProvidersCache: () => null,
readProviderModelsCache: () => null,
}))
import { createEventHandler } from "./event" import { createEventHandler } from "./event"
import { createChatMessageHandler } from "./chat-message" import { createChatMessageHandler } from "./chat-message"
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook" import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
describe("createEventHandler - model fallback", () => { describe("createEventHandler - model fallback", () => {
const createHandler = (args?: { hooks?: any }) => { const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
const abortCalls: string[] = [] const abortCalls: string[] = []
const promptCalls: string[] = [] const promptCalls: string[] = []
@@ -26,7 +31,7 @@ describe("createEventHandler - model fallback", () => {
}, },
}, },
} as any, } as any,
pluginConfig: {} as any, pluginConfig: (args?.pluginConfig ?? {}) as any,
firstMessageVariantGate: { firstMessageVariantGate: {
markSessionCreated: () => {}, markSessionCreated: () => {},
clear: () => {}, clear: () => {},
@@ -206,11 +211,222 @@ describe("createEventHandler - model fallback", () => {
//#then //#then
expect(abortCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID]) expect(promptCalls).toEqual([sessionID])
expect(output.message["model"]).toEqual({ expect(output.message["model"]).toMatchObject({
providerID: "anthropic", providerID: "kimi-for-coding",
modelID: "claude-opus-4-6", modelID: "k2p5",
}) })
expect(output.message["variant"]).toBe("max") expect(output.message["variant"]).toBeUndefined()
})
test("does not spam abort/prompt when session.status retry countdown updates", async () => {
//#given
const sessionID = "ses_status_retry_dedup"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_status_dedup",
sessionID,
role: "user",
modelID: "claude-opus-4-6-thinking",
providerID: "anthropic",
agent: "Sisyphus (Ultraworker)",
},
},
},
})
//#when
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
next: 300,
},
},
},
})
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~4 days attempt #1]",
next: 299,
},
},
},
})
//#then
expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID])
})
test("does not trigger model-fallback from session.status when runtime_fallback is enabled", async () => {
//#given
const sessionID = "ses_status_retry_runtime_enabled"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const runtimeFallback = {
event: async () => {},
"chat.message": async () => {},
}
const { handler, abortCalls, promptCalls } = createHandler({
hooks: { modelFallback, runtimeFallback },
pluginConfig: { runtime_fallback: { enabled: true } },
})
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_status_runtime_enabled",
sessionID,
role: "user",
modelID: "claude-opus-4-6",
providerID: "quotio",
agent: "Sisyphus (Ultraworker)",
},
},
},
})
//#when
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
next: 476,
},
},
},
})
//#then
expect(abortCalls).toEqual([])
expect(promptCalls).toEqual([])
})
test("prefers user-configured fallback_models over hardcoded chain on session.status retry", async () => {
//#given
const sessionID = "ses_status_retry_user_fallback"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const pluginConfig = {
agents: {
sisyphus: {
fallback_models: ["quotio/gpt-5.2", "quotio/kimi-k2.5"],
},
},
}
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig })
const chatMessageHandler = createChatMessageHandler({
ctx: {
client: {
tui: {
showToast: async () => ({}),
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
},
hooks: {
modelFallback,
stopContinuationGuard: null,
keywordDetector: null,
claudeCodeHooks: null,
autoSlashCommand: null,
startWork: null,
ralphLoop: null,
} as any,
})
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_status_user_fallback",
sessionID,
role: "user",
time: { created: 1 },
content: [],
modelID: "claude-opus-4-6",
providerID: "quotio",
agent: "Sisyphus (Ultraworker)",
path: { cwd: "/tmp", root: "/tmp" },
},
},
},
})
//#when
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
next: 300,
},
},
},
})
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
await chatMessageHandler(
{
sessionID,
agent: "sisyphus",
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
},
output,
)
//#then
expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID])
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
}) })
test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => { test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => {
@@ -322,21 +538,21 @@ describe("createEventHandler - model fallback", () => {
//#when - first retry cycle //#when - first retry cycle
const first = await triggerRetryCycle() const first = await triggerRetryCycle()
//#then - first fallback entry applied (prefers current provider when available) //#then - first fallback entry applied (no-op skip: claude-opus-4-6 matches current model after normalization)
expect(first.message["model"]).toEqual({ expect(first.message["model"]).toMatchObject({
providerID: "anthropic", providerID: "kimi-for-coding",
modelID: "claude-opus-4-6", modelID: "k2p5",
}) })
expect(first.message["variant"]).toBe("max") expect(first.message["variant"]).toBeUndefined()
//#when - second retry cycle //#when - second retry cycle
const second = await triggerRetryCycle() const second = await triggerRetryCycle()
//#then - second fallback entry applied (chain advanced) //#then - second fallback entry applied (chain advanced past k2p5)
expect(second.message["model"]).toEqual({ expect(second.message["model"]).toMatchObject({
providerID: "kimi-for-coding", modelID: "kimi-k2.5",
modelID: "k2p5",
}) })
expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy()
expect(second.message["variant"]).toBeUndefined() expect(second.message["variant"]).toBeUndefined()
expect(abortCalls).toEqual([sessionID, sessionID]) expect(abortCalls).toEqual([sessionID, sessionID])
expect(promptCalls).toEqual([sessionID, sessionID]) expect(promptCalls).toEqual([sessionID, sessionID])
+61 -28
View File
@@ -13,11 +13,16 @@ import {
import { import {
clearPendingModelFallback, clearPendingModelFallback,
clearSessionFallbackChain, clearSessionFallbackChain,
setSessionFallbackChain,
setPendingModelFallback, setPendingModelFallback,
} from "../hooks/model-fallback/hook"; } from "../hooks/model-fallback/hook";
import { getFallbackModelsForSession } from "../hooks/runtime-fallback/fallback-models";
import { resetMessageCursor } from "../shared"; import { resetMessageCursor } from "../shared";
import { getAgentConfigKey } from "../shared/agent-display-names";
import { log } from "../shared/logger"; import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier"; import { shouldRetryError } from "../shared/model-error-classifier";
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"; import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store"; import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools"; import { lspManager } from "../tools";
@@ -97,6 +102,22 @@ function extractProviderModelFromErrorMessage(message: string): { providerID?: s
return {}; return {};
} }
function applyUserConfiguredFallbackChain(
sessionID: string,
agentName: string,
currentProviderID: string,
pluginConfig: OhMyOpenCodeConfig,
): void {
const agentKey = getAgentConfigKey(agentName);
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
if (configuredFallbackModels.length === 0) return;
const fallbackChain = buildFallbackChainFromModels(configuredFallbackModels, currentProviderID);
if (fallbackChain && fallbackChain.length > 0) {
setSessionFallbackChain(sessionID, fallbackChain);
}
}
function isCompactionAgent(agent: string): boolean { function isCompactionAgent(agent: string): boolean {
return agent.toLowerCase() === "compaction"; return agent.toLowerCase() === "compaction";
@@ -116,6 +137,11 @@ export function createEventHandler(args: {
client: { client: {
session: { session: {
abort: (input: { path: { id: string } }) => Promise<unknown>; abort: (input: { path: { id: string } }) => Promise<unknown>;
promptAsync?: (input: {
path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
prompt: (input: { prompt: (input: {
path: { id: string }; path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> }; body: { parts: Array<{ type: "text"; text: string }> };
@@ -176,6 +202,29 @@ export function createEventHandler(args: {
return !subagentSessions.has(sessionID); return !subagentSessions.has(sessionID);
}; };
const autoContinueAfterFallback = async (sessionID: string, source: string): Promise<void> => {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => {
log("[event] model-fallback abort failed", { sessionID, source, error });
});
const promptBody = {
path: { id: sessionID },
body: { parts: [{ type: "text" as const, text: "continue" }] },
query: { directory: pluginContext.directory },
};
if (typeof pluginContext.client.session.promptAsync === "function") {
await pluginContext.client.session.promptAsync(promptBody).catch((error) => {
log("[event] model-fallback promptAsync failed", { sessionID, source, error });
});
return;
}
await pluginContext.client.session.prompt(promptBody).catch((error) => {
log("[event] model-fallback prompt failed", { sessionID, source, error });
});
};
return async (input): Promise<void> => { return async (input): Promise<void> => {
pruneRecentSyntheticIdles({ pruneRecentSyntheticIdles({
recentSyntheticIdles, recentSyntheticIdles,
@@ -310,6 +359,7 @@ export function createEventHandler(args: {
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"; const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"; const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
const currentModel = normalizeFallbackModelID(rawModel); const currentModel = normalizeFallbackModelID(rawModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel); const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
@@ -319,15 +369,7 @@ export function createEventHandler(args: {
!hooks.stopContinuationGuard?.isStopped(sessionID) !hooks.stopContinuationGuard?.isStopped(sessionID)
) { ) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
await autoContinueAfterFallback(sessionID, "message.updated");
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
} }
} }
} }
@@ -342,10 +384,14 @@ export function createEventHandler(args: {
const sessionID = props?.sessionID as string | undefined; const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) { if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
try { try {
const retryMessage = typeof status.message === "string" ? status.message : ""; const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`; const parsedForKey = extractProviderModelFromErrorMessage(retryMessage);
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage);
// Deduplicate countdown updates for the same retry attempt/model.
// Messages like "retrying in 7m 56s" change every second but should only trigger once.
const retryKey = `${retryAttempt}:${parsedForKey.providerID ?? ""}/${parsedForKey.modelID ?? ""}:${normalizeRetryStatusMessage(retryMessage)}`;
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) { if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return; return;
} }
@@ -370,6 +416,7 @@ export function createEventHandler(args: {
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"; const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"; let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel); currentModel = normalizeFallbackModelID(currentModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel); const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
@@ -378,14 +425,7 @@ export function createEventHandler(args: {
shouldAutoRetrySession(sessionID) && shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID) !hooks.stopContinuationGuard?.isStopped(sessionID)
) { ) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {}); await autoContinueAfterFallback(sessionID, "session.status");
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
} }
} }
} }
@@ -448,6 +488,7 @@ export function createEventHandler(args: {
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode"; const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6"; let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel); currentModel = normalizeFallbackModelID(currentModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel); const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
@@ -456,15 +497,7 @@ export function createEventHandler(args: {
shouldAutoRetrySession(sessionID) && shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID) !hooks.stopContinuationGuard?.isStopped(sessionID)
) { ) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {}); await autoContinueAfterFallback(sessionID, "session.error");
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
} }
} }
} }
+7 -1
View File
@@ -48,7 +48,13 @@ export function createToolRegistry(args: {
const { ctx, pluginConfig, managers, skillContext, availableCategories } = args const { ctx, pluginConfig, managers, skillContext, availableCategories } = args
const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client) const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client)
const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager, pluginConfig.disabled_agents ?? []) const callOmoAgent = createCallOmoAgent(
ctx,
managers.backgroundManager,
pluginConfig.disabled_agents ?? [],
pluginConfig.agents,
pluginConfig.categories,
)
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
(agent) => agent.toLowerCase() === "multimodal-looker", (agent) => agent.toLowerCase() === "multimodal-looker",
@@ -0,0 +1,48 @@
import { describe, test, expect } from "bun:test"
import { buildFallbackChainFromModels, parseFallbackModelEntry } from "./fallback-chain-from-models"
describe("fallback-chain-from-models", () => {
test("parses provider/model entry with parenthesized variant", () => {
//#given
const fallbackModel = "openai/gpt-5.2(high)"
//#when
const parsed = parseFallbackModelEntry(fallbackModel, "quotio")
//#then
expect(parsed).toEqual({
providers: ["openai"],
model: "gpt-5.2",
variant: "high",
})
})
test("uses default provider when fallback model omits provider prefix", () => {
//#given
const fallbackModel = "glm-5"
//#when
const parsed = parseFallbackModelEntry(fallbackModel, "quotio")
//#then
expect(parsed).toEqual({
providers: ["quotio"],
model: "glm-5",
variant: undefined,
})
})
test("builds fallback chain from normalized fallback_models input", () => {
//#given
const fallbackModels = ["quotio/kimi-k2.5", "gpt-5.2 medium"]
//#when
const chain = buildFallbackChainFromModels(fallbackModels, "quotio")
//#then
expect(chain).toEqual([
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
{ providers: ["quotio"], model: "gpt-5.2", variant: "medium" },
])
})
})
+75
View File
@@ -0,0 +1,75 @@
import type { FallbackEntry } from "./model-requirements"
import { normalizeFallbackModels } from "./model-resolver"
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"none",
"auto",
"thinking",
])
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
const trimmedModel = rawModel.trim()
if (!trimmedModel) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModel }
}
export function parseFallbackModelEntry(
model: string,
defaultProviderID: string,
): FallbackEntry | undefined {
const trimmed = model.trim()
if (!trimmed) return undefined
const parts = trimmed.split("/")
const providerID = parts.length >= 2 ? parts[0].trim() : defaultProviderID
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
if (!providerID || !rawModelID) return undefined
const parsed = parseVariantFromModel(rawModelID)
if (!parsed.modelID) return undefined
return {
providers: [providerID],
model: parsed.modelID,
variant: parsed.variant,
}
}
export function buildFallbackChainFromModels(
fallbackModels: string | string[] | undefined,
defaultProviderID: string,
): FallbackEntry[] | undefined {
const normalized = normalizeFallbackModels(fallbackModels)
if (!normalized || normalized.length === 0) return undefined
const parsed = normalized
.map((model) => parseFallbackModelEntry(model, defaultProviderID))
.filter((entry): entry is FallbackEntry => entry !== undefined)
if (parsed.length === 0) return undefined
return parsed
}
+28
View File
@@ -36,6 +36,20 @@ describe("model-error-classifier", () => {
expect(result).toBe(true) expect(result).toBe(true)
}) })
test("treats cooling-down auto-retry messages as retryable", () => {
//#given
const error = {
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("selectFallbackProvider prefers first connected provider in preference order", () => { test("selectFallbackProvider prefers first connected provider in preference order", () => {
//#given //#given
writeFileSync( writeFileSync(
@@ -73,4 +87,18 @@ describe("model-error-classifier", () => {
//#then //#then
expect(provider).toBe("anthropic") expect(provider).toBe("anthropic")
}) })
test("selectFallbackProvider uses connected preferred provider when fallback providers are unavailable", () => {
//#given
writeFileSync(
join(TEST_CACHE_DIR, "connected-providers.json"),
JSON.stringify({ connected: ["provider-x"], updatedAt: new Date().toISOString() }, null, 2),
)
//#when
const provider = selectFallbackProvider(["provider-y"], "provider-x")
//#then
expect(provider).toBe("provider-x")
})
}) })
+35 -1
View File
@@ -36,6 +36,11 @@ const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit", "rate_limit",
"rate limit", "rate limit",
"quota", "quota",
"quota will reset after",
"usage limit has been reached",
"all credentials for model",
"cooling down",
"exhausted your capacity",
"not found", "not found",
"unavailable", "unavailable",
"insufficient", "insufficient",
@@ -55,6 +60,23 @@ const RETRYABLE_MESSAGE_PATTERNS = [
"504", "504",
] ]
const AUTO_RETRY_GATE_PATTERNS = [
"rate limit",
"quota",
"usage limit",
"limit reached",
"cooling down",
"credentials for model",
"exhausted your capacity",
]
function hasProviderAutoRetrySignal(message: string): boolean {
if (!message.includes("retrying in")) {
return false
}
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
}
export interface ErrorInfo { export interface ErrorInfo {
name?: string name?: string
message?: string message?: string
@@ -79,6 +101,9 @@ export function isRetryableModelError(error: ErrorInfo): boolean {
// Check message patterns for unknown errors // Check message patterns for unknown errors
const msg = error.message?.toLowerCase() ?? "" const msg = error.message?.toLowerCase() ?? ""
if (hasProviderAutoRetrySignal(msg)) {
return true
}
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern)) return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
} }
@@ -115,7 +140,8 @@ export function hasMoreFallbacks(
* Selects the best provider for a fallback entry. * Selects the best provider for a fallback entry.
* Priority: * Priority:
* 1) First connected provider in the entry's provider preference order * 1) First connected provider in the entry's provider preference order
* 2) First provider listed in the fallback entry (when cache is missing) * 2) Preferred provider when connected (and entry providers are unavailable)
* 3) First provider listed in the fallback entry
*/ */
export function selectFallbackProvider( export function selectFallbackProvider(
providers: string[], providers: string[],
@@ -124,11 +150,19 @@ export function selectFallbackProvider(
const connectedProviders = readConnectedProvidersCache() const connectedProviders = readConnectedProvidersCache()
if (connectedProviders) { if (connectedProviders) {
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase())) const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
for (const provider of providers) { for (const provider of providers) {
if (connectedSet.has(provider.toLowerCase())) { if (connectedSet.has(provider.toLowerCase())) {
return provider return provider
} }
} }
if (
preferredProviderID &&
connectedSet.has(preferredProviderID.toLowerCase())
) {
return preferredProviderID
}
} }
return providers[0] || preferredProviderID || "opencode" return providers[0] || preferredProviderID || "opencode"
+19
View File
@@ -0,0 +1,19 @@
export function normalizeRetryStatusMessage(message: string): string {
return message
.replace(/\[retrying in [^\]]*attempt\s*#\d+\]/gi, "[retrying]")
.replace(/retrying in\s+[^(]*attempt\s*#\d+/gi, "retrying")
.replace(/\s+/g, " ")
.trim()
.toLowerCase()
}
export function extractRetryAttempt(statusAttempt: unknown, message: string): string {
if (typeof statusAttempt === "number" && Number.isFinite(statusAttempt)) {
return String(statusAttempt)
}
const attemptMatch = message.match(/attempt\s*#\s*(\d+)/i)
if (attemptMatch?.[1]) {
return attemptMatch[1]
}
return "?"
}
@@ -64,4 +64,26 @@ describe("executeBackground", () => {
expect(result).toContain("interrupt") expect(result).toContain("interrupt")
expect(result).toContain("test-task-id") expect(result).toContain("test-task-id")
}) })
test("passes fallbackChain to background manager launch", async () => {
//#given
const fallbackChain = [
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
]
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: "sub-session",
description: "Test task",
agent: "test-agent",
status: "pending",
})
//#when
await executeBackground(testArgs, testContext, mockManager, mockClient, fallbackChain)
//#then
const launchArgs = launchMock.mock.calls.at(-1)?.[0]
expect(launchArgs.fallbackChain).toEqual(fallbackChain)
})
}) })
@@ -2,6 +2,7 @@ import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared" import { log } from "../../shared"
import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveMessageContext } from "../../features/hook-message-injector" import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { getMessageDir } from "./message-dir" import { getMessageDir } from "./message-dir"
@@ -17,7 +18,8 @@ export async function executeBackground(
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}, },
manager: BackgroundManager, manager: BackgroundManager,
client: PluginInput["client"] client: PluginInput["client"],
fallbackChain?: FallbackEntry[],
): Promise<string> { ): Promise<string> {
try { try {
const messageDir = getMessageDir(toolContext.sessionID) const messageDir = getMessageDir(toolContext.sessionID)
@@ -48,6 +50,7 @@ export async function executeBackground(
parentMessageID: toolContext.messageID, parentMessageID: toolContext.messageID,
parentAgent, parentAgent,
parentTools: getSessionTools(toolContext.sessionID), parentTools: getSessionTools(toolContext.sessionID),
fallbackChain,
}) })
const WAIT_FOR_SESSION_INTERVAL_MS = 50 const WAIT_FOR_SESSION_INTERVAL_MS = 50
@@ -9,6 +9,7 @@ describe("executeSync", () => {
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
waitForCompletion: mock(async () => {}), waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"), processMessages: mock(async () => "agent response"),
setSessionFallbackChain: mock(() => {}),
} }
let promptArgs: any let promptArgs: any
@@ -53,6 +54,7 @@ describe("executeSync", () => {
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
waitForCompletion: mock(async () => {}), waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"), processMessages: mock(async () => "agent response"),
setSessionFallbackChain: mock(() => {}),
} }
let promptArgs: any let promptArgs: any
@@ -88,4 +90,48 @@ describe("executeSync", () => {
expect(promptAsync).toHaveBeenCalled() expect(promptAsync).toHaveBeenCalled()
expect(promptArgs.body.tools.task).toBe(false) expect(promptArgs.body.tools.task).toBe(false)
}) })
test("applies fallbackChain to sync sessions", async () => {
//#given
const { executeSync } = require("./sync-executor")
const setSessionFallbackChain = mock(() => {})
const deps = {
createOrGetSession: mock(async () => ({ sessionID: "ses-test-456", isNew: true })),
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"),
setSessionFallbackChain,
}
const args = {
subagent_type: "explore",
description: "test task",
prompt: "find something",
}
const toolContext = {
sessionID: "parent-session",
messageID: "msg-3",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: mock(async () => {}),
}
const ctx = {
client: {
session: { promptAsync: mock(async () => ({ data: {} })) },
},
}
const fallbackChain = [
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
]
//#when
await executeSync(args, toolContext, ctx as any, deps, fallbackChain)
//#then
expect(setSessionFallbackChain).toHaveBeenCalledWith("ses-test-456", fallbackChain)
})
}) })
+10 -1
View File
@@ -1,7 +1,9 @@
import type { CallOmoAgentArgs } from "./types" import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared" import { log } from "../../shared"
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentToolRestrictions } from "../../shared" import { getAgentToolRestrictions } from "../../shared"
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { createOrGetSession } from "./session-creator" import { createOrGetSession } from "./session-creator"
import { waitForCompletion } from "./completion-poller" import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor" import { processMessages } from "./message-processor"
@@ -14,12 +16,14 @@ type ExecuteSyncDeps = {
createOrGetSession: typeof createOrGetSession createOrGetSession: typeof createOrGetSession
waitForCompletion: typeof waitForCompletion waitForCompletion: typeof waitForCompletion
processMessages: typeof processMessages processMessages: typeof processMessages
setSessionFallbackChain: typeof setSessionFallbackChain
} }
const defaultDeps: ExecuteSyncDeps = { const defaultDeps: ExecuteSyncDeps = {
createOrGetSession, createOrGetSession,
waitForCompletion, waitForCompletion,
processMessages, processMessages,
setSessionFallbackChain,
} }
export async function executeSync( export async function executeSync(
@@ -32,10 +36,15 @@ export async function executeSync(
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}, },
ctx: PluginInput, ctx: PluginInput,
deps: ExecuteSyncDeps = defaultDeps deps: ExecuteSyncDeps = defaultDeps,
fallbackChain?: FallbackEntry[],
): Promise<string> { ): Promise<string> {
const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx) const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx)
if (fallbackChain && fallbackChain.length > 0) {
deps.setSessionFallbackChain(sessionID, fallbackChain)
}
await toolContext.metadata?.({ await toolContext.metadata?.({
title: args.description, title: args.description,
metadata: { sessionId: sessionID }, metadata: { sessionId: sessionID },
+44
View File
@@ -99,4 +99,48 @@ describe("createCallOmoAgent", () => {
//#then //#then
expect(result).not.toContain("disabled via disabled_agents") expect(result).not.toContain("disabled via disabled_agents")
}) })
test("uses agent override fallback_models when launching background subagent", async () => {
//#given
const launch = mock(() => Promise.resolve({
id: "task-fallback",
sessionID: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
}))
const managerWithLaunch = {
launch,
getTask: mock(() => undefined),
} as unknown as BackgroundManager
const toolDef = createCallOmoAgent(
mockCtx,
managerWithLaunch,
[],
{
explore: {
fallback_models: ["quotio/kimi-k2.5", "openai/gpt-5.2(high)"],
},
},
)
const executeFunc = toolDef.execute as Function
//#when
await executeFunc(
{
description: "Test fallback",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
const launchArgs = launch.mock.calls[0]?.[0]
expect(launchArgs.fallbackChain).toEqual([
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
])
})
}) })
+41 -3
View File
@@ -2,14 +2,46 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types" import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
import type { FallbackEntry } from "../../shared/model-requirements"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { log } from "../../shared" import { log } from "../../shared"
import { executeBackground } from "./background-executor" import { executeBackground } from "./background-executor"
import { executeSync } from "./sync-executor" import { executeSync } from "./sync-executor"
function resolveFallbackChainForCallOmoAgent(args: {
subagentType: string
agentOverrides?: AgentOverrides
userCategories?: CategoriesConfig
}): FallbackEntry[] | undefined {
const { subagentType, agentOverrides, userCategories } = args
const agentConfigKey = getAgentConfigKey(subagentType)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
const agentOverride = agentOverrides?.[agentConfigKey as keyof AgentOverrides]
?? (agentOverrides
? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1]
: undefined)
const normalizedFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
)
const defaultProviderID = agentRequirement?.fallbackChain?.[0]?.providers?.[0] ?? "opencode"
const configuredFallbackChain = buildFallbackChainFromModels(normalizedFallbackModels, defaultProviderID)
return configuredFallbackChain ?? agentRequirement?.fallbackChain
}
export function createCallOmoAgent( export function createCallOmoAgent(
ctx: PluginInput, ctx: PluginInput,
backgroundManager: BackgroundManager, backgroundManager: BackgroundManager,
disabledAgents: string[] = [] disabledAgents: string[] = [],
agentOverrides?: AgentOverrides,
userCategories?: CategoriesConfig,
): ToolDefinition { ): ToolDefinition {
const agentDescriptions = ALLOWED_AGENTS.map( const agentDescriptions = ALLOWED_AGENTS.map(
(name) => `- ${name}: Specialized agent for ${name} tasks` (name) => `- ${name}: Specialized agent for ${name} tasks`
@@ -50,14 +82,20 @@ export function createCallOmoAgent(
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.` return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.`
} }
const fallbackChain = resolveFallbackChainForCallOmoAgent({
subagentType: args.subagent_type,
agentOverrides,
userCategories,
})
if (args.run_in_background) { if (args.run_in_background) {
if (args.session_id) { if (args.session_id) {
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.` return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
} }
return await executeBackground(args, toolCtx, backgroundManager, ctx.client) return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain)
} }
return await executeSync(args, toolCtx, ctx) return await executeSync(args, toolCtx, ctx, undefined, fallbackChain)
}, },
}) })
} }
@@ -75,4 +75,34 @@ describe("resolveCategoryExecution", () => {
expect(result.error).toContain("Unknown category") expect(result.error).toContain("Unknown category")
expect(result.error).toContain("definitely-not-a-real-category-xyz123") expect(result.error).toContain("definitely-not-a-real-category-xyz123")
}) })
test("uses category fallback_models for background/runtime fallback chain", async () => {
//#given
const args = {
category: "deep",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
model: "quotio/claude-opus-4-6",
fallback_models: ["quotio/kimi-k2.5", "openai/gpt-5.2(high)"],
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.fallbackChain).toEqual([
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
])
})
}) })
+13 -1
View File
@@ -7,6 +7,8 @@ import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories" import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser" import { parseModelString } from "./model-string-parser"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { getAvailableModelsForDelegateTask } from "./available-models" import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection" import { resolveModelForDelegateTask } from "./model-selection"
@@ -79,6 +81,7 @@ Available categories: ${allCategoryNames}`,
} }
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!] const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!]
const normalizedConfiguredFallbackModels = normalizeFallbackModels(resolved.config.fallback_models)
let actualModel: string | undefined let actualModel: string | undefined
let modelInfo: ModelFallbackInfo | undefined let modelInfo: ModelFallbackInfo | undefined
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
@@ -99,6 +102,7 @@ Available categories: ${allCategoryNames}`,
} else { } else {
const resolution = resolveModelForDelegateTask({ const resolution = resolveModelForDelegateTask({
userModel: explicitCategoryModel ?? overrideModel, userModel: explicitCategoryModel ?? overrideModel,
userFallbackModels: normalizedConfiguredFallbackModels,
categoryDefaultModel: resolved.model, categoryDefaultModel: resolved.model,
fallbackChain: requirement.fallbackChain, fallbackChain: requirement.fallbackChain,
availableModels, availableModels,
@@ -178,6 +182,14 @@ Available categories: ${categoryNames.join(", ")}`,
const categoryConfigModel = resolved.config.model?.toLowerCase() const categoryConfigModel = resolved.config.model?.toLowerCase()
const isUnstableAgent = resolved.config.is_unstable_agent === true || [unstableModel, categoryConfigModel].some(m => m ? m.includes("gemini") || m.includes("minimax") || m.includes("kimi") : false) const isUnstableAgent = resolved.config.is_unstable_agent === true || [unstableModel, categoryConfigModel].some(m => m ? m.includes("gemini") || m.includes("minimax") || m.includes("kimi") : false)
const defaultProviderID = categoryModel?.providerID
?? parseModelString(actualModel ?? "")?.providerID
?? "opencode"
const configuredFallbackChain = buildFallbackChainFromModels(
normalizedConfiguredFallbackModels,
defaultProviderID,
)
return { return {
agentToUse: SISYPHUS_JUNIOR_AGENT, agentToUse: SISYPHUS_JUNIOR_AGENT,
categoryModel, categoryModel,
@@ -186,6 +198,6 @@ Available categories: ${categoryNames.join(", ")}`,
modelInfo, modelInfo,
actualModel, actualModel,
isUnstableAgent, isUnstableAgent,
fallbackChain: requirement?.fallbackChain, fallbackChain: configuredFallbackChain ?? requirement?.fallbackChain,
} }
} }
@@ -14,6 +14,7 @@ function getExplicitHighBaseModel(model: string): string | null {
export function resolveModelForDelegateTask(input: { export function resolveModelForDelegateTask(input: {
userModel?: string userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string categoryDefaultModel?: string
fallbackChain?: FallbackEntry[] fallbackChain?: FallbackEntry[]
availableModels: Set<string> availableModels: Set<string>
@@ -44,6 +45,28 @@ export function resolveModelForDelegateTask(input: {
} }
} }
const userFallbackModels = input.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (input.availableModels.size === 0) {
const first = normalizeModel(userFallbackModels[0])
if (first) {
return { model: first }
}
} else {
for (const fallbackModel of userFallbackModels) {
const normalizedFallback = normalizeModel(fallbackModel)
if (!normalizedFallback) continue
const parts = normalizedFallback.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(normalizedFallback, input.availableModels, providerHint)
if (match) {
return { model: match }
}
}
}
}
const fallbackChain = input.fallbackChain const fallbackChain = input.fallbackChain
if (fallbackChain && fallbackChain.length > 0) { if (fallbackChain && fallbackChain.length > 0) {
if (input.availableModels.size === 0) { if (input.availableModels.size === 0) {
@@ -17,7 +17,10 @@ function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs
} }
} }
function createExecutorContext(agentsFn: () => Promise<unknown>): ExecutorContext { function createExecutorContext(
agentsFn: () => Promise<unknown>,
overrides?: Partial<ExecutorContext>,
): ExecutorContext {
const client = { const client = {
app: { app: {
agents: agentsFn, agents: agentsFn,
@@ -28,6 +31,7 @@ function createExecutorContext(agentsFn: () => Promise<unknown>): ExecutorContex
client, client,
manager: {} as ExecutorContext["manager"], manager: {} as ExecutorContext["manager"],
directory: "/tmp/test", directory: "/tmp/test",
...overrides,
} }
} }
@@ -101,4 +105,74 @@ describe("resolveSubagentExecution", () => {
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" }) expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
cacheSpy.mockRestore() cacheSpy.mockRestore()
}) })
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { quotio: ["claude-haiku-4-5"] },
connected: ["quotio"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5" },
]),
{
agentOverrides: {
explore: {
fallback_models: ["quotio/gpt-5.2", "glm-5(max)"],
},
} as ExecutorContext["agentOverrides"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.fallbackChain).toEqual([
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
{ providers: ["quotio"], model: "glm-5", variant: "max" },
])
cacheSpy.mockRestore()
})
test("uses category fallback_models when agent override points at category", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { anthropic: ["claude-haiku-4-5"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5" },
]),
{
agentOverrides: {
explore: {
category: "research",
},
} as ExecutorContext["agentOverrides"],
userCategories: {
research: {
fallback_models: ["anthropic/claude-haiku-4-5"],
},
} as ExecutorContext["userCategories"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.fallbackChain).toEqual([
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
])
cacheSpy.mockRestore()
})
}) })
+17 -2
View File
@@ -4,6 +4,8 @@ import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { normalizeModelFormat } from "../../shared/model-format-normalizer" import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names" import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
import { normalizeSDKResponse } from "../../shared" import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
@@ -17,7 +19,7 @@ export async function resolveSubagentExecution(
parentAgent: string | undefined, parentAgent: string | undefined,
categoryExamples: string categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> { ): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
const { client, agentOverrides } = executorCtx const { client, agentOverrides, userCategories } = executorCtx
if (!args.subagent_type?.trim()) { if (!args.subagent_type?.trim()) {
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` } return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
@@ -98,7 +100,10 @@ Create the work plan directly - that's your job as the planning agent.`,
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides] const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined) ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey] const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
fallbackChain = agentRequirement?.fallbackChain const normalizedAgentFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
)
if (agentOverride?.model || agentRequirement || matchedAgent.model) { if (agentOverride?.model || agentRequirement || matchedAgent.model) {
const availableModels = await getAvailableModelsForDelegateTask(client) const availableModels = await getAvailableModelsForDelegateTask(client)
@@ -112,6 +117,7 @@ Create the work plan directly - that's your job as the planning agent.`,
const resolution = resolveModelForDelegateTask({ const resolution = resolveModelForDelegateTask({
userModel: agentOverride?.model, userModel: agentOverride?.model,
userFallbackModels: normalizedAgentFallbackModels,
categoryDefaultModel: matchedAgentModelStr, categoryDefaultModel: matchedAgentModelStr,
fallbackChain: agentRequirement?.fallbackChain, fallbackChain: agentRequirement?.fallbackChain,
availableModels, availableModels,
@@ -125,6 +131,15 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
} }
} }
const defaultProviderID = categoryModel?.providerID
?? normalizedMatchedModel?.providerID
?? "opencode"
const configuredFallbackChain = buildFallbackChainFromModels(
normalizedAgentFallbackModels,
defaultProviderID,
)
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
} }
if (!categoryModel && matchedAgent.model) { if (!categoryModel && matchedAgent.model) {