fix(todo-continuation-enforcer): add 500ms grace period to prevent false countdown cancellation (#424)

This commit is contained in:
Jeon Suyeol
2026-01-03 02:22:55 +09:00
committed by GitHub
parent fa204d8af0
commit 8b9913345b
2 changed files with 41 additions and 3 deletions

View File

@@ -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"

View File

@@ -33,6 +33,7 @@ interface SessionState {
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
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 })
}