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

View File

@@ -1,4 +1,11 @@
<<<<<<< HEAD
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 { log } from "../../shared/logger"
import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver"
@@ -6,11 +13,21 @@ import { getSessionAgent } from "../../features/claude-code-session-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { prepareFallback } from "./fallback-state"
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
<<<<<<< HEAD
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout
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) {
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
@@ -87,8 +104,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
return
}
const modelParts = newModel.split("/")
if (modelParts.length < 2) {
const retryModelPayload = buildRetryModelPayload(newModel)
if (!retryModelPayload) {
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
@@ -97,11 +114,6 @@ export function createAutoRetryHelpers(deps: HookDeps) {
return
}
const fallbackModelObj = {
providerID: modelParts[0],
modelID: modelParts.slice(1).join("/"),
}
sessionRetryInFlight.add(sessionID)
let retryDispatched = false
try {
@@ -109,43 +121,27 @@ export function createAutoRetryHelpers(deps: HookDeps) {
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as {
data?: Array<{
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) {
const retryParts = getLastUserRetryParts(messagesResp)
if (retryParts.length > 0) {
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID,
model: newModel,
})
const retryParts = lastUserPartsRaw
.filter((p) => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
.map((p) => ({ type: "text" as const, text: p.text! }))
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
sessionAwaitingFallbackResult.add(sessionID)
scheduleSessionFallbackTimeout(sessionID, retryAgent)
if (retryParts.length > 0) {
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
sessionAwaitingFallbackResult.add(sessionID)
scheduleSessionFallbackTimeout(sessionID, retryAgent)
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
...(retryAgent ? { agent: retryAgent } : {}),
model: fallbackModelObj,
parts: retryParts,
},
query: { directory: ctx.directory },
})
retryDispatched = true
}
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
...(retryAgent ? { agent: retryAgent } : {}),
...retryModelPayload,
parts: retryParts,
},
query: { directory: ctx.directory },
})
retryDispatched = true
} else {
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
}

View File

@@ -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", () => {
test("detects cooling-down auto-retry status signals", () => {
@@ -45,6 +46,45 @@ describe("runtime-fallback error classifier", () => {
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", () => {
//#given
const info = {

View File

@@ -58,6 +58,11 @@ export function extractErrorName(error: unknown): string | undefined {
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 nestedName = nestedError?.name
if (typeof nestedName === "string" && nestedName.length > 0) {
@@ -78,6 +83,7 @@ export function classifyErrorType(error: unknown): string | undefined {
const errorName = extractErrorName(error)?.toLowerCase()
if (
errorName?.includes("ai_loadapikeyerror") ||
errorName?.includes("loadapi") ||
(/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"
}
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"
}

View File

@@ -2,15 +2,18 @@ import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier"
import { createFallbackState } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
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) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
const sessionStatusRetryKeys = new Map<string, string>()
const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys)
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
@@ -136,135 +139,32 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
}
if (!state) {
const currentModel = props?.model as string | undefined
if (currentModel) {
state = createFallbackState(currentModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
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
}
const initialModel = resolveFallbackBootstrapModel({
sessionID,
source: "session.error",
eventModel: props?.model as string | undefined,
resolvedAgent,
pluginConfig,
})
if (!initialModel) {
log(`[${HOOK_NAME}] No model info available, cannot fallback`, { sessionID })
return
}
state = createFallbackState(initialModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else {
sessionLastAccess.set(sessionID, Date.now())
}
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.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`, {
await dispatchFallbackRetry(deps, helpers, {
sessionID,
model: state.currentModel,
retryAttempt: status.attempt,
state,
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 } }) => {
@@ -276,7 +176,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
if (event.type === "session.stop") { await handleSessionStop(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 }
}
}

View File

@@ -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
}

View File

@@ -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,
})
}

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 type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
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", () => {
test("should detect retryable error with status code 429", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
@@ -310,6 +328,114 @@ describe("runtime-fallback", () => {
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 () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
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" })
})
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 () => {
const pending = new Promise<never>(() => {})

View File

@@ -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 }))
}

View File

@@ -3,8 +3,10 @@ import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
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 { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof extractAutoRetrySignal) {
return async (
@@ -154,22 +156,13 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
}
if (!state) {
let initialModel = model
if (!initialModel) {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
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
}
}
const initialModel = resolveFallbackBootstrapModel({
sessionID,
source: "message.updated",
eventModel: model,
resolvedAgent,
pluginConfig,
})
if (!initialModel) {
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)
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, "message.updated")
}
await dispatchFallbackRetry(deps, helpers, {
sessionID,
state,
fallbackModels,
resolvedAgent,
source: "message.updated",
})
}
}
}

View File

@@ -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,
},
}
}

View File

@@ -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",
})
}
}

View File

@@ -0,0 +1 @@
export type TimerHandle = number | { unref?: () => void }

View File

@@ -72,5 +72,11 @@ export interface HookDeps {
sessionLastAccess: Map<string, number>
sessionRetryInFlight: Set<string>
sessionAwaitingFallbackResult: Set<string>
<<<<<<< HEAD
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))
}

View File

@@ -1,10 +1,62 @@
/**
* Parse a model string in "provider/model" format.
*/
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"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 }
}