From da339204bb3352ef35068d92dc372309040c69de Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 10:07:35 +0900 Subject: [PATCH 1/5] fix(fallback): dedupe overlapping fallback continuations Guard model fallback continuation dispatch so overlapping message.updated/session.error surfaces issue only one abort+promptAsync cycle for the same failed fallback. Refs https://github.com/code-yeongyu/oh-my-openagent/issues/4019 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.model-fallback.test.ts | 136 +++++++++++++++++++++--- src/plugin/event.ts | 105 ++++++++++++------ 2 files changed, 190 insertions(+), 51 deletions(-) diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index 5c5870bb9..e18c4cc18 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -1,5 +1,5 @@ -declare const require: (name: string) => any -const { afterEach, describe, expect, spyOn, test } = require("bun:test") +/// +import { afterEach, describe, expect, spyOn, test } from "bun:test" import { createEventHandler } from "./event" import { createChatMessageHandler } from "./chat-message" @@ -8,6 +8,13 @@ import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/mod import * as connectedProvidersCache from "../shared/connected-providers-cache" import { unsafeTestValue } from "../../test-support/unsafe-test-value" +type EventInput = { event: { type: string; properties?: unknown } } +type EventHandlerInput = Parameters>[0] + +function asEventHandlerInput(input: EventInput): EventHandlerInput { + return unsafeTestValue(input) +} + let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined @@ -17,25 +24,40 @@ function setupConnectedProviderCacheMocks(): void { } describe("createEventHandler - model fallback", () => { - const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => { + const createHandler = (args?: { + hooks?: any + pluginConfig?: any + promptAsync?: (input: { path: { id: string } }) => Promise + }) => { setupConnectedProviderCacheMocks() const abortCalls: string[] = [] const promptCalls: string[] = [] + const promptAsyncCalls: string[] = [] - const handler = createEventHandler({ + const sessionClient = { + abort: async ({ path }: { path: { id: string } }) => { + abortCalls.push(path.id) + return {} + }, + prompt: async ({ path }: { path: { id: string } }) => { + promptCalls.push(path.id) + return {} + }, + ...(args?.promptAsync + ? { + promptAsync: async (input: { path: { id: string } }) => { + promptAsyncCalls.push(input.path.id) + return args.promptAsync?.(input) + }, + } + : {}), + } + + const eventHandler = createEventHandler({ ctx: unsafeTestValue({ directory: "/tmp", client: { - session: { - abort: async ({ path }: { path: { id: string } }) => { - abortCalls.push(path.id) - return {} - }, - prompt: async ({ path }: { path: { id: string } }) => { - promptCalls.push(path.id) - return {} - }, - }, + session: sessionClient, }, }), pluginConfig: unsafeTestValue((args?.pluginConfig ?? {})), @@ -54,8 +76,9 @@ describe("createEventHandler - model fallback", () => { }), hooks: args?.hooks ?? (unsafeTestValue({})), }) + const handler = (input: EventInput): Promise => eventHandler(asEventHandlerInput(input)) - return { handler, abortCalls, promptCalls } + return { handler, abortCalls, promptCalls, promptAsyncCalls } } afterEach(() => { @@ -139,6 +162,85 @@ describe("createEventHandler - model fallback", () => { expect(promptCalls).toEqual([sessionID]) }) + test("does not dispatch duplicate fallback continuations when error events overlap", async () => { + //#given + const sessionID = "ses_model_fallback_concurrent_events" + setMainSession(sessionID) + let releasePromptAsync: (() => void) | undefined + const promptAsyncBlocked = new Promise((resolve) => { + releasePromptAsync = resolve + }) + let firstPromptAsyncStartedResolve: (() => void) | undefined + const firstPromptAsyncStarted = new Promise((resolve) => { + firstPromptAsyncStartedResolve = resolve + }) + let pendingFallbackArms = 0 + const modelFallback = unsafeTestValue({ + setSessionFallbackChain: () => {}, + setPendingModelFallback: () => { + pendingFallbackArms += 1 + return true + }, + }) + const { handler, abortCalls, promptAsyncCalls } = createHandler({ + hooks: { modelFallback }, + promptAsync: async () => { + if (promptAsyncCalls.length === 1) { + firstPromptAsyncStartedResolve?.() + } + await promptAsyncBlocked + return {} + }, + }) + + const assistantError = { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + isRetryable: true, + }, + } + + //#when + const messageUpdated = handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_err_concurrent_1", + sessionID, + role: "assistant", + error: assistantError, + modelID: "claude-opus-4-7-thinking", + providerID: "anthropic", + agent: "Sisyphus - Ultraworker", + }, + }, + }, + }) + await firstPromptAsyncStarted + const sessionError = handler({ + event: { + type: "session.error", + properties: { + sessionID, + providerID: "anthropic", + modelID: "claude-opus-4-7-thinking", + error: assistantError, + }, + }, + }) + + releasePromptAsync?.() + await Promise.all([messageUpdated, sessionError]) + + //#then + expect(pendingFallbackArms).toBe(2) + expect(promptAsyncCalls).toEqual([sessionID]) + expect(abortCalls).toEqual([sessionID]) + }) + test("triggers retry prompt on session.status retry events and applies fallback", async () => { //#given const sessionID = "ses_status_retry_fallback" @@ -603,7 +705,7 @@ describe("createEventHandler - model fallback", () => { }) const triggerRetryCycle = async (providerID: string, modelID: string) => { - await eventHandler({ + await eventHandler(asEventHandlerInput({ event: { type: "session.error", properties: { @@ -621,7 +723,7 @@ describe("createEventHandler - model fallback", () => { }, }, }, - }) + })) const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> } await chatMessageHandler( diff --git a/src/plugin/event.ts b/src/plugin/event.ts index b1c44a3eb..8752815dc 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -219,6 +219,8 @@ export function createEventHandler(args: { const lastHandledModelErrorMessageID = new Map(); const lastHandledRetryStatusKey = new Map(); const lastKnownModelBySession = new Map(); + const modelFallbackContinuationsInFlight = new Set(); + const lastDispatchedModelFallbackContinuationKey = new Map(); const resolveFallbackProviderID = (sessionID: string, providerHint?: string): string => { const normalizedProviderHint = providerHint?.trim(); @@ -368,46 +370,78 @@ export function createEventHandler(args: { modelID?: string; }, ): Promise => { - await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { - log("[event] model-fallback abort failed", { sessionID, source, error }); - }); + const fallbackKey = [ + fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName) : "", + fallbackContext?.providerID ?? "", + fallbackContext?.modelID ?? "", + ].join(":"); - const launchAgent = fallbackContext?.agentName - ? resolveRegisteredAgentName(fallbackContext.agentName) - : undefined; - const launchModel = fallbackContext?.providerID && fallbackContext?.modelID - ? { providerID: fallbackContext.providerID, modelID: fallbackContext.modelID } - : undefined; + if (modelFallbackContinuationsInFlight.has(sessionID)) { + log("[event] model-fallback continuation skipped because one is already in flight", { sessionID, source }); + return; + } - const agentConfigKey = fallbackContext?.agentName - ? getAgentConfigKey(fallbackContext.agentName) - : undefined; - const agentSettings = agentConfigKey - ? pluginConfig.agents?.[agentConfigKey as keyof NonNullable] - : undefined; - const launchVariant = (agentSettings as { variant?: string } | undefined)?.variant; - - const promptBody = { - path: { id: sessionID }, - body: { - ...(launchAgent ? { agent: launchAgent } : {}), - ...(launchModel ? { model: launchModel } : {}), - ...(launchVariant ? { variant: launchVariant } : {}), - parts: [createInternalAgentContinuationTextPart("continue")], - }, - query: { directory: pluginContext.directory }, - }; - - if (typeof pluginContext.client.session.promptAsync === "function") { - await pluginContext.client.session.promptAsync(promptBody).catch((error) => { - log("[event] model-fallback promptAsync failed", { sessionID, source, error }); + if (fallbackKey && lastDispatchedModelFallbackContinuationKey.get(sessionID) === fallbackKey) { + log("[event] model-fallback continuation skipped because matching fallback was already dispatched", { + sessionID, + source, }); return; } - await pluginContext.client.session.prompt(promptBody).catch((error) => { - log("[event] model-fallback prompt failed", { sessionID, source, error }); - }); + modelFallbackContinuationsInFlight.add(sessionID); + let dispatched = false; + try { + await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { + log("[event] model-fallback abort failed", { sessionID, source, error }); + }); + + const launchAgent = fallbackContext?.agentName + ? resolveRegisteredAgentName(fallbackContext.agentName) + : undefined; + const launchModel = fallbackContext?.providerID && fallbackContext?.modelID + ? { providerID: fallbackContext.providerID, modelID: fallbackContext.modelID } + : undefined; + + const agentConfigKey = fallbackContext?.agentName + ? getAgentConfigKey(fallbackContext.agentName) + : undefined; + const agentSettings = agentConfigKey + ? pluginConfig.agents?.[agentConfigKey as keyof NonNullable] + : undefined; + const launchVariant = (agentSettings as { variant?: string } | undefined)?.variant; + + const promptBody = { + path: { id: sessionID }, + body: { + ...(launchAgent ? { agent: launchAgent } : {}), + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + parts: [createInternalAgentContinuationTextPart("continue")], + }, + query: { directory: pluginContext.directory }, + }; + + if (typeof pluginContext.client.session.promptAsync === "function") { + await pluginContext.client.session.promptAsync(promptBody).then(() => { + dispatched = true; + }).catch((error) => { + log("[event] model-fallback promptAsync failed", { sessionID, source, error }); + }); + return; + } + + await pluginContext.client.session.prompt(promptBody).then(() => { + dispatched = true; + }).catch((error) => { + log("[event] model-fallback prompt failed", { sessionID, source, error }); + }); + } finally { + if (dispatched && fallbackKey) { + lastDispatchedModelFallbackContinuationKey.set(sessionID, fallbackKey); + } + modelFallbackContinuationsInFlight.delete(sessionID); + } }; return async (input): Promise => { @@ -526,6 +560,8 @@ export function createEventHandler(args: { lastHandledModelErrorMessageID.delete(sessionID); lastHandledRetryStatusKey.delete(sessionID); lastKnownModelBySession.delete(sessionID); + modelFallbackContinuationsInFlight.delete(sessionID); + lastDispatchedModelFallbackContinuationKey.delete(sessionID); if (modelFallback) { clearPendingModelFallback(modelFallback, sessionID); clearSessionFallbackChain(modelFallback, sessionID); @@ -684,6 +720,7 @@ export function createEventHandler(args: { // (non-retry idle) so future failures with the same key can trigger fallback again. if (sessionID && status?.type === "idle") { lastHandledRetryStatusKey.delete(sessionID); + lastDispatchedModelFallbackContinuationKey.delete(sessionID); } if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) { From 005d16dd962faff44795a2e671244b2dca439bcf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 10:44:00 +0900 Subject: [PATCH 2/5] fix(fallback): dedupe providerless fallback errors Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.model-fallback.test.ts | 60 +++++++++++++++++++++++++ src/plugin/event.ts | 45 ++++++++++++++----- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index e18c4cc18..15ed40131 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -241,6 +241,66 @@ describe("createEventHandler - model fallback", () => { expect(abortCalls).toEqual([sessionID]) }) + test("does not dispatch duplicate fallback continuations when session.error omits provider after dispatch", async () => { + //#given + const sessionID = "ses_model_fallback_providerless_duplicate" + setMainSession(sessionID) + let pendingFallbackArms = 0 + const modelFallback = unsafeTestValue({ + setSessionFallbackChain: () => {}, + setPendingModelFallback: () => { + pendingFallbackArms += 1 + return true + }, + }) + const { handler, abortCalls, promptAsyncCalls } = createHandler({ + hooks: { modelFallback }, + promptAsync: async () => ({}), + }) + + const assistantError = { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + isRetryable: true, + }, + } + + await handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_err_providerless_duplicate_1", + sessionID, + role: "assistant", + error: assistantError, + modelID: "claude-opus-4-7-thinking", + providerID: "anthropic", + agent: "Sisyphus - Ultraworker", + }, + }, + }, + }) + + //#when - same failed model arrives without provider metadata after first dispatch resolved + await handler({ + event: { + type: "session.error", + properties: { + sessionID, + error: assistantError, + }, + }, + }) + + //#then + expect(pendingFallbackArms).toBe(2) + expect(promptAsyncCalls).toEqual([sessionID]) + expect(abortCalls).toEqual([sessionID]) + }) + test("triggers retry prompt on session.status retry events and applies fallback", async () => { //#given const sessionID = "ses_status_retry_fallback" diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 8752815dc..eeefe0e78 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -220,7 +220,7 @@ export function createEventHandler(args: { const lastHandledRetryStatusKey = new Map(); const lastKnownModelBySession = new Map(); const modelFallbackContinuationsInFlight = new Set(); - const lastDispatchedModelFallbackContinuationKey = new Map(); + const lastDispatchedModelFallbackContinuationKeys = new Map>(); const resolveFallbackProviderID = (sessionID: string, providerHint?: string): string => { const normalizedProviderHint = providerHint?.trim(); @@ -361,6 +361,28 @@ export function createEventHandler(args: { return true; }; + const getFallbackContinuationKeys = (fallbackContext?: { + agentName?: string; + providerID?: string; + modelID?: string; + }): string[] => { + const agentKey = fallbackContext?.agentName + ? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase() + : ""; + const providerID = fallbackContext?.providerID?.trim().toLowerCase() ?? ""; + const modelID = fallbackContext?.modelID?.trim().toLowerCase() ?? ""; + + if (!agentKey || !modelID) { + return []; + } + + const keys = [`${agentKey}:${modelID}`]; + if (providerID) { + keys.push(`${agentKey}:${providerID}:${modelID}`); + } + return keys; + }; + const autoContinueAfterFallback = async ( sessionID: string, source: string, @@ -370,18 +392,15 @@ export function createEventHandler(args: { modelID?: string; }, ): Promise => { - const fallbackKey = [ - fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName) : "", - fallbackContext?.providerID ?? "", - fallbackContext?.modelID ?? "", - ].join(":"); + const fallbackKeys = getFallbackContinuationKeys(fallbackContext); if (modelFallbackContinuationsInFlight.has(sessionID)) { log("[event] model-fallback continuation skipped because one is already in flight", { sessionID, source }); return; } - if (fallbackKey && lastDispatchedModelFallbackContinuationKey.get(sessionID) === fallbackKey) { + const lastDispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID); + if (lastDispatchedKeys && fallbackKeys.some((fallbackKey) => lastDispatchedKeys.has(fallbackKey))) { log("[event] model-fallback continuation skipped because matching fallback was already dispatched", { sessionID, source, @@ -437,8 +456,12 @@ export function createEventHandler(args: { log("[event] model-fallback prompt failed", { sessionID, source, error }); }); } finally { - if (dispatched && fallbackKey) { - lastDispatchedModelFallbackContinuationKey.set(sessionID, fallbackKey); + if (dispatched && fallbackKeys.length > 0) { + const dispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID) ?? new Set(); + for (const fallbackKey of fallbackKeys) { + dispatchedKeys.add(fallbackKey); + } + lastDispatchedModelFallbackContinuationKeys.set(sessionID, dispatchedKeys); } modelFallbackContinuationsInFlight.delete(sessionID); } @@ -561,7 +584,7 @@ export function createEventHandler(args: { lastHandledRetryStatusKey.delete(sessionID); lastKnownModelBySession.delete(sessionID); modelFallbackContinuationsInFlight.delete(sessionID); - lastDispatchedModelFallbackContinuationKey.delete(sessionID); + lastDispatchedModelFallbackContinuationKeys.delete(sessionID); if (modelFallback) { clearPendingModelFallback(modelFallback, sessionID); clearSessionFallbackChain(modelFallback, sessionID); @@ -720,7 +743,7 @@ export function createEventHandler(args: { // (non-retry idle) so future failures with the same key can trigger fallback again. if (sessionID && status?.type === "idle") { lastHandledRetryStatusKey.delete(sessionID); - lastDispatchedModelFallbackContinuationKey.delete(sessionID); + lastDispatchedModelFallbackContinuationKeys.delete(sessionID); } if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) { From 26bb6231b64cb39a62f48d5cc5b65fab55ccd0ab Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 11:06:04 +0900 Subject: [PATCH 3/5] fix(fallback): preserve provider-specific fallback retries Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.model-fallback.test.ts | 62 ++++++++++++++++++ src/plugin/event.ts | 87 ++++++++++++++++++------- 2 files changed, 127 insertions(+), 22 deletions(-) diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index 15ed40131..f75b99682 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -301,6 +301,68 @@ describe("createEventHandler - model fallback", () => { expect(abortCalls).toEqual([sessionID]) }) + test("does not collapse fallback continuations for different providers with the same model id", async () => { + //#given + const sessionID = "ses_model_fallback_same_model_different_provider" + setMainSession(sessionID) + let pendingFallbackArms = 0 + const modelFallback = unsafeTestValue({ + setSessionFallbackChain: () => {}, + setPendingModelFallback: () => { + pendingFallbackArms += 1 + return true + }, + }) + const { handler, abortCalls, promptAsyncCalls } = createHandler({ + hooks: { modelFallback }, + promptAsync: async () => ({}), + }) + + const assistantError = { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + isRetryable: true, + }, + } + + await handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_err_same_model_provider_1", + sessionID, + role: "assistant", + error: assistantError, + modelID: "claude-opus-4-7-thinking", + providerID: "anthropic", + agent: "Sisyphus - Ultraworker", + }, + }, + }, + }) + + //#when - a distinct provider reports the same normalized model id before idle cleanup + await handler({ + event: { + type: "session.error", + properties: { + sessionID, + providerID: "quotio", + modelID: "claude-opus-4-7-thinking", + error: assistantError, + }, + }, + }) + + //#then + expect(pendingFallbackArms).toBe(2) + expect(promptAsyncCalls).toEqual([sessionID, sessionID]) + expect(abortCalls).toEqual([sessionID, sessionID]) + }) + test("triggers retry prompt on session.status retry events and applies fallback", async () => { //#given const sessionID = "ses_status_retry_fallback" diff --git a/src/plugin/event.ts b/src/plugin/event.ts index eeefe0e78..5feaca791 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -54,6 +54,17 @@ type FirstMessageVariantGate = { clear: (sessionID: string) => void; }; +type FallbackContinuationDedupeKeys = { + modelKey?: string; + providerModelKey?: string; +}; + +type FallbackContinuationDedupeState = { + modelKeys: Set; + providerModelKeys: Set; + providerlessModelKeys: Set; +}; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -220,7 +231,7 @@ export function createEventHandler(args: { const lastHandledRetryStatusKey = new Map(); const lastKnownModelBySession = new Map(); const modelFallbackContinuationsInFlight = new Set(); - const lastDispatchedModelFallbackContinuationKeys = new Map>(); + const lastDispatchedModelFallbackContinuationKeys = new Map(); const resolveFallbackProviderID = (sessionID: string, providerHint?: string): string => { const normalizedProviderHint = providerHint?.trim(); @@ -364,23 +375,53 @@ export function createEventHandler(args: { const getFallbackContinuationKeys = (fallbackContext?: { agentName?: string; providerID?: string; + dedupeProviderID?: string; modelID?: string; - }): string[] => { + }): FallbackContinuationDedupeKeys => { const agentKey = fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase() : ""; - const providerID = fallbackContext?.providerID?.trim().toLowerCase() ?? ""; + const providerID = fallbackContext?.dedupeProviderID?.trim().toLowerCase() ?? ""; const modelID = fallbackContext?.modelID?.trim().toLowerCase() ?? ""; if (!agentKey || !modelID) { - return []; + return {}; } - const keys = [`${agentKey}:${modelID}`]; - if (providerID) { - keys.push(`${agentKey}:${providerID}:${modelID}`); + return { + modelKey: `${agentKey}:${modelID}`, + ...(providerID ? { providerModelKey: `${agentKey}:${providerID}:${modelID}` } : {}), + }; + }; + + const getFallbackContinuationDedupeState = (sessionID: string): FallbackContinuationDedupeState => { + const existingState = lastDispatchedModelFallbackContinuationKeys.get(sessionID); + if (existingState) { + return existingState; } - return keys; + + const state = { + modelKeys: new Set(), + providerModelKeys: new Set(), + providerlessModelKeys: new Set(), + }; + lastDispatchedModelFallbackContinuationKeys.set(sessionID, state); + return state; + }; + + const wasFallbackContinuationAlreadyDispatched = ( + state: FallbackContinuationDedupeState | undefined, + keys: FallbackContinuationDedupeKeys, + ): boolean => { + if (!state || !keys.modelKey) { + return false; + } + + if (!keys.providerModelKey) { + return state.modelKeys.has(keys.modelKey); + } + + return state.providerModelKeys.has(keys.providerModelKey) || state.providerlessModelKeys.has(keys.modelKey); }; const autoContinueAfterFallback = async ( @@ -389,6 +430,7 @@ export function createEventHandler(args: { fallbackContext?: { agentName?: string; providerID?: string; + dedupeProviderID?: string; modelID?: string; }, ): Promise => { @@ -400,7 +442,7 @@ export function createEventHandler(args: { } const lastDispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID); - if (lastDispatchedKeys && fallbackKeys.some((fallbackKey) => lastDispatchedKeys.has(fallbackKey))) { + if (wasFallbackContinuationAlreadyDispatched(lastDispatchedKeys, fallbackKeys)) { log("[event] model-fallback continuation skipped because matching fallback was already dispatched", { sessionID, source, @@ -456,12 +498,14 @@ export function createEventHandler(args: { log("[event] model-fallback prompt failed", { sessionID, source, error }); }); } finally { - if (dispatched && fallbackKeys.length > 0) { - const dispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID) ?? new Set(); - for (const fallbackKey of fallbackKeys) { - dispatchedKeys.add(fallbackKey); + if (dispatched && fallbackKeys.modelKey) { + const dispatchedKeys = getFallbackContinuationDedupeState(sessionID); + dispatchedKeys.modelKeys.add(fallbackKeys.modelKey); + if (fallbackKeys.providerModelKey) { + dispatchedKeys.providerModelKeys.add(fallbackKeys.providerModelKey); + } else { + dispatchedKeys.providerlessModelKeys.add(fallbackKeys.modelKey); } - lastDispatchedModelFallbackContinuationKeys.set(sessionID, dispatchedKeys); } modelFallbackContinuationsInFlight.delete(sessionID); } @@ -702,10 +746,8 @@ export function createEventHandler(args: { } if (agentName) { - const currentProvider = resolveFallbackProviderID( - sessionID, - info?.providerID as string | undefined, - ); + const providerHint = info?.providerID as string | undefined; + const currentProvider = resolveFallbackProviderID(sessionID, providerHint); const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7"; const currentModel = normalizeFallbackModelID(rawModel); applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); @@ -723,6 +765,7 @@ export function createEventHandler(args: { await autoContinueAfterFallback(sessionID, "message.updated", { agentName, providerID: currentProvider, + dedupeProviderID: providerHint, modelID: currentModel, }); } @@ -792,6 +835,7 @@ export function createEventHandler(args: { await autoContinueAfterFallback(sessionID, "session.status", { agentName, providerID: currentProvider, + dedupeProviderID: parsed.providerID, modelID: currentModel, }); } @@ -864,10 +908,8 @@ export function createEventHandler(args: { if (agentName) { const parsed = extractProviderModelFromErrorMessage(errorMessage); - const currentProvider = resolveFallbackProviderID( - sessionID, - (props?.providerID as string | undefined) || parsed.providerID, - ); + const providerHint = (props?.providerID as string | undefined) || parsed.providerID; + const currentProvider = resolveFallbackProviderID(sessionID, providerHint); let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7"; currentModel = normalizeFallbackModelID(currentModel); applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); @@ -884,6 +926,7 @@ export function createEventHandler(args: { await autoContinueAfterFallback(sessionID, "session.error", { agentName, providerID: currentProvider, + dedupeProviderID: providerHint, modelID: currentModel, }); } From b7482ea727c7e7d75d02e8286f3de080b03c55fa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 11:28:18 +0900 Subject: [PATCH 4/5] test(fallback): type chat output assertions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.model-fallback.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index f75b99682..ee7f526a1 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -10,6 +10,10 @@ import { unsafeTestValue } from "../../test-support/unsafe-test-value" type EventInput = { event: { type: string; properties?: unknown } } type EventHandlerInput = Parameters>[0] +type ChatMessageOutput = { + message: Record + parts: Array<{ type: string; text?: string }> +} function asEventHandlerInput(input: EventInput): EventHandlerInput { return unsafeTestValue(input) @@ -432,7 +436,7 @@ describe("createEventHandler - model fallback", () => { }, }) - const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> } + const output: ChatMessageOutput = { message: {}, parts: [] } await chatMessageHandler( { sessionID, @@ -568,7 +572,7 @@ describe("createEventHandler - model fallback", () => { }, }) - const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> } + const output: ChatMessageOutput = { message: {}, parts: [] } await chatMessageHandler( { sessionID, @@ -733,7 +737,7 @@ describe("createEventHandler - model fallback", () => { }, }) - const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> } + const output: ChatMessageOutput = { message: {}, parts: [] } await chatMessageHandler( { sessionID, @@ -847,7 +851,7 @@ describe("createEventHandler - model fallback", () => { }, })) - const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> } + const output: ChatMessageOutput = { message: {}, parts: [] } await chatMessageHandler( { sessionID, From 23dfe7eec2866c58173fcf3506d775bed69f0cdf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 11:45:21 +0900 Subject: [PATCH 5/5] fix(fallback): skip duplicate fallback re-arms Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.model-fallback.test.ts | 22 +++- src/plugin/event.ts | 139 +++++++++++++----------- 2 files changed, 94 insertions(+), 67 deletions(-) diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index ee7f526a1..a711ef08b 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -240,7 +240,7 @@ describe("createEventHandler - model fallback", () => { await Promise.all([messageUpdated, sessionError]) //#then - expect(pendingFallbackArms).toBe(2) + expect(pendingFallbackArms).toBe(1) expect(promptAsyncCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID]) }) @@ -300,7 +300,7 @@ describe("createEventHandler - model fallback", () => { }) //#then - expect(pendingFallbackArms).toBe(2) + expect(pendingFallbackArms).toBe(1) expect(promptAsyncCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID]) }) @@ -517,7 +517,7 @@ describe("createEventHandler - model fallback", () => { expect(promptCalls).toEqual([sessionID]) }) - test("does not re-arm fallback when a duplicate error reports the same failed model after fallback was applied", async () => { + test("does not leave stale pending fallback when a providerless duplicate arrives after fallback was applied", async () => { //#given const sessionID = "ses_model_fallback_duplicate_surface" setMainSession(sessionID) @@ -582,14 +582,12 @@ describe("createEventHandler - model fallback", () => { output, ) - //#when - same failed model arrives again through another OpenCode event surface + //#when - same failed model arrives again without provider metadata after fallback was applied await handler({ event: { type: "session.error", properties: { sessionID, - providerID: "anthropic", - modelID: "claude-opus-4-7-thinking", error: { name: "UnknownError", data: { @@ -603,9 +601,21 @@ describe("createEventHandler - model fallback", () => { }, }) + const staleOutput: ChatMessageOutput = { message: {}, parts: [] } + await chatMessageHandler( + { + sessionID, + agent: "sisyphus", + model: { providerID: "opencode-go", modelID: "kimi-k2.6" }, + }, + staleOutput, + ) + //#then expect(abortCalls).toEqual([sessionID]) expect(promptCalls).toEqual([sessionID]) + expect(modelFallback.hasPendingModelFallback(sessionID)).toBe(false) + expect(staleOutput.message["model"]).toBeUndefined() }) test("does not trigger model-fallback from session.status when runtime_fallback is enabled", async () => { diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 5feaca791..976c732ab 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -65,6 +65,13 @@ type FallbackContinuationDedupeState = { providerlessModelKeys: Set; }; +type FallbackContinuationContext = { + agentName?: string; + providerID?: string; + dedupeProviderID?: string; + modelID?: string; +}; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -372,12 +379,7 @@ export function createEventHandler(args: { return true; }; - const getFallbackContinuationKeys = (fallbackContext?: { - agentName?: string; - providerID?: string; - dedupeProviderID?: string; - modelID?: string; - }): FallbackContinuationDedupeKeys => { + const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => { const agentKey = fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase() : ""; @@ -424,21 +426,16 @@ export function createEventHandler(args: { return state.providerModelKeys.has(keys.providerModelKey) || state.providerlessModelKeys.has(keys.modelKey); }; - const autoContinueAfterFallback = async ( + const shouldSkipFallbackContinuation = ( sessionID: string, source: string, - fallbackContext?: { - agentName?: string; - providerID?: string; - dedupeProviderID?: string; - modelID?: string; - }, - ): Promise => { + fallbackContext?: FallbackContinuationContext, + ): boolean => { const fallbackKeys = getFallbackContinuationKeys(fallbackContext); if (modelFallbackContinuationsInFlight.has(sessionID)) { log("[event] model-fallback continuation skipped because one is already in flight", { sessionID, source }); - return; + return true; } const lastDispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID); @@ -447,6 +444,20 @@ export function createEventHandler(args: { sessionID, source, }); + return true; + } + + return false; + }; + + const autoContinueAfterFallback = async ( + sessionID: string, + source: string, + fallbackContext?: FallbackContinuationContext, + ): Promise => { + const fallbackKeys = getFallbackContinuationKeys(fallbackContext); + + if (shouldSkipFallbackContinuation(sessionID, source, fallbackContext)) { return; } @@ -750,24 +761,26 @@ export function createEventHandler(args: { const currentProvider = resolveFallbackProviderID(sessionID, providerHint); const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7"; const currentModel = normalizeFallbackModelID(rawModel); - applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); + const fallbackContext = { + agentName, + providerID: currentProvider, + dedupeProviderID: providerHint, + modelID: currentModel, + }; + const shouldAutoContinue = shouldAutoRetrySession(sessionID) && + !hooks.stopContinuationGuard?.isStopped(sessionID); - const setFallback = modelFallback - ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) - : false; + if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "message.updated", fallbackContext)) { + applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); - if ( - setFallback && - shouldAutoRetrySession(sessionID) && - !hooks.stopContinuationGuard?.isStopped(sessionID) - ) { - lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); - await autoContinueAfterFallback(sessionID, "message.updated", { - agentName, - providerID: currentProvider, - dedupeProviderID: providerHint, - modelID: currentModel, - }); + const setFallback = modelFallback + ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) + : false; + + if (setFallback && shouldAutoContinue) { + lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); + await autoContinueAfterFallback(sessionID, "message.updated", fallbackContext); + } } } } @@ -821,23 +834,25 @@ export function createEventHandler(args: { const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID); let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7"; currentModel = normalizeFallbackModelID(currentModel); - applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); + const fallbackContext = { + agentName, + providerID: currentProvider, + dedupeProviderID: parsed.providerID, + modelID: currentModel, + }; + const shouldAutoContinue = shouldAutoRetrySession(sessionID) && + !hooks.stopContinuationGuard?.isStopped(sessionID); - const setFallback = modelFallback - ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) - : false; + if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "session.status", fallbackContext)) { + applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); - if ( - setFallback && - shouldAutoRetrySession(sessionID) && - !hooks.stopContinuationGuard?.isStopped(sessionID) - ) { - await autoContinueAfterFallback(sessionID, "session.status", { - agentName, - providerID: currentProvider, - dedupeProviderID: parsed.providerID, - modelID: currentModel, - }); + const setFallback = modelFallback + ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) + : false; + + if (setFallback && shouldAutoContinue) { + await autoContinueAfterFallback(sessionID, "session.status", fallbackContext); + } } } } @@ -912,23 +927,25 @@ export function createEventHandler(args: { const currentProvider = resolveFallbackProviderID(sessionID, providerHint); let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7"; currentModel = normalizeFallbackModelID(currentModel); - applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); + const fallbackContext = { + agentName, + providerID: currentProvider, + dedupeProviderID: providerHint, + modelID: currentModel, + }; + const shouldAutoContinue = shouldAutoRetrySession(sessionID) && + !hooks.stopContinuationGuard?.isStopped(sessionID); - const setFallback = modelFallback - ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) - : false; + if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "session.error", fallbackContext)) { + applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); - if ( - setFallback && - shouldAutoRetrySession(sessionID) && - !hooks.stopContinuationGuard?.isStopped(sessionID) - ) { - await autoContinueAfterFallback(sessionID, "session.error", { - agentName, - providerID: currentProvider, - dedupeProviderID: providerHint, - modelID: currentModel, - }); + const setFallback = modelFallback + ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) + : false; + + if (setFallback && shouldAutoContinue) { + await autoContinueAfterFallback(sessionID, "session.error", fallbackContext); + } } } }