From 005d16dd962faff44795a2e671244b2dca439bcf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 10:44:00 +0900 Subject: [PATCH] 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) {