fix(recovery): avoid duplicate continuation prompts
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user