fix(hooks): fix TODO continuation abort handling with timer-based approach

Replace blocking await with non-blocking timer scheduling to handle race
condition between session.idle and session.error events. When ESC abort
occurs, session.error immediately cancels the pending timer, preventing
unwanted continuation prompts.

Changes:
- Add pendingTimers Map to track scheduled continuation checks
- Cancel timer on session.error (especially abort cases)
- Cancel timer on message.updated and session.deleted for cleanup
- Reduce delay to 200ms for faster response
- Maintain existing Set-based flag logic for compatibility

This fixes the issue where ESC abort would not prevent continuation
prompts due to event ordering (idle before error)
This commit is contained in:
YeonGyu-Kim
2025-12-10 11:07:24 +09:00
parent 4f019f8fe5
commit 8102d178cb

View File

@@ -36,6 +36,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
@@ -47,6 +48,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
if (detectInterrupt(props?.error)) {
interruptedSessions.add(sessionID)
}
// Cancel pending continuation if error occurs
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
}
return
}
@@ -55,8 +63,15 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
// Wait for potential session.error events to be processed first
await new Promise(resolve => setTimeout(resolve, 150))
// Cancel any existing timer to debounce
const existingTimer = pendingTimers.get(sessionID)
if (existingTimer) {
clearTimeout(existingTimer)
}
// Schedule continuation check
const timer = setTimeout(async () => {
pendingTimers.delete(sessionID)
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
@@ -95,7 +110,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.add(sessionID)
// Re-check if abort occurred during the delay
// Re-check if abort occurred during the delay/fetch
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
@@ -117,6 +132,9 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
} catch {
remindedSessions.delete(sessionID)
}
}, 200)
pendingTimers.set(sessionID, timer)
}
if (event.type === "message.updated") {
@@ -124,6 +142,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "user") {
remindedSessions.delete(sessionID)
// Cancel pending continuation on user interaction
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
}
}
@@ -133,6 +158,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id)
// Cancel pending continuation
const timer = pendingTimers.get(sessionInfo.id)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionInfo.id)
}
}
}
}