fix(recovery): avoid duplicate continuation prompts

This commit is contained in:
YeonGyu-Kim
2026-05-19 13:48:39 +09:00
parent e57bac3b6b
commit 38462aa9d2
4 changed files with 147 additions and 15 deletions
+81 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createSessionRecoveryHook } from "./hook"
import { _setInterruptedIdleMessagesFetchTimeoutMsForTesting } from "./interrupted-idle-message-fetch-timeout"
import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
import { dispatchInternalPrompt, releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
type RecoverableInfo = Parameters<ReturnType<typeof createSessionRecoveryHook>["handleSessionRecovery"]>[0]
@@ -103,6 +103,86 @@ describe("session-recovery hook persistent dedupe", () => {
expect(result).toBe(false)
expect(counts.abort).toBe(1)
})
test("#given recovery is blocked by a peer prompt reservation #when the same error is observed after the reservation clears #then recovery retries once", async () => {
// given
const sessionID = "ses_recovery_gate_block"
const promptAsyncCalls: PromptAsyncCall[] = []
let releasePeerPrompt: (() => void) | undefined
const peerPrompt = new Promise<void>((resolve) => {
releasePeerPrompt = resolve
})
const peerReservation = dispatchInternalPrompt({
mode: "async",
client: {
session: {
promptAsync: async () => {
await peerPrompt
},
},
},
sessionID,
input: { path: { id: sessionID }, body: { parts: [{ type: "text", text: "peer" }] } },
source: "test:peer-recovery-blocker",
settleMs: 0,
})
await Promise.resolve()
const info: RecoverableInfo = {
id: "msg_tool_missing",
role: "assistant",
sessionID,
error: { message: "messages.2 has tool_use without a matching tool_result" },
}
const ctx = {
client: {
session: {
abort: async () => ({}),
messages: async () => ({
data: [
{
info: {
id: info.id,
role: "assistant",
error: info.error,
},
parts: [
{
type: "tool_use",
id: "toolu_recovery_gate",
name: "bash",
input: {},
state: { status: "running" },
},
],
},
],
}),
promptAsync: async (call: PromptAsyncCall) => {
promptAsyncCalls.push(call)
return {}
},
},
tui: {
showToast: async () => ({}),
},
},
directory: "/tmp/session-recovery-gate-test",
}
const hook = createSessionRecoveryHook(ctx as never)
// when
const firstResult = await hook.handleSessionRecovery(info)
releasePeerPrompt?.()
await peerReservation
releaseAllPromptAsyncReservationsForTesting()
const secondResult = await hook.handleSessionRecovery(info)
// then
expect(firstResult).toBe(false)
expect(secondResult).toBe(true)
expect(promptAsyncCalls).toHaveLength(1)
})
})
describe("session-recovery hook interrupted idle recovery", () => {
+8 -7
View File
@@ -191,6 +191,7 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
if (!assistantMsgID) return false
if (processingErrors.has(assistantMsgID)) return false
processingErrors.add(assistantMsgID)
let shouldKeepProcessingError = false
try {
if (onAbortCallback) {
@@ -268,21 +269,21 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
await resumeSession(ctx.client, resumeConfig)
}
} else if (errorType === "assistant_prefill_unsupported") {
shouldKeepProcessingError = true
success = false
}
if (success) {
shouldKeepProcessingError = true
}
return success
} catch (err) {
log("[session-recovery] Recovery failed:", err)
return false
} finally {
// Keep assistantMsgID in processingErrors permanently so that a
// stale duplicate session.error for the SAME assistant message
// does not retrigger recovery (and a second resumeSession
// promptAsync injection) after the first attempt resolves.
// Successful recovery starts a new assistant message on the next
// turn with a different id, so this dedupe never blocks future
// legitimate errors.
if (!shouldKeepProcessingError) {
processingErrors.delete(assistantMsgID)
}
if (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}