From 4b5e38f8f875b2c20651eadf5a65afacf7d1cc65 Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Sat, 31 Jan 2026 15:24:27 +0900 Subject: [PATCH] fix(hooks): make /stop-continuation one-time only and respect in session recovery - Clear stop state when user sends new message (chat.message handler) - Add isContinuationStopped check to session error recovery block - Continuation resumes automatically after user interaction --- .../stop-continuation-guard/index.test.ts | 38 +++++++++++++++++++ src/hooks/stop-continuation-guard/index.ts | 13 +++++++ src/index.ts | 8 +++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/hooks/stop-continuation-guard/index.test.ts b/src/hooks/stop-continuation-guard/index.test.ts index 028d2cb60..b3c6fab4c 100644 --- a/src/hooks/stop-continuation-guard/index.test.ts +++ b/src/hooks/stop-continuation-guard/index.test.ts @@ -103,4 +103,42 @@ describe("stop-continuation-guard", () => { expect(guard.isStopped(session1)).toBe(true) expect(guard.isStopped(session2)).toBe(false) }) + + test("should clear stopped state on new user message (chat.message)", async () => { + // #given - a session that was stopped + const guard = createStopContinuationGuardHook(createMockPluginInput()) + const sessionID = "test-session-4" + guard.stop(sessionID) + expect(guard.isStopped(sessionID)).toBe(true) + + // #when - user sends a new message + await guard["chat.message"]({ sessionID }) + + // #then - stop state should be cleared (one-time only) + expect(guard.isStopped(sessionID)).toBe(false) + }) + + test("should not affect non-stopped sessions on chat.message", async () => { + // #given - a session that was never stopped + const guard = createStopContinuationGuardHook(createMockPluginInput()) + const sessionID = "test-session-5" + + // #when - user sends a message (session was never stopped) + await guard["chat.message"]({ sessionID }) + + // #then - should not throw and session remains not stopped + expect(guard.isStopped(sessionID)).toBe(false) + }) + + test("should handle undefined sessionID in chat.message", async () => { + // #given - a guard with a stopped session + const guard = createStopContinuationGuardHook(createMockPluginInput()) + guard.stop("some-session") + + // #when - chat.message is called without sessionID + await guard["chat.message"]({ sessionID: undefined }) + + // #then - should not throw and stopped session remains stopped + expect(guard.isStopped("some-session")).toBe(true) + }) }) diff --git a/src/hooks/stop-continuation-guard/index.ts b/src/hooks/stop-continuation-guard/index.ts index e08e9969c..37ac304fd 100644 --- a/src/hooks/stop-continuation-guard/index.ts +++ b/src/hooks/stop-continuation-guard/index.ts @@ -5,6 +5,7 @@ const HOOK_NAME = "stop-continuation-guard" export interface StopContinuationGuard { event: (input: { event: { type: string; properties?: unknown } }) => Promise + "chat.message": (input: { sessionID?: string }) => Promise stop: (sessionID: string) => void isStopped: (sessionID: string) => boolean clear: (sessionID: string) => void @@ -45,8 +46,20 @@ export function createStopContinuationGuardHook( } } + const chatMessage = async ({ + sessionID, + }: { + sessionID?: string + }): Promise => { + if (sessionID && stoppedSessions.has(sessionID)) { + clear(sessionID) + log(`[${HOOK_NAME}] Cleared stop state on new user message`, { sessionID }) + } + } + return { event, + "chat.message": chatMessage, stop, isStopped, clear, diff --git a/src/index.ts b/src/index.ts index 1e1d20836..d0cd1819a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -429,6 +429,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { } } + await stopContinuationGuard?.["chat.message"]?.(input); await keywordDetector?.["chat.message"]?.(input, output); await claudeCodeHooks["chat.message"]?.(input, output); await autoSlashCommand?.["chat.message"]?.(input, output); @@ -591,7 +592,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const recovered = await sessionRecovery.handleSessionRecovery(messageInfo); - if (recovered && sessionID && sessionID === getMainSessionID()) { + if ( + recovered && + sessionID && + sessionID === getMainSessionID() && + !stopContinuationGuard?.isStopped(sessionID) + ) { await ctx.client.session .prompt({ path: { id: sessionID },