From 55312cc4b6564ebd251ef02006753f64af5d3587 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:17:36 +0900 Subject: [PATCH 1/2] 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, + }, + }); } } From 8bc49775634d915d30a5e9581f613f7b35924538 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:17:42 +0900 Subject: [PATCH 2/2] fix(slash-command): skip already tagged command output --- src/hooks/auto-slash-command/hook.ts | 14 ++++++++++++++ src/hooks/auto-slash-command/index.test.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/hooks/auto-slash-command/hook.ts b/src/hooks/auto-slash-command/hook.ts index 1803394d1..8b2c4a58e 100644 --- a/src/hooks/auto-slash-command/hook.ts +++ b/src/hooks/auto-slash-command/hook.ts @@ -56,6 +56,16 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | return null } +function partsContainAutoSlashCommandTags(parts: Array<{ text?: string }>): boolean { + return parts.some((part) => + typeof part.text === "string" + && ( + part.text.includes(AUTO_SLASH_COMMAND_TAG_OPEN) + || part.text.includes(AUTO_SLASH_COMMAND_TAG_CLOSE) + ) + ) +} + export interface AutoSlashCommandHookOptions { skills?: LoadedSkill[] pluginsEnabled?: boolean @@ -153,6 +163,10 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions input: CommandExecuteBeforeInput, output: CommandExecuteBeforeOutput ): Promise => { + if (partsContainAutoSlashCommandTags(output.parts)) { + return + } + const eventID = getCommandExecutionEventID(input) const commandKey = eventID ? `${input.sessionID}:event:${eventID}` diff --git a/src/hooks/auto-slash-command/index.test.ts b/src/hooks/auto-slash-command/index.test.ts index 56d6dbbed..70e8d81c9 100644 --- a/src/hooks/auto-slash-command/index.test.ts +++ b/src/hooks/auto-slash-command/index.test.ts @@ -355,6 +355,22 @@ describe("createAutoSlashCommandHook", () => { expect(output.parts[0].text).toContain("/ralph-loop Command") }) + it("should not duplicate injection when command output is already tagged", async () => { + //#given + const hook = createAutoSlashCommandHook() + const input = createCommandInput("ralph-loop") + const taggedContent = "\n/ralph-loop Command\n" + const output = createCommandOutput(taggedContent) + + //#when + await hook["command.execute.before"](input, output) + + //#then + expect(output.parts).toHaveLength(1) + expect(output.parts[0]?.text).toBe(taggedContent) + expect(output.parts[0]?.text?.split("").length).toBe(2) + }) + it("should inject template for known builtin commands like ulw-loop", async () => { //#given const hook = createAutoSlashCommandHook()