fix(fallback): dedupe providerless fallback errors

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-15 10:44:00 +09:00
parent da339204bb
commit 005d16dd96
2 changed files with 94 additions and 11 deletions
+60
View File
@@ -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"
+34 -11
View File
@@ -220,7 +220,7 @@ export function createEventHandler(args: {
const lastHandledRetryStatusKey = new Map<string, string>();
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
const modelFallbackContinuationsInFlight = new Set<string>();
const lastDispatchedModelFallbackContinuationKey = new Map<string, string>();
const lastDispatchedModelFallbackContinuationKeys = new Map<string, Set<string>>();
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<void> => {
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<string>();
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) {