Merge pull request #4072 from PeterPonyu/fix/3645-runtime-fallback-git-silent-fail

fix(runtime-fallback): fall back to synthetic continuation when session messages are empty (#3645)
This commit is contained in:
YeonGyu-Kim
2026-05-21 12:58:30 +09:00
committed by GitHub
2 changed files with 88 additions and 62 deletions
+14 -5
View File
@@ -155,8 +155,20 @@ export function createAutoRetryHelpers(deps: HookDeps) {
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
const retryPayload = getLastUserRetryPayload(messagesResp, sessionID) const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
const retryParts = retryPayload.retryParts const fetchedParts = retryPayload.retryParts
if (retryParts.length > 0) { const retryParts =
fetchedParts.length > 0
? fetchedParts
: (() => {
log(
`[${HOOK_NAME}] No user message parts found for auto-retry (${source}); using synthetic continuation`,
{
sessionID,
hint: "This can occur when the working directory contains .git and messages are not yet persisted",
},
)
return [{ type: "text" as const, text: "continue" }]
})()
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, { log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID, sessionID,
model: newModel, model: newModel,
@@ -214,9 +226,6 @@ export function createAutoRetryHelpers(deps: HookDeps) {
state.pendingFallbackPromptMayHaveBeenAccepted = false state.pendingFallbackPromptMayHaveBeenAccepted = false
} }
retryDispatched = true retryDispatched = true
} else {
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
}
} catch (retryError) { } catch (retryError) {
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) }) log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
} finally { } finally {
+20 -3
View File
@@ -380,6 +380,9 @@ describe("runtime-fallback", () => {
type: "session.error", type: "session.error",
properties: { properties: {
sessionID, sessionID,
// model at the top level so the awaiting-fallback gate recognises this
// as an error from the fallback model we just dispatched
model: "anthropic/claude-opus-4.7",
error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.7." } }, error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.7." } },
}, },
}, },
@@ -432,6 +435,9 @@ describe("runtime-fallback", () => {
type: "session.error", type: "session.error",
properties: { properties: {
sessionID, sessionID,
// model at the top level so the awaiting-fallback gate recognises this
// as an error from the fallback model we just dispatched
model: "anthropic/claude-opus-4.7",
error: { error: {
name: "ProviderModelNotFoundError", name: "ProviderModelNotFoundError",
data: { data: {
@@ -2764,11 +2770,15 @@ describe("runtime-fallback", () => {
}, },
}) })
// Simulate the fallback session completing before the next error arrives
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
//#when - second error occurs immediately; tries to switch back to original model but should be in cooldown //#when - second error occurs immediately; tries to switch back to original model but should be in cooldown
await hook.event({ await hook.event({
event: { event: {
type: "session.error", type: "session.error",
properties: { sessionID, error: { statusCode: 429 } }, // model matches pendingFallbackModel so the awaiting-fallback gate lets this through
properties: { sessionID, model: "openai/gpt-5.4", error: { statusCode: 429 } },
}, },
}) })
@@ -3158,14 +3168,21 @@ describe("runtime-fallback", () => {
}, },
}) })
const autoRetryLog = logCalls.find((call) => call.msg.includes("No user message found for auto-retry")) const autoRetryLog = logCalls.find((call) =>
call.msg.includes("No user message parts found for auto-retry") &&
call.msg.includes("using synthetic continuation"),
)
expect(autoRetryLog).toBeDefined() expect(autoRetryLog).toBeDefined()
// Simulate the fallback session completing before the next error arrives
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
//#when - second error fires after retry completed (retryInFlight cleared) //#when - second error fires after retry completed (retryInFlight cleared)
await hook.event({ await hook.event({
event: { event: {
type: "session.error", type: "session.error",
properties: { sessionID, error: { statusCode: 429, message: "Rate limit again" } }, // model matches pendingFallbackModel so the awaiting-fallback gate lets this through
properties: { sessionID, model: "provider-a/model-a", error: { statusCode: 429, message: "Rate limit again" } },
}, },
}) })