Skip to content

Commit 3920f84

Browse files
sisyphus-dev-aikdcokenny
authored andcommitted
fix(todo-continuation): implement hybrid abort detection
- Add event-based abort detection as primary method - Keep API-based detection as fallback - Track abort events via session.error with 3s time window - Clear abort flag on user/assistant activity and tool execution - Add comprehensive tests for hybrid approach (8 new test cases) - All 663 tests pass Fixes #577
1 parent 42de7c3 commit 3920f84

File tree

2 files changed

+288
-1
lines changed

2 files changed

+288
-1
lines changed

src/hooks/todo-continuation-enforcer.test.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,4 +548,263 @@ describe("todo-continuation-enforcer", () => {
548548
// #then - no continuation (abort error detected)
549549
expect(promptCalls).toHaveLength(0)
550550
})
551+
552+
test("should skip injection when abort detected via session.error event (event-based, primary)", async () => {
553+
// #given - session with incomplete todos
554+
const sessionID = "main-event-abort"
555+
setMainSession(sessionID)
556+
mockMessages = [
557+
{ info: { id: "msg-1", role: "user" } },
558+
{ info: { id: "msg-2", role: "assistant" } },
559+
]
560+
561+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
562+
563+
// #when - abort error event fires
564+
await hook.handler({
565+
event: {
566+
type: "session.error",
567+
properties: { sessionID, error: { name: "MessageAbortedError" } },
568+
},
569+
})
570+
571+
// #when - session goes idle immediately after
572+
await hook.handler({
573+
event: { type: "session.idle", properties: { sessionID } },
574+
})
575+
576+
await new Promise(r => setTimeout(r, 3000))
577+
578+
// #then - no continuation (abort detected via event)
579+
expect(promptCalls).toHaveLength(0)
580+
})
581+
582+
test("should skip injection when AbortError detected via session.error event", async () => {
583+
// #given - session with incomplete todos
584+
const sessionID = "main-event-abort-dom"
585+
setMainSession(sessionID)
586+
mockMessages = [
587+
{ info: { id: "msg-1", role: "user" } },
588+
{ info: { id: "msg-2", role: "assistant" } },
589+
]
590+
591+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
592+
593+
// #when - AbortError event fires
594+
await hook.handler({
595+
event: {
596+
type: "session.error",
597+
properties: { sessionID, error: { name: "AbortError" } },
598+
},
599+
})
600+
601+
// #when - session goes idle
602+
await hook.handler({
603+
event: { type: "session.idle", properties: { sessionID } },
604+
})
605+
606+
await new Promise(r => setTimeout(r, 3000))
607+
608+
// #then - no continuation (abort detected via event)
609+
expect(promptCalls).toHaveLength(0)
610+
})
611+
612+
test("should inject when abort flag is stale (>3s old)", async () => {
613+
// #given - session with incomplete todos and old abort timestamp
614+
const sessionID = "main-stale-abort"
615+
setMainSession(sessionID)
616+
mockMessages = [
617+
{ info: { id: "msg-1", role: "user" } },
618+
{ info: { id: "msg-2", role: "assistant" } },
619+
]
620+
621+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
622+
623+
// #when - abort error fires
624+
await hook.handler({
625+
event: {
626+
type: "session.error",
627+
properties: { sessionID, error: { name: "MessageAbortedError" } },
628+
},
629+
})
630+
631+
// #when - wait >3s then idle fires
632+
await new Promise(r => setTimeout(r, 3100))
633+
634+
await hook.handler({
635+
event: { type: "session.idle", properties: { sessionID } },
636+
})
637+
638+
await new Promise(r => setTimeout(r, 3000))
639+
640+
// #then - continuation injected (abort flag is stale)
641+
expect(promptCalls.length).toBeGreaterThan(0)
642+
}, 10000)
643+
644+
test("should clear abort flag on user message activity", async () => {
645+
// #given - session with abort detected
646+
const sessionID = "main-clear-on-user"
647+
setMainSession(sessionID)
648+
mockMessages = [
649+
{ info: { id: "msg-1", role: "user" } },
650+
{ info: { id: "msg-2", role: "assistant" } },
651+
]
652+
653+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
654+
655+
// #when - abort error fires
656+
await hook.handler({
657+
event: {
658+
type: "session.error",
659+
properties: { sessionID, error: { name: "MessageAbortedError" } },
660+
},
661+
})
662+
663+
// #when - user sends new message (clears abort flag)
664+
await new Promise(r => setTimeout(r, 600))
665+
await hook.handler({
666+
event: {
667+
type: "message.updated",
668+
properties: { info: { sessionID, role: "user" } },
669+
},
670+
})
671+
672+
// #when - session goes idle
673+
await hook.handler({
674+
event: { type: "session.idle", properties: { sessionID } },
675+
})
676+
677+
await new Promise(r => setTimeout(r, 3000))
678+
679+
// #then - continuation injected (abort flag was cleared by user activity)
680+
expect(promptCalls.length).toBeGreaterThan(0)
681+
})
682+
683+
test("should clear abort flag on assistant message activity", async () => {
684+
// #given - session with abort detected
685+
const sessionID = "main-clear-on-assistant"
686+
setMainSession(sessionID)
687+
mockMessages = [
688+
{ info: { id: "msg-1", role: "user" } },
689+
{ info: { id: "msg-2", role: "assistant" } },
690+
]
691+
692+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
693+
694+
// #when - abort error fires
695+
await hook.handler({
696+
event: {
697+
type: "session.error",
698+
properties: { sessionID, error: { name: "MessageAbortedError" } },
699+
},
700+
})
701+
702+
// #when - assistant starts responding (clears abort flag)
703+
await hook.handler({
704+
event: {
705+
type: "message.updated",
706+
properties: { info: { sessionID, role: "assistant" } },
707+
},
708+
})
709+
710+
// #when - session goes idle
711+
await hook.handler({
712+
event: { type: "session.idle", properties: { sessionID } },
713+
})
714+
715+
await new Promise(r => setTimeout(r, 3000))
716+
717+
// #then - continuation injected (abort flag was cleared by assistant activity)
718+
expect(promptCalls.length).toBeGreaterThan(0)
719+
})
720+
721+
test("should clear abort flag on tool execution", async () => {
722+
// #given - session with abort detected
723+
const sessionID = "main-clear-on-tool"
724+
setMainSession(sessionID)
725+
mockMessages = [
726+
{ info: { id: "msg-1", role: "user" } },
727+
{ info: { id: "msg-2", role: "assistant" } },
728+
]
729+
730+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
731+
732+
// #when - abort error fires
733+
await hook.handler({
734+
event: {
735+
type: "session.error",
736+
properties: { sessionID, error: { name: "MessageAbortedError" } },
737+
},
738+
})
739+
740+
// #when - tool executes (clears abort flag)
741+
await hook.handler({
742+
event: {
743+
type: "tool.execute.before",
744+
properties: { sessionID },
745+
},
746+
})
747+
748+
// #when - session goes idle
749+
await hook.handler({
750+
event: { type: "session.idle", properties: { sessionID } },
751+
})
752+
753+
await new Promise(r => setTimeout(r, 3000))
754+
755+
// #then - continuation injected (abort flag was cleared by tool execution)
756+
expect(promptCalls.length).toBeGreaterThan(0)
757+
})
758+
759+
test("should use event-based detection even when API indicates no abort (event wins)", async () => {
760+
// #given - session with abort event but API shows no error
761+
const sessionID = "main-event-wins"
762+
setMainSession(sessionID)
763+
mockMessages = [
764+
{ info: { id: "msg-1", role: "user" } },
765+
{ info: { id: "msg-2", role: "assistant" } },
766+
]
767+
768+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
769+
770+
// #when - abort error event fires (but API doesn't have it yet)
771+
await hook.handler({
772+
event: {
773+
type: "session.error",
774+
properties: { sessionID, error: { name: "MessageAbortedError" } },
775+
},
776+
})
777+
778+
// #when - session goes idle
779+
await hook.handler({
780+
event: { type: "session.idle", properties: { sessionID } },
781+
})
782+
783+
await new Promise(r => setTimeout(r, 3000))
784+
785+
// #then - no continuation (event-based detection wins over API)
786+
expect(promptCalls).toHaveLength(0)
787+
})
788+
789+
test("should use API fallback when event is missed but API shows abort", async () => {
790+
// #given - session where event was missed but API shows abort
791+
const sessionID = "main-api-fallback"
792+
setMainSession(sessionID)
793+
mockMessages = [
794+
{ info: { id: "msg-1", role: "user" } },
795+
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } },
796+
]
797+
798+
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
799+
800+
// #when - session goes idle without prior session.error event
801+
await hook.handler({
802+
event: { type: "session.idle", properties: { sessionID } },
803+
})
804+
805+
await new Promise(r => setTimeout(r, 3000))
806+
807+
// #then - no continuation (API fallback detected the abort)
808+
expect(promptCalls).toHaveLength(0)
809+
})
551810
})

src/hooks/todo-continuation-enforcer.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface SessionState {
3636
countdownInterval?: ReturnType<typeof setInterval>
3737
isRecovering?: boolean
3838
countdownStartedAt?: number
39+
abortDetectedAt?: number
3940
}
4041

4142
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
@@ -254,6 +255,13 @@ export function createTodoContinuationEnforcer(
254255
const sessionID = props?.sessionID as string | undefined
255256
if (!sessionID) return
256257

258+
const error = props?.error as { name?: string } | undefined
259+
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
260+
const state = getState(sessionID)
261+
state.abortDetectedAt = Date.now()
262+
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
263+
}
264+
257265
cancelCountdown(sessionID)
258266
log(`[${HOOK_NAME}] session.error`, { sessionID })
259267
return
@@ -281,6 +289,18 @@ export function createTodoContinuationEnforcer(
281289
return
282290
}
283291

292+
// Check 1: Event-based abort detection (primary, most reliable)
293+
if (state.abortDetectedAt) {
294+
const timeSinceAbort = Date.now() - state.abortDetectedAt
295+
const ABORT_WINDOW_MS = 3000
296+
if (timeSinceAbort < ABORT_WINDOW_MS) {
297+
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
298+
state.abortDetectedAt = undefined
299+
return
300+
}
301+
state.abortDetectedAt = undefined
302+
}
303+
284304
const hasRunningBgTasks = backgroundManager
285305
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
286306
: false
@@ -290,6 +310,7 @@ export function createTodoContinuationEnforcer(
290310
return
291311
}
292312

313+
// Check 2: API-based abort detection (fallback, for cases where event was missed)
293314
try {
294315
const messagesResp = await ctx.client.session.messages({
295316
path: { id: sessionID },
@@ -298,7 +319,7 @@ export function createTodoContinuationEnforcer(
298319
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
299320

300321
if (isLastAssistantMessageAborted(messages)) {
301-
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
322+
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
302323
return
303324
}
304325
} catch (err) {
@@ -367,10 +388,13 @@ export function createTodoContinuationEnforcer(
367388
return
368389
}
369390
}
391+
if (state) state.abortDetectedAt = undefined
370392
cancelCountdown(sessionID)
371393
}
372394

373395
if (role === "assistant") {
396+
const state = sessions.get(sessionID)
397+
if (state) state.abortDetectedAt = undefined
374398
cancelCountdown(sessionID)
375399
}
376400
return
@@ -382,6 +406,8 @@ export function createTodoContinuationEnforcer(
382406
const role = info?.role as string | undefined
383407

384408
if (sessionID && role === "assistant") {
409+
const state = sessions.get(sessionID)
410+
if (state) state.abortDetectedAt = undefined
385411
cancelCountdown(sessionID)
386412
}
387413
return
@@ -390,6 +416,8 @@ export function createTodoContinuationEnforcer(
390416
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
391417
const sessionID = props?.sessionID as string | undefined
392418
if (sessionID) {
419+
const state = sessions.get(sessionID)
420+
if (state) state.abortDetectedAt = undefined
393421
cancelCountdown(sessionID)
394422
}
395423
return

0 commit comments

Comments
 (0)