From 55312cc4b6564ebd251ef02006753f64af5d3587 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:17:36 +0900 Subject: [PATCH] fix(session-recovery): preflight idle recovery fanout --- src/hooks/session-recovery/hook.test.ts | 52 ++++++++++ src/hooks/session-recovery/hook.ts | 6 +- src/plugin/event.test.ts | 122 ++++++++++++++++++++++++ src/plugin/event.ts | 40 ++++---- 4 files changed, 200 insertions(+), 20 deletions(-) diff --git a/src/hooks/session-recovery/hook.test.ts b/src/hooks/session-recovery/hook.test.ts index 71c66e68d..89fa03d98 100644 --- a/src/hooks/session-recovery/hook.test.ts +++ b/src/hooks/session-recovery/hook.test.ts @@ -210,4 +210,56 @@ describe("session-recovery hook interrupted idle recovery", () => { // then expect(result).toBe(false) }) + + test("#given a newer user turn follows an unfinished assistant turn #when idle recovery runs #then it does not recover the stale assistant", async () => { + // given + const promptAsyncCalls: PromptAsyncCall[] = [] + const ctx = { + client: { + session: { + messages: async () => ({ + data: [ + { + info: { + id: "msg_stale_assistant", + role: "assistant", + sessionID: "ses_stale_after_user", + finish: "tool-calls", + }, + parts: [ + { + type: "tool_use", + id: "toolu_stale_pending", + name: "bash", + input: {}, + state: { status: "pending" }, + }, + ], + }, + { + info: { + id: "msg_newer_user", + role: "user", + }, + parts: [{ type: "text", text: "new prompt after interrupted turn" }], + }, + ], + }), + promptAsync: async (call: PromptAsyncCall) => { + promptAsyncCalls.push(call) + return {} + }, + }, + }, + directory: "/tmp/session-recovery-newer-user-test", + } + const hook = createSessionRecoveryHook(ctx as never) + + // when + const result = await hook.handleInterruptedToolResultsOnIdle("ses_stale_after_user") + + // then + expect(result).toBe(false) + expect(promptAsyncCalls).toHaveLength(0) + }) }) diff --git a/src/hooks/session-recovery/hook.ts b/src/hooks/session-recovery/hook.ts index 89f2b4302..9c9c69c05 100644 --- a/src/hooks/session-recovery/hook.ts +++ b/src/hooks/session-recovery/hook.ts @@ -95,7 +95,11 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec const findLatestAssistantMessage = (messages: MessageData[]): MessageData | undefined => { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] - if (message?.info?.role === "assistant") { + const role = message?.info?.role + if (role === "user") { + return undefined + } + if (role === "assistant") { return message } } diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index d5fd0c642..55a88ffa5 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -425,6 +425,11 @@ describe("createEventHandler - idle deduplication", () => { return true }, }, + backgroundNotificationHook: { + event: async () => { + callOrder.push("backgroundNotificationHook") + }, + }, todoContinuationEnforcer: { handler: async (input: EventInput) => { if (input.event.type === "session.idle") { @@ -448,6 +453,123 @@ describe("createEventHandler - idle deduplication", () => { expect(callOrder).toEqual(["sessionRecovery"]) }) + it("#given idle recovery handles a real idle #when another real idle arrives immediately #then dedup state does not suppress the later idle", async () => { + //#given + const originalDateNow = Date.now + Date.now = () => 40_000 + const dispatchCalls: EventInput[] = [] + let recoveryCalls = 0 + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({ directory: "/tmp" }), + pluginConfig: asPluginConfig({}), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers(), + hooks: createEventHandlerHooks({ + sessionRecovery: { + handleInterruptedToolResultsOnIdle: async () => { + recoveryCalls += 1 + return recoveryCalls === 1 + }, + }, + autoUpdateChecker: { + event: async (input: EventInput) => { + if (input.event.type === "session.idle") { + dispatchCalls.push(input) + } + }, + }, + }), + }) + + try { + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { sessionID: "ses_recovered_then_real" }, + }, + })) + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { sessionID: "ses_recovered_then_real" }, + }, + })) + + //#then + expect(recoveryCalls).toBe(2) + expect(dispatchCalls).toHaveLength(1) + expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe( + "ses_recovered_then_real", + ) + } finally { + Date.now = originalDateNow + } + }) + + it("#given idle recovery handles a real idle #when a synthetic idle arrives immediately #then dedup state does not suppress the synthetic idle", async () => { + //#given + const originalDateNow = Date.now + Date.now = () => 50_000 + const dispatchCalls: EventInput[] = [] + let recoveryCalls = 0 + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({ directory: "/tmp" }), + pluginConfig: asPluginConfig({}), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers(), + hooks: createEventHandlerHooks({ + sessionRecovery: { + handleInterruptedToolResultsOnIdle: async () => { + recoveryCalls += 1 + return recoveryCalls === 1 + }, + }, + autoUpdateChecker: { + event: async (input: EventInput) => { + if (input.event.type === "session.idle") { + dispatchCalls.push(input) + } + }, + }, + }), + }) + + try { + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { sessionID: "ses_recovered_then_synthetic" }, + }, + })) + await eventHandler(asEventHandlerInput({ + event: { + type: "session.status", + properties: { + sessionID: "ses_recovered_then_synthetic", + status: { type: "idle" }, + }, + }, + })) + + //#then + expect(recoveryCalls).toBe(2) + expect(dispatchCalls).toHaveLength(1) + expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe( + "ses_recovered_then_synthetic", + ) + } finally { + Date.now = originalDateNow + } + }) + it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => { //#given const originalDateNow = Date.now diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 6720b1bd6..6c5cd1254 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -562,6 +562,7 @@ export function createEventHandler(args: { now: Date.now(), dedupWindowMs: DEDUP_WINDOW_MS, }); + const syntheticIdle = normalizeSessionStatusToIdle(input); if (input.event.type === "session.idle") { const sessionID = getEventSessionID(input); @@ -577,15 +578,20 @@ export function createEventHandler(args: { recentAnyIdles.delete(sessionID); } } + } + const recovered = await recoverInterruptedToolResultsOnIdleEvent(input); + if (recovered) { + return; + } + if (sessionID) { + const now = Date.now(); recentRealIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) { return; } } - } - - if (input.event.type === "session.idle") { - const recovered = await recoverInterruptedToolResultsOnIdleEvent(input); + } else if (syntheticIdle) { + const recovered = await recoverInterruptedToolResultsOnIdleEvent(syntheticIdle as EventInput); if (recovered) { return; } @@ -593,7 +599,6 @@ export function createEventHandler(args: { await dispatchToHooks(input); - const syntheticIdle = normalizeSessionStatusToIdle(input); if (syntheticIdle) { const sessionID = (syntheticIdle.event.properties as Record)?.sessionID as string; const now = Date.now(); @@ -606,20 +611,17 @@ export function createEventHandler(args: { if (!shouldDispatchIdleEvent(sessionID, now)) { return; } - const recovered = await recoverInterruptedToolResultsOnIdleEvent(syntheticIdle as EventInput); - if (!recovered) { - await dispatchToHooks(syntheticIdle as EventInput); - if (pluginConfig.openclaw) { - await dispatchOpenClawEvent({ - config: pluginConfig.openclaw, - rawEvent: "session.idle", - context: { - sessionId: sessionID, - projectPath: pluginContext.directory, - tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, - }, - }); - } + await dispatchToHooks(syntheticIdle as EventInput); + if (pluginConfig.openclaw) { + await dispatchOpenClawEvent({ + config: pluginConfig.openclaw, + rawEvent: "session.idle", + context: { + sessionId: sessionID, + projectPath: pluginContext.directory, + tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, + }, + }); } }