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:
@@ -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
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user