diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer.test.ts index 91bf6e3..8594274 100644 --- a/src/hooks/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer.test.ts @@ -216,7 +216,7 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls.length).toBe(1) }) - test("should cancel countdown on user message", async () => { + test("should cancel countdown on user message after grace period", async () => { // #given - session starting countdown const sessionID = "main-cancel" setMainSession(sessionID) @@ -228,7 +228,8 @@ describe("todo-continuation-enforcer", () => { event: { type: "session.idle", properties: { sessionID } }, }) - // #when - user sends message immediately (before 2s countdown) + // #when - wait past grace period (500ms), then user sends message + await new Promise(r => setTimeout(r, 600)) await hook.handler({ event: { type: "message.updated", @@ -236,11 +237,37 @@ describe("todo-continuation-enforcer", () => { }, }) - // #then - wait past countdown time and verify no injection + // #then - wait past countdown time and verify no injection (countdown was cancelled) await new Promise(r => setTimeout(r, 2500)) expect(promptCalls).toHaveLength(0) }) + test("should ignore user message within grace period", async () => { + // #given - session starting countdown + const sessionID = "main-grace" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // #when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // #when - user message arrives within grace period (immediately) + await hook.handler({ + event: { + type: "message.updated", + properties: { info: { sessionID, role: "user" } } + }, + }) + + // #then - countdown should continue (message was ignored) + // wait past 2s countdown and verify injection happens + await new Promise(r => setTimeout(r, 2500)) + expect(promptCalls).toHaveLength(1) + }) + test("should cancel countdown on assistant activity", async () => { // #given - session starting countdown const sessionID = "main-assistant" diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 1812d3f..97f4970 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -33,6 +33,7 @@ interface SessionState { countdownTimer?: ReturnType countdownInterval?: ReturnType isRecovering?: boolean + countdownStartedAt?: number } const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION] @@ -45,6 +46,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending const COUNTDOWN_SECONDS = 2 const TOAST_DURATION_MS = 900 +const COUNTDOWN_GRACE_PERIOD_MS = 500 function getMessageDir(sessionID: string): string | null { if (!existsSync(MESSAGE_STORAGE)) return null @@ -113,6 +115,7 @@ export function createTodoContinuationEnforcer( clearInterval(state.countdownInterval) state.countdownInterval = undefined } + state.countdownStartedAt = undefined } function cleanup(sessionID: string): void { @@ -228,6 +231,7 @@ export function createTodoContinuationEnforcer( let secondsRemaining = COUNTDOWN_SECONDS showCountdownToast(secondsRemaining, incompleteCount) + state.countdownStartedAt = Date.now() state.countdownInterval = setInterval(() => { secondsRemaining-- @@ -334,6 +338,13 @@ export function createTodoContinuationEnforcer( } if (role === "user") { + if (state?.countdownStartedAt) { + const elapsed = Date.now() - state.countdownStartedAt + if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { + log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) + return + } + } cancelCountdown(sessionID) log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID }) }