From ba86ef0eea34c1d506f03c7c28e99c028f7c0406 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Mar 2026 01:43:17 +0900 Subject: [PATCH] fix: enable runtime fallback for delegated child sessions (#2357) --- src/hooks/runtime-fallback/auto-retry.ts | 72 ++++--- .../runtime-fallback/error-classifier.test.ts | 44 ++++- .../runtime-fallback/error-classifier.ts | 12 +- src/hooks/runtime-fallback/event-handler.ts | 152 +++----------- .../fallback-bootstrap-model.ts | 49 +++++ .../fallback-retry-dispatcher.ts | 55 ++++++ src/hooks/runtime-fallback/index.test.ts | 185 +++++++++++++++++- .../runtime-fallback/last-user-retry-parts.ts | 25 +++ .../message-update-handler.ts | 52 ++--- .../runtime-fallback/retry-model-payload.ts | 25 +++ .../session-status-handler.ts | 95 +++++++++ src/hooks/runtime-fallback/timer-handle.ts | 1 + src/hooks/runtime-fallback/types.ts | 6 + .../delegate-task/model-string-parser.ts | 68 ++++++- 14 files changed, 630 insertions(+), 211 deletions(-) create mode 100644 src/hooks/runtime-fallback/fallback-bootstrap-model.ts create mode 100644 src/hooks/runtime-fallback/fallback-retry-dispatcher.ts create mode 100644 src/hooks/runtime-fallback/last-user-retry-parts.ts create mode 100644 src/hooks/runtime-fallback/retry-model-payload.ts create mode 100644 src/hooks/runtime-fallback/session-status-handler.ts create mode 100644 src/hooks/runtime-fallback/timer-handle.ts diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 4b966c438..66e402b98 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -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, 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, delay?: number): ReturnType +declare function clearTimeout(timeout: ReturnType): void +======= +declare function setTimeout(callback: () => void | Promise, 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 - 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 }) } diff --git a/src/hooks/runtime-fallback/error-classifier.test.ts b/src/hooks/runtime-fallback/error-classifier.test.ts index 52c9bb502..163ccf745 100644 --- a/src/hooks/runtime-fallback/error-classifier.test.ts +++ b/src/hooks/runtime-fallback/error-classifier.test.ts @@ -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 = { diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 39b6ecdbb..c20aa8979 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -58,6 +58,11 @@ export function extractErrorName(error: unknown): string | undefined { return directName } + const dataName = (errorObj.data as Record | undefined)?.name + if (typeof dataName === "string" && dataName.length > 0) { + return dataName + } + const nestedError = errorObj.error as Record | 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" } diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index c631c8cd5..daf3519af 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -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() + const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys) const handleSessionCreated = (props: Record | 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 | 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 } } } diff --git a/src/hooks/runtime-fallback/fallback-bootstrap-model.ts b/src/hooks/runtime-fallback/fallback-bootstrap-model.ts new file mode 100644 index 000000000..eacb13fbd --- /dev/null +++ b/src/hooks/runtime-fallback/fallback-bootstrap-model.ts @@ -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 +} diff --git a/src/hooks/runtime-fallback/fallback-retry-dispatcher.ts b/src/hooks/runtime-fallback/fallback-retry-dispatcher.ts new file mode 100644 index 000000000..ecb28d2e5 --- /dev/null +++ b/src/hooks/runtime-fallback/fallback-retry-dispatcher.ts @@ -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 { + 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, + }) +} diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 73e1586f5..8bb506e00 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -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> = [] + const hook = createRuntimeFallbackHook( + createMockPluginInput({ + session: { + messages: async () => ({ + data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }], + }), + promptAsync: async (args) => { + promptCalls.push(args as Record) + 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> = [] + const hook = createRuntimeFallbackHook( + createMockPluginInput({ + session: { + messages: async () => ({ + data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }], + }), + promptAsync: async (args) => { + promptCalls.push(args as Record) + 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(() => {}) diff --git a/src/hooks/runtime-fallback/last-user-retry-parts.ts b/src/hooks/runtime-fallback/last-user-retry-parts.ts new file mode 100644 index 000000000..1f7f086bb --- /dev/null +++ b/src/hooks/runtime-fallback/last-user-retry-parts.ts @@ -0,0 +1,25 @@ +type SessionMessagesResponse = { + data?: Array<{ + info?: Record + 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 })) +} diff --git a/src/hooks/runtime-fallback/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index 9252b7918..86b2e1c6e 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -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", + }) } } } diff --git a/src/hooks/runtime-fallback/retry-model-payload.ts b/src/hooks/runtime-fallback/retry-model-payload.ts new file mode 100644 index 000000000..17d04aa90 --- /dev/null +++ b/src/hooks/runtime-fallback/retry-model-payload.ts @@ -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, + }, + } +} diff --git a/src/hooks/runtime-fallback/session-status-handler.ts b/src/hooks/runtime-fallback/session-status-handler.ts new file mode 100644 index 000000000..514d51a6e --- /dev/null +++ b/src/hooks/runtime-fallback/session-status-handler.ts @@ -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, +) { + const { + pluginConfig, + sessionStates, + sessionLastAccess, + sessionRetryInFlight, + } = deps + + return async (props: Record | 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", + }) + } +} diff --git a/src/hooks/runtime-fallback/timer-handle.ts b/src/hooks/runtime-fallback/timer-handle.ts new file mode 100644 index 000000000..c80e4a599 --- /dev/null +++ b/src/hooks/runtime-fallback/timer-handle.ts @@ -0,0 +1 @@ +export type TimerHandle = number | { unref?: () => void } diff --git a/src/hooks/runtime-fallback/types.ts b/src/hooks/runtime-fallback/types.ts index e3ef4a31b..686053257 100644 --- a/src/hooks/runtime-fallback/types.ts +++ b/src/hooks/runtime-fallback/types.ts @@ -72,5 +72,11 @@ export interface HookDeps { sessionLastAccess: Map sessionRetryInFlight: Set sessionAwaitingFallbackResult: Set +<<<<<<< HEAD sessionFallbackTimeouts: Map +||||||| parent of b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357)) + sessionFallbackTimeouts: Map> +======= + sessionFallbackTimeouts: Map +>>>>>>> b6f740ed (fix: enable runtime fallback for delegated child sessions (#2357)) } diff --git a/src/tools/delegate-task/model-string-parser.ts b/src/tools/delegate-task/model-string-parser.ts index 97d4f331f..061a97876 100644 --- a/src/tools/delegate-task/model-string-parser.ts +++ b/src/tools/delegate-task/model-string-parser.ts @@ -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 } }