fix(fallback): skip duplicate fallback re-arms

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 11:45:21 +09:00
parent b7482ea727
commit 23dfe7eec2
2 changed files with 94 additions and 67 deletions
+16 -6
View File
@@ -240,7 +240,7 @@ describe("createEventHandler - model fallback", () => {
await Promise.all([messageUpdated, sessionError]) await Promise.all([messageUpdated, sessionError])
//#then //#then
expect(pendingFallbackArms).toBe(2) expect(pendingFallbackArms).toBe(1)
expect(promptAsyncCalls).toEqual([sessionID]) expect(promptAsyncCalls).toEqual([sessionID])
expect(abortCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID])
}) })
@@ -300,7 +300,7 @@ describe("createEventHandler - model fallback", () => {
}) })
//#then //#then
expect(pendingFallbackArms).toBe(2) expect(pendingFallbackArms).toBe(1)
expect(promptAsyncCalls).toEqual([sessionID]) expect(promptAsyncCalls).toEqual([sessionID])
expect(abortCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID])
}) })
@@ -517,7 +517,7 @@ describe("createEventHandler - model fallback", () => {
expect(promptCalls).toEqual([sessionID]) 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 //#given
const sessionID = "ses_model_fallback_duplicate_surface" const sessionID = "ses_model_fallback_duplicate_surface"
setMainSession(sessionID) setMainSession(sessionID)
@@ -582,14 +582,12 @@ describe("createEventHandler - model fallback", () => {
output, 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({ await handler({
event: { event: {
type: "session.error", type: "session.error",
properties: { properties: {
sessionID, sessionID,
providerID: "anthropic",
modelID: "claude-opus-4-7-thinking",
error: { error: {
name: "UnknownError", name: "UnknownError",
data: { 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 //#then
expect(abortCalls).toEqual([sessionID]) expect(abortCalls).toEqual([sessionID])
expect(promptCalls).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 () => { test("does not trigger model-fallback from session.status when runtime_fallback is enabled", async () => {
+78 -61
View File
@@ -65,6 +65,13 @@ type FallbackContinuationDedupeState = {
providerlessModelKeys: Set<string>; providerlessModelKeys: Set<string>;
}; };
type FallbackContinuationContext = {
agentName?: string;
providerID?: string;
dedupeProviderID?: string;
modelID?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
@@ -372,12 +379,7 @@ export function createEventHandler(args: {
return true; return true;
}; };
const getFallbackContinuationKeys = (fallbackContext?: { const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => {
agentName?: string;
providerID?: string;
dedupeProviderID?: string;
modelID?: string;
}): FallbackContinuationDedupeKeys => {
const agentKey = fallbackContext?.agentName const agentKey = fallbackContext?.agentName
? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase() ? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase()
: ""; : "";
@@ -424,21 +426,16 @@ export function createEventHandler(args: {
return state.providerModelKeys.has(keys.providerModelKey) || state.providerlessModelKeys.has(keys.modelKey); return state.providerModelKeys.has(keys.providerModelKey) || state.providerlessModelKeys.has(keys.modelKey);
}; };
const autoContinueAfterFallback = async ( const shouldSkipFallbackContinuation = (
sessionID: string, sessionID: string,
source: string, source: string,
fallbackContext?: { fallbackContext?: FallbackContinuationContext,
agentName?: string; ): boolean => {
providerID?: string;
dedupeProviderID?: string;
modelID?: string;
},
): Promise<void> => {
const fallbackKeys = getFallbackContinuationKeys(fallbackContext); const fallbackKeys = getFallbackContinuationKeys(fallbackContext);
if (modelFallbackContinuationsInFlight.has(sessionID)) { if (modelFallbackContinuationsInFlight.has(sessionID)) {
log("[event] model-fallback continuation skipped because one is already in flight", { sessionID, source }); log("[event] model-fallback continuation skipped because one is already in flight", { sessionID, source });
return; return true;
} }
const lastDispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID); const lastDispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID);
@@ -447,6 +444,20 @@ export function createEventHandler(args: {
sessionID, sessionID,
source, source,
}); });
return true;
}
return false;
};
const autoContinueAfterFallback = async (
sessionID: string,
source: string,
fallbackContext?: FallbackContinuationContext,
): Promise<void> => {
const fallbackKeys = getFallbackContinuationKeys(fallbackContext);
if (shouldSkipFallbackContinuation(sessionID, source, fallbackContext)) {
return; return;
} }
@@ -750,24 +761,26 @@ export function createEventHandler(args: {
const currentProvider = resolveFallbackProviderID(sessionID, providerHint); const currentProvider = resolveFallbackProviderID(sessionID, providerHint);
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7"; const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7";
const currentModel = normalizeFallbackModelID(rawModel); 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 if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "message.updated", fallbackContext)) {
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
: false;
if ( const setFallback = modelFallback
setFallback && ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
shouldAutoRetrySession(sessionID) && : false;
!hooks.stopContinuationGuard?.isStopped(sessionID)
) { if (setFallback && shouldAutoContinue) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
await autoContinueAfterFallback(sessionID, "message.updated", { await autoContinueAfterFallback(sessionID, "message.updated", fallbackContext);
agentName, }
providerID: currentProvider,
dedupeProviderID: providerHint,
modelID: currentModel,
});
} }
} }
} }
@@ -821,23 +834,25 @@ export function createEventHandler(args: {
const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID); const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID);
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7"; let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7";
currentModel = normalizeFallbackModelID(currentModel); 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 if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "session.status", fallbackContext)) {
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
: false;
if ( const setFallback = modelFallback
setFallback && ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
shouldAutoRetrySession(sessionID) && : false;
!hooks.stopContinuationGuard?.isStopped(sessionID)
) { if (setFallback && shouldAutoContinue) {
await autoContinueAfterFallback(sessionID, "session.status", { await autoContinueAfterFallback(sessionID, "session.status", fallbackContext);
agentName, }
providerID: currentProvider,
dedupeProviderID: parsed.providerID,
modelID: currentModel,
});
} }
} }
} }
@@ -912,23 +927,25 @@ export function createEventHandler(args: {
const currentProvider = resolveFallbackProviderID(sessionID, providerHint); const currentProvider = resolveFallbackProviderID(sessionID, providerHint);
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7"; let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7";
currentModel = normalizeFallbackModelID(currentModel); 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 if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "session.error", fallbackContext)) {
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
: false;
if ( const setFallback = modelFallback
setFallback && ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
shouldAutoRetrySession(sessionID) && : false;
!hooks.stopContinuationGuard?.isStopped(sessionID)
) { if (setFallback && shouldAutoContinue) {
await autoContinueAfterFallback(sessionID, "session.error", { await autoContinueAfterFallback(sessionID, "session.error", fallbackContext);
agentName, }
providerID: currentProvider,
dedupeProviderID: providerHint,
modelID: currentModel,
});
} }
} }
} }