fix(fallback): guard duplicate prompt injections

This commit is contained in:
YeonGyu-Kim
2026-05-14 01:03:19 +09:00
parent ea55c385bb
commit 5ffbe0e24e
7 changed files with 257 additions and 15 deletions
@@ -12,6 +12,21 @@ import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
import { createSessionStatusHandler } from "./session-status-handler"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
function resolveEventModel(props: Record<string, unknown> | undefined): string | undefined {
const model = props?.model
if (typeof model === "string") {
return model
}
const providerID = props?.providerID
const modelID = props?.modelID
if (typeof providerID === "string" && typeof modelID === "string") {
return `${providerID}/${modelID}`
}
return undefined
}
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys)
@@ -137,6 +152,19 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
return
}
if (sessionAwaitingFallbackResult.has(sessionID)) {
const pendingFallbackModel = sessionStates.get(sessionID)?.pendingFallbackModel
const eventModel = resolveEventModel(props)
if (!pendingFallbackModel || eventModel !== pendingFallbackModel) {
log(`[${HOOK_NAME}] session.error skipped - awaiting fallback result`, {
sessionID,
pendingFallbackModel,
eventModel,
})
return
}
}
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
+66 -1
View File
@@ -2650,7 +2650,7 @@ describe("runtime-fallback", () => {
await hook.event({
event: {
type: "session.error",
properties: { sessionID, error: { statusCode: 429, message: "Rate limit again" } },
properties: { sessionID, model: "provider-a/model-a", error: { statusCode: 429, message: "Rate limit again" } },
},
})
@@ -2659,6 +2659,71 @@ describe("runtime-fallback", () => {
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
})
test("session.error is skipped while waiting for the dispatched fallback result", async () => {
const promptCalls: Array<unknown> = []
//#given
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
}),
promptAsync: async (args: unknown) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
test: {
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
},
},
},
}
)
const sessionID = "test-race-awaiting-fallback-result"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
},
})
await hook.event({
event: {
type: "session.error",
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
},
})
//#when - duplicate stale error fires after promptAsync resolved but before fallback output is visible
await hook.event({
event: {
type: "session.error",
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
},
})
//#then
expect(promptCalls).toHaveLength(1)
const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
expect(fallbackLogs).toHaveLength(1)
const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped - awaiting fallback result"))
expect(skipLog).toBeDefined()
})
test("session.stop aborts when sessionAwaitingFallbackResult is set", async () => {
const abortCalls: Array<{ path?: { id?: string } }> = []