fix: enable runtime fallback for delegated child sessions (#2357)

This commit is contained in:
YeonGyu-Kim
2026-03-12 01:43:17 +09:00
parent 4ded45d14c
commit ba86ef0eea
14 changed files with 630 additions and 211 deletions
+34 -38
View File
@@ -1,4 +1,11 @@
<<<<<<< HEAD
import type { HookDeps, RuntimeFallbackTimeout } from "./types" import type { HookDeps, RuntimeFallbackTimeout } from "./types"
||||||| parent of b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357))
import type { HookDeps } from "./types"
=======
import type { HookDeps } from "./types"
import type { TimerHandle } from "./timer-handle"
>>>>>>> b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357))
import { HOOK_NAME } from "./constants" import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver" import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver"
@@ -6,11 +13,21 @@ import { getSessionAgent } from "../../features/claude-code-session-state"
import { getFallbackModelsForSession } from "./fallback-models" import { getFallbackModelsForSession } from "./fallback-models"
import { prepareFallback } from "./fallback-state" import { prepareFallback } from "./fallback-state"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { buildRetryModelPayload } from "./retry-model-payload"
import { getLastUserRetryParts } from "./last-user-retry-parts"
const SESSION_TTL_MS = 30 * 60 * 1000 const SESSION_TTL_MS = 30 * 60 * 1000
<<<<<<< HEAD
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
||||||| parent of b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357))
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): ReturnType<typeof globalThis.setTimeout>
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
=======
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): TimerHandle
declare function clearTimeout(timeout: TimerHandle): void
>>>>>>> b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357))
export function createAutoRetryHelpers(deps: HookDeps) { export function createAutoRetryHelpers(deps: HookDeps) {
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
@@ -87,8 +104,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
return return
} }
const modelParts = newModel.split("/") const retryModelPayload = buildRetryModelPayload(newModel)
if (modelParts.length < 2) { if (!retryModelPayload) {
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`) log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
const state = sessionStates.get(sessionID) const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) { if (state?.pendingFallbackModel) {
@@ -97,11 +114,6 @@ export function createAutoRetryHelpers(deps: HookDeps) {
return return
} }
const fallbackModelObj = {
providerID: modelParts[0],
modelID: modelParts.slice(1).join("/"),
}
sessionRetryInFlight.add(sessionID) sessionRetryInFlight.add(sessionID)
let retryDispatched = false let retryDispatched = false
try { try {
@@ -109,43 +121,27 @@ export function createAutoRetryHelpers(deps: HookDeps) {
path: { id: sessionID }, path: { id: sessionID },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
const msgs = (messagesResp as { const retryParts = getLastUserRetryParts(messagesResp)
data?: Array<{ if (retryParts.length > 0) {
info?: Record<string, unknown>
parts?: Array<{ type?: string; text?: string }>
}>
}).data
const lastUserMsg = msgs?.filter((m) => m.info?.role === "user").pop()
const lastUserPartsRaw =
lastUserMsg?.parts ??
(lastUserMsg?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
if (lastUserPartsRaw && lastUserPartsRaw.length > 0) {
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, { log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID, sessionID,
model: newModel, model: newModel,
}) })
const retryParts = lastUserPartsRaw const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
.filter((p) => p.type === "text" && typeof p.text === "string" && p.text.length > 0) sessionAwaitingFallbackResult.add(sessionID)
.map((p) => ({ type: "text" as const, text: p.text! })) scheduleSessionFallbackTimeout(sessionID, retryAgent)
if (retryParts.length > 0) { await ctx.client.session.promptAsync({
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID) path: { id: sessionID },
sessionAwaitingFallbackResult.add(sessionID) body: {
scheduleSessionFallbackTimeout(sessionID, retryAgent) ...(retryAgent ? { agent: retryAgent } : {}),
...retryModelPayload,
await ctx.client.session.promptAsync({ parts: retryParts,
path: { id: sessionID }, },
body: { query: { directory: ctx.directory },
...(retryAgent ? { agent: retryAgent } : {}), })
model: fallbackModelObj, retryDispatched = true
parts: retryParts,
},
query: { directory: ctx.directory },
})
retryDispatched = true
}
} else { } else {
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID }) log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
} }
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test" declare const require: (name: string) => any
const { describe, expect, test } = require("bun:test")
import { extractAutoRetrySignal, isRetryableError } from "./error-classifier" import { classifyErrorType, extractAutoRetrySignal, isRetryableError } from "./error-classifier"
describe("runtime-fallback error classifier", () => { describe("runtime-fallback error classifier", () => {
test("detects cooling-down auto-retry status signals", () => { test("detects cooling-down auto-retry status signals", () => {
@@ -45,6 +46,45 @@ describe("runtime-fallback error classifier", () => {
expect(retryable).toBe(true) expect(retryable).toBe(true)
}) })
test("classifies ProviderModelNotFoundError as model_not_found", () => {
//#given
const error = {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-opus-4-6",
message: "Model not found: anthropic/claude-opus-4-6.",
},
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 503, 529])
//#then
expect(errorType).toBe("model_not_found")
expect(retryable).toBe(true)
})
test("classifies nested AI_LoadAPIKeyError as missing_api_key", () => {
//#given
const error = {
data: {
name: "AI_LoadAPIKeyError",
message:
"Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
},
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 503, 529])
//#then
expect(errorType).toBe("missing_api_key")
expect(retryable).toBe(true)
})
test("ignores non-retry assistant status text", () => { test("ignores non-retry assistant status text", () => {
//#given //#given
const info = { const info = {
+11 -1
View File
@@ -58,6 +58,11 @@ export function extractErrorName(error: unknown): string | undefined {
return directName return directName
} }
const dataName = (errorObj.data as Record<string, unknown> | undefined)?.name
if (typeof dataName === "string" && dataName.length > 0) {
return dataName
}
const nestedError = errorObj.error as Record<string, unknown> | undefined const nestedError = errorObj.error as Record<string, unknown> | undefined
const nestedName = nestedError?.name const nestedName = nestedError?.name
if (typeof nestedName === "string" && nestedName.length > 0) { if (typeof nestedName === "string" && nestedName.length > 0) {
@@ -78,6 +83,7 @@ export function classifyErrorType(error: unknown): string | undefined {
const errorName = extractErrorName(error)?.toLowerCase() const errorName = extractErrorName(error)?.toLowerCase()
if ( if (
errorName?.includes("ai_loadapikeyerror") ||
errorName?.includes("loadapi") || errorName?.includes("loadapi") ||
(/api.?key.?is.?missing/i.test(message) && /environment variable/i.test(message)) (/api.?key.?is.?missing/i.test(message) && /environment variable/i.test(message))
) { ) {
@@ -88,7 +94,11 @@ export function classifyErrorType(error: unknown): string | undefined {
return "invalid_api_key" return "invalid_api_key"
} }
if (errorName?.includes("unknownerror") && /model\s+not\s+found/i.test(message)) { if (
errorName?.includes("providermodelnotfounderror") ||
errorName?.includes("modelnotfounderror") ||
(errorName?.includes("unknownerror") && /model\s+not\s+found/i.test(message))
) {
return "model_not_found" return "model_not_found"
} }
+26 -126
View File
@@ -2,15 +2,18 @@ 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, extractAutoRetrySignal } from "./error-classifier" import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state" import { createFallbackState } 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" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
import { createSessionStatusHandler } from "./session-status-handler"
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 sessionStatusRetryKeys = new Map<string, string>()
const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys)
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
@@ -136,135 +139,32 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
} }
if (!state) { if (!state) {
const currentModel = props?.model as string | undefined const initialModel = resolveFallbackBootstrapModel({
if (currentModel) { sessionID,
state = createFallbackState(currentModel) source: "session.error",
sessionStates.set(sessionID, state) eventModel: props?.model as string | undefined,
sessionLastAccess.set(sessionID, Date.now()) resolvedAgent,
} else { pluginConfig,
const detectedAgent = resolvedAgent })
const agentConfig = detectedAgent if (!initialModel) {
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents] log(`[${HOOK_NAME}] No model info available, cannot fallback`, { sessionID })
: undefined return
const agentModel = agentConfig?.model as string | undefined
if (agentModel) {
log(`[${HOOK_NAME}] Derived model from agent config`, { sessionID, agent: detectedAgent, model: agentModel })
state = createFallbackState(agentModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else {
log(`[${HOOK_NAME}] No model info available, cannot fallback`, { sessionID })
return
}
} }
state = createFallbackState(initialModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else { } else {
sessionLastAccess.set(sessionID, Date.now()) sessionLastAccess.set(sessionID, Date.now())
} }
const result = prepareFallback(sessionID, state, fallbackModels, config) await dispatchFallbackRetry(deps, helpers, {
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.error")
}
if (!result.success) {
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
}
}
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, sessionID,
model: state.currentModel, state,
retryAttempt: status.attempt, fallbackModels,
resolvedAgent,
source: "session.error",
}) })
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 } }) => {
@@ -276,7 +176,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.status") { await sessionStatusHandler(props); return }
if (event.type === "session.error") { await handleSessionError(props); return } if (event.type === "session.error") { await handleSessionError(props); return }
} }
} }
@@ -0,0 +1,49 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
type ResolveFallbackBootstrapModelOptions = {
sessionID: string
source: string
eventModel?: string
resolvedAgent?: string
pluginConfig?: OhMyOpenCodeConfig
}
export function resolveFallbackBootstrapModel(
options: ResolveFallbackBootstrapModelOptions,
): string | undefined {
if (options.eventModel) {
return options.eventModel
}
const agentConfigs = options.pluginConfig?.agents
const agentConfig = options.resolvedAgent && agentConfigs
? agentConfigs[options.resolvedAgent as keyof typeof agentConfigs]
: undefined
const agentModel = typeof agentConfig?.model === "string" ? agentConfig.model : undefined
if (agentModel) {
log(`[${HOOK_NAME}] Derived model from agent config for ${options.source}`, {
sessionID: options.sessionID,
agent: options.resolvedAgent,
model: agentModel,
})
return agentModel
}
const sessionCategory = SessionCategoryRegistry.get(options.sessionID)
const categoryModel = sessionCategory
? options.pluginConfig?.categories?.[sessionCategory]?.model
: undefined
if (typeof categoryModel === "string" && categoryModel.length > 0) {
log(`[${HOOK_NAME}] Derived model from session category config for ${options.source}`, {
sessionID: options.sessionID,
category: sessionCategory,
model: categoryModel,
})
return categoryModel
}
return undefined
}
@@ -0,0 +1,55 @@
import type { AutoRetryHelpers } from "./auto-retry"
import type { HookDeps, FallbackState } from "./types"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { prepareFallback } from "./fallback-state"
type DispatchFallbackRetryOptions = {
sessionID: string
state: FallbackState
fallbackModels: string[]
resolvedAgent?: string
source: string
}
export async function dispatchFallbackRetry(
deps: HookDeps,
helpers: AutoRetryHelpers,
options: DispatchFallbackRetryOptions,
): Promise<void> {
const result = prepareFallback(
options.sessionID,
options.state,
options.fallbackModels,
deps.config,
)
if (result.success && deps.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(
options.sessionID,
result.newModel,
options.resolvedAgent,
options.source,
)
return
}
log(`[${HOOK_NAME}] Fallback preparation failed`, {
sessionID: options.sessionID,
source: options.source,
error: result.error,
})
}
+184 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" declare const require: (name: string) => any
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
import { createRuntimeFallbackHook } from "./index" import { createRuntimeFallbackHook } from "./index"
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config" import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
@@ -72,6 +73,23 @@ describe("runtime-fallback", () => {
} }
} }
function createMockPluginConfigWithCategoryModel(
categoryName: string,
model: string,
fallbackModels: string[],
variant?: string,
): OhMyOpenCodeConfig {
return {
categories: {
[categoryName]: {
model,
fallback_models: fallbackModels,
...(variant ? { variant } : {}),
},
},
}
}
describe("session.error handling", () => { describe("session.error handling", () => {
test("should detect retryable error with status code 429", async () => { test("should detect retryable error with status code 429", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() }) const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
@@ -310,6 +328,114 @@ describe("runtime-fallback", () => {
expect(nonRetryLog).toBeUndefined() expect(nonRetryLog).toBeUndefined()
}) })
test("should continue fallback chain when ProviderModelNotFoundError occurs", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"anthropic/claude-opus-4.6",
"openai/gpt-5.4",
]),
})
const sessionID = "test-session-provider-model-not-found"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
},
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "AI_LoadAPIKeyError",
message:
"Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
},
},
},
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-opus-4.6",
message: "Model not found: anthropic/claude-opus-4.6.",
},
},
},
},
})
const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.6", to: "openai/gpt-5.4" })
})
test("should bootstrap session.error fallback from session category model and preserve variant", async () => {
const promptCalls: Array<Record<string, unknown>> = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }],
}),
promptAsync: async (args) => {
promptCalls.push(args as Record<string, unknown>)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryModel(
"quick",
"anthropic/claude-haiku-4-5",
["openai/gpt-5.4(high)"],
),
},
)
const sessionID = "test-session-category-bootstrap-session-error"
SessionCategoryRegistry.register(sessionID, "quick")
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: { statusCode: 429, message: "Rate limit exceeded" },
},
},
})
expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as {
model?: { providerID?: string; modelID?: string }
variant?: string
} | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high")
const bootstrapLog = logCalls.find((call) =>
call.msg.includes("Derived model from session category config for session.error"),
)
expect(bootstrapLog?.data).toMatchObject({
sessionID,
category: "quick",
model: "anthropic/claude-haiku-4-5",
})
})
test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => { test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), { const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }), config: createMockConfig({ notify_on_fallback: false }),
@@ -905,6 +1031,63 @@ describe("runtime-fallback", () => {
expect(fallbackLog?.data).toMatchObject({ from: "google/gemini-2.5-pro", to: "openai/gpt-5.4" }) expect(fallbackLog?.data).toMatchObject({ from: "google/gemini-2.5-pro", to: "openai/gpt-5.4" })
}) })
test("should bootstrap message.updated fallback from session category model and preserve variant", async () => {
const promptCalls: Array<Record<string, unknown>> = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }],
}),
promptAsync: async (args) => {
promptCalls.push(args as Record<string, unknown>)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryModel(
"quick",
"anthropic/claude-haiku-4-5",
["openai/gpt-5.4(high)"],
),
},
)
const sessionID = "test-session-category-bootstrap-message-updated"
SessionCategoryRegistry.register(sessionID, "quick")
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
error: { statusCode: 429, message: "Rate limit exceeded" },
},
},
},
})
expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as {
model?: { providerID?: string; modelID?: string }
variant?: string
} | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high")
const bootstrapLog = logCalls.find((call) =>
call.msg.includes("Derived model from session category config for message.updated"),
)
expect(bootstrapLog?.data).toMatchObject({
sessionID,
category: "quick",
model: "anthropic/claude-haiku-4-5",
})
})
test("should not advance fallback state from message.updated while retry is already in flight", async () => { test("should not advance fallback state from message.updated while retry is already in flight", async () => {
const pending = new Promise<never>(() => {}) const pending = new Promise<never>(() => {})
@@ -0,0 +1,25 @@
type SessionMessagesResponse = {
data?: Array<{
info?: Record<string, unknown>
parts?: Array<{ type?: string; text?: string }>
}>
}
export function getLastUserRetryParts(
messagesResponse: unknown,
): Array<{ type: "text"; text: string }> {
const messages = (messagesResponse as SessionMessagesResponse).data
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
const lastUserParts =
lastUserMessage?.parts
?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
return (lastUserParts ?? [])
.filter(
(part): part is { type: "text"; text: string } =>
part.type === "text"
&& typeof part.text === "string"
&& part.text.length > 0,
)
.map((part) => ({ type: "text" as const, text: part.text }))
}
@@ -3,8 +3,10 @@ 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, extractAutoRetrySignal, containsErrorContent } from "./error-classifier" import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal, containsErrorContent } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state" import { createFallbackState } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models" import { getFallbackModelsForSession } from "./fallback-models"
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof extractAutoRetrySignal) { export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof extractAutoRetrySignal) {
return async ( return async (
@@ -154,22 +156,13 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
} }
if (!state) { if (!state) {
let initialModel = model const initialModel = resolveFallbackBootstrapModel({
if (!initialModel) { sessionID,
const detectedAgent = resolvedAgent source: "message.updated",
const agentConfig = detectedAgent eventModel: model,
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents] resolvedAgent,
: undefined pluginConfig,
const agentModel = agentConfig?.model as string | undefined })
if (agentModel) {
log(`[${HOOK_NAME}] Derived model from agent config for message.updated`, {
sessionID,
agent: detectedAgent,
model: agentModel,
})
initialModel = agentModel
}
}
if (!initialModel) { if (!initialModel) {
log(`[${HOOK_NAME}] message.updated missing model info, cannot fallback`, { log(`[${HOOK_NAME}] message.updated missing model info, cannot fallback`, {
@@ -203,24 +196,13 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
} }
} }
const result = prepareFallback(sessionID, state, fallbackModels, config) await dispatchFallbackRetry(deps, helpers, {
sessionID,
if (result.success && config.notify_on_fallback) { state,
await deps.ctx.client.tui fallbackModels,
.showToast({ resolvedAgent,
body: { source: "message.updated",
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, "message.updated")
}
} }
} }
} }
@@ -0,0 +1,25 @@
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
export function buildRetryModelPayload(
model: string,
): { model: { providerID: string; modelID: string }; variant?: string } | undefined {
const parsedModel = parseModelString(model)
if (!parsedModel) {
return undefined
}
return parsedModel.variant
? {
model: {
providerID: parsedModel.providerID,
modelID: parsedModel.modelID,
},
variant: parsedModel.variant,
}
: {
model: {
providerID: parsedModel.providerID,
modelID: parsedModel.modelID,
},
}
}
@@ -0,0 +1,95 @@
import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractAutoRetrySignal } from "./error-classifier"
import { createFallbackState } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
export function createSessionStatusHandler(
deps: HookDeps,
helpers: AutoRetryHelpers,
sessionStatusRetryKeys: Map<string, string>,
) {
const {
pluginConfig,
sessionStates,
sessionLastAccess,
sessionRetryInFlight,
} = deps
return 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 initialModel = resolveFallbackBootstrapModel({
sessionID,
source: "session.status",
eventModel: model,
resolvedAgent,
pluginConfig,
})
if (!initialModel) {
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
return
}
state = createFallbackState(initialModel)
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")
await dispatchFallbackRetry(deps, helpers, {
sessionID,
state,
fallbackModels,
resolvedAgent,
source: "session.status",
})
}
}
@@ -0,0 +1 @@
export type TimerHandle = number | { unref?: () => void }
+6
View File
@@ -72,5 +72,11 @@ export interface HookDeps {
sessionLastAccess: Map<string, number> sessionLastAccess: Map<string, number>
sessionRetryInFlight: Set<string> sessionRetryInFlight: Set<string>
sessionAwaitingFallbackResult: Set<string> sessionAwaitingFallbackResult: Set<string>
<<<<<<< HEAD
sessionFallbackTimeouts: Map<string, RuntimeFallbackTimeout> sessionFallbackTimeouts: Map<string, RuntimeFallbackTimeout>
||||||| parent of b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357))
sessionFallbackTimeouts: Map<string, ReturnType<typeof setTimeout>>
=======
sessionFallbackTimeouts: Map<string, TimerHandle>
>>>>>>> b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357))
} }
+60 -8
View File
@@ -1,10 +1,62 @@
/** const KNOWN_VARIANTS = new Set([
* Parse a model string in "provider/model" format. "low",
*/ "medium",
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined { "high",
const parts = model.split("/") "xhigh",
if (parts.length >= 2) { "max",
return { providerID: parts[0], modelID: parts.slice(1).join("/") } "none",
"auto",
"thinking",
])
function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
} }
return undefined
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModelID.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: trimmedModelID }
}
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
const parts = trimmedModel.split("/")
if (parts.length < 2) {
return undefined
}
const providerID = parts[0]?.trim()
const rawModelID = parts.slice(1).join("/").trim()
if (!providerID || !rawModelID) {
return undefined
}
const parsedModel = parseVariantFromModelID(rawModelID)
if (!parsedModel.modelID) {
return undefined
}
return parsedModel.variant
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
: { providerID, modelID: parsedModel.modelID }
} }