fix(session-recovery): persist dedupe across stale repeated session.error

processingErrors was emptied in a finally block, so a second
session.error fired for the same assistant message id after the
first recovery resolved would re-run abort, history fetch, the
recovery toast, and any auto-resume promptAsync (resumeSession),
producing duplicate internal prompt injections during stale event
re-emission or polling-driven retries.

Drop the in-flight delete and keep the dedupe permanent for the
plugin lifetime. A genuinely new failure starts a new assistant
message with a different id, so this never blocks future legitimate
errors. Same-id duplicates collapse into a single recovery attempt.

Add hook.test.ts asserting that two sequential handleSessionRecovery
calls for the same recoverable info trigger session.abort, the
recovery toast, and any internal promptAsync at most once.
This commit is contained in:
YeonGyu-Kim
2026-05-15 18:43:53 +09:00
parent a02686e729
commit 8e9dea949b
2 changed files with 93 additions and 2 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { createSessionRecoveryHook } from "./hook"
type RecoverableInfo = Parameters<ReturnType<typeof createSessionRecoveryHook>["handleSessionRecovery"]>[0]
function createPrefillErrorInfo(): RecoverableInfo {
return {
id: "msg_failed_prefill",
role: "assistant",
sessionID: "ses_recovery_dedupe",
error: { message: "This model does not support assistant message prefill." },
}
}
function createCountingCtx() {
const counts = { abort: 0, messages: 0, promptAsync: 0, toast: 0 }
const info = createPrefillErrorInfo()
const ctx = {
client: {
session: {
abort: async () => {
counts.abort++
return {}
},
messages: async () => {
counts.messages++
return {
data: [
{
info: {
id: info.id,
role: "assistant",
error: info.error,
},
},
],
}
},
promptAsync: async () => {
counts.promptAsync++
return {}
},
},
tui: {
showToast: async () => {
counts.toast++
return {}
},
},
},
directory: "/tmp/session-recovery-dedupe-test",
}
return { ctx, counts, info }
}
describe("session-recovery hook persistent dedupe", () => {
test("#given the same recoverable session.error fires twice for the same assistant message id #when handleSessionRecovery is called twice in sequence #then recovery side effects run only once", async () => {
// given
const { ctx, counts, info } = createCountingCtx()
const hook = createSessionRecoveryHook(ctx as never)
// when
await hook.handleSessionRecovery(info)
await hook.handleSessionRecovery(info)
// then
expect(counts.abort).toBe(1)
expect(counts.toast).toBe(1)
expect(counts.promptAsync).toBe(0)
})
test("#given a recovered assistant message id is later reused by a stale duplicate session.error #when handleSessionRecovery is called for that stale duplicate #then recovery is suppressed", async () => {
// given
const { ctx, counts, info } = createCountingCtx()
const hook = createSessionRecoveryHook(ctx as never)
// when
await hook.handleSessionRecovery(info)
await Promise.resolve()
const result = await hook.handleSessionRecovery(info)
// then
expect(result).toBe(false)
expect(counts.abort).toBe(1)
})
})
+7 -2
View File
@@ -151,8 +151,13 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
log("[session-recovery] Recovery failed:", err)
return false
} finally {
processingErrors.delete(assistantMsgID)
// 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 (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}