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
@@ -196,7 +196,7 @@ describe("createCompactionContextInjector recovery", () => {
expect(recoveryPart?.metadata).toEqual({ compaction_continue: true })
})
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
it("does not immediately retry recovery when the recovered prompt config still mismatches expected model or tools", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const mismatchResponse = [
@@ -247,7 +247,61 @@ describe("createCompactionContextInjector recovery", () => {
})
//#then
expect(promptAsyncRecorder.calls.length).toBe(2)
expect(promptAsyncRecorder.calls.length).toBe(1)
})
it("#given post-dispatch config read is stale #when a second compaction event arrives immediately #then recovery prompt is not duplicated", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const checkpointedPromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
]
const incompletePromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
]
const ctx = createMockContext(
[
checkpointedPromptConfig,
incompletePromptConfig,
incompletePromptConfig,
incompletePromptConfig,
incompletePromptConfig,
incompletePromptConfig,
],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_stale_recovery_read")
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID: "ses_stale_recovery_read" },
},
})
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID: "ses_stale_recovery_read" },
},
})
//#then
expect(promptAsyncRecorder.calls.length).toBe(1)
})
it("does not treat reasoning-only assistant messages as a no-text tail", async () => {
@@ -21,7 +21,7 @@ import {
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
import type { CompactionContextClient } from "./types"
import type { TailMonitorState } from "./tail-monitor"
import { dispatchInternalPrompt, releasePromptAsyncReservation } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
export function createRecoveryLogic(
ctx: CompactionContextClient | undefined,
@@ -107,6 +107,7 @@ export function createRecoveryLogic(
})
return false
}
tailState.lastRecoveryAt = now
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
@@ -118,9 +119,6 @@ export function createRecoveryLogic(
hasTools: !!tools,
recoveredPromptConfig,
})
releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery", {
reservedBy: "compaction-context-injector",
})
return false
}
@@ -132,7 +130,6 @@ export function createRecoveryLogic(
setSessionTools(sessionID, tools)
}
tailState.lastRecoveryAt = now
tailState.consecutiveNoTextMessages = 0
log(`[compaction-context-injector] Re-injected checkpointed agent config`, {