fix: enable runtime fallback for delegated child sessions (#2357)
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
49
src/hooks/runtime-fallback/fallback-bootstrap-model.ts
Normal file
49
src/hooks/runtime-fallback/fallback-bootstrap-model.ts
Normal 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
|
||||
}
|
||||
55
src/hooks/runtime-fallback/fallback-retry-dispatcher.ts
Normal file
55
src/hooks/runtime-fallback/fallback-retry-dispatcher.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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>(() => {})
|
||||
|
||||
|
||||
25
src/hooks/runtime-fallback/last-user-retry-parts.ts
Normal file
25
src/hooks/runtime-fallback/last-user-retry-parts.ts
Normal 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 }))
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
src/hooks/runtime-fallback/retry-model-payload.ts
Normal file
25
src/hooks/runtime-fallback/retry-model-payload.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
95
src/hooks/runtime-fallback/session-status-handler.ts
Normal file
95
src/hooks/runtime-fallback/session-status-handler.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/hooks/runtime-fallback/timer-handle.ts
Normal file
1
src/hooks/runtime-fallback/timer-handle.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type TimerHandle = number | { unref?: () => void }
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user