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 remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>() const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>() const errorSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => { return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
@@ -47,6 +48,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
if (detectInterrupt(props?.error)) { if (detectInterrupt(props?.error)) {
interruptedSessions.add(sessionID) interruptedSessions.add(sessionID)
} }
// Cancel pending continuation if error occurs
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
} }
return return
} }
@@ -55,68 +63,78 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = props?.sessionID as string | undefined const sessionID = props?.sessionID as string | undefined
if (!sessionID) return if (!sessionID) return
// Wait for potential session.error events to be processed first // Cancel any existing timer to debounce
await new Promise(resolve => setTimeout(resolve, 150)) const existingTimer = pendingTimers.get(sessionID)
if (existingTimer) {
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID) clearTimeout(existingTimer)
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
if (shouldBypass) {
return
} }
if (remindedSessions.has(sessionID)) { // Schedule continuation check
return const timer = setTimeout(async () => {
} pendingTimers.delete(sessionID)
let todos: Todo[] = [] const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
try {
const response = await ctx.client.session.todo({ interruptedSessions.delete(sessionID)
path: { id: sessionID }, errorSessions.delete(sessionID)
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
if (!todos || todos.length === 0) { if (shouldBypass) {
return return
} }
const incomplete = todos.filter( if (remindedSessions.has(sessionID)) {
(t) => t.status !== "completed" && t.status !== "cancelled" return
) }
if (incomplete.length === 0) { let todos: Todo[] = []
return try {
} const response = await ctx.client.session.todo({
path: { id: sessionID },
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
remindedSessions.add(sessionID) if (!todos || todos.length === 0) {
return
}
// Re-check if abort occurred during the delay const incomplete = todos.filter(
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) { (t) => t.status !== "completed" && t.status !== "cancelled"
remindedSessions.delete(sessionID) )
return
}
try { if (incomplete.length === 0) {
await ctx.client.session.prompt({ return
path: { id: sessionID }, }
body: {
parts: [ remindedSessions.add(sessionID)
{
type: "text", // Re-check if abort occurred during the delay/fetch
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`, if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
}, remindedSessions.delete(sessionID)
], return
}, }
query: { directory: ctx.directory },
}) try {
} catch { await ctx.client.session.prompt({
remindedSessions.delete(sessionID) path: { id: sessionID },
} body: {
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
},
],
},
query: { directory: ctx.directory },
})
} catch {
remindedSessions.delete(sessionID)
}
}, 200)
pendingTimers.set(sessionID, timer)
} }
if (event.type === "message.updated") { if (event.type === "message.updated") {
@@ -124,6 +142,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = info?.sessionID as string | undefined const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "user") { if (sessionID && info?.role === "user") {
remindedSessions.delete(sessionID) 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) remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id) interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id) errorSessions.delete(sessionInfo.id)
// Cancel pending continuation
const timer = pendingTimers.get(sessionInfo.id)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionInfo.id)
}
} }
} }
} }