///
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "."
import {
CONTINUATION_COOLDOWN_MS,
FAILURE_RESET_WINDOW_MS,
MAX_CONSECUTIVE_FAILURES,
MAX_STAGNATION_COUNT,
} from "./constants"
type TimerCallback = (...args: any[]) => void
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise
advanceClockBy: (ms: number) => Promise
restore: () => void
}
function createFakeTimers(): FakeTimers {
const FAKE_MIN_DELAY_MS = 500
const REAL_MAX_DELAY_MS = 5000
const originalNow = Date.now()
let clockNow = originalNow
let timerNow = 0
let nextId = 1
const timers = new Map()
const cleared = new Set()
const original = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
dateNow: Date.now,
}
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
const flushMicrotasks = async (iterations: number = 5) => {
for (let index = 0; index < iterations; index++) {
await Promise.resolve()
}
}
const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: timerNow + normalizeDelay(delay),
interval,
callback,
args,
})
return id
}
const clear = (id: number | undefined) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const normalized = normalizeDelay(delay)
if (normalized < FAKE_MIN_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
if (normalized >= REAL_MAX_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
return schedule(callback, normalized, null, args) as unknown as ReturnType
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
if (interval < FAKE_MIN_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
if (interval >= REAL_MAX_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
return schedule(callback, interval, interval, args) as unknown as ReturnType
}) as typeof setInterval
globalThis.clearTimeout = ((id?: Parameters[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearTimeout(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: Parameters[0]) => {
if (typeof id === "number" && timers.has(id)) {
clear(id)
return
}
original.clearInterval(id)
}) as typeof clearInterval
Date.now = () => clockNow
const advanceBy = async (ms: number, advanceClock: boolean = false) => {
const clamped = Math.max(0, ms)
const target = timerNow + clamped
if (advanceClock) {
clockNow += clamped
}
while (true) {
let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined
for (const timer of timers.values()) {
if (timer.time <= target && (!next || timer.time < next.time)) {
next = timer
}
}
if (!next) break
timerNow = next.time
timers.delete(next.id)
next.callback(...next.args)
if (next.interval !== null && !cleared.has(next.id)) {
timers.set(next.id, {
id: next.id,
time: timerNow + next.interval,
interval: next.interval,
callback: next.callback,
args: next.args,
})
} else {
cleared.delete(next.id)
}
await flushMicrotasks()
}
timerNow = target
await flushMicrotasks()
}
const advanceClockBy = async (ms: number) => {
const clamped = Math.max(0, ms)
clockNow += clamped
await flushMicrotasks()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
globalThis.setInterval = original.setInterval
globalThis.clearInterval = original.clearInterval
Date.now = original.dateNow
}
return { advanceBy, advanceClockBy, restore }
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }>
let fakeTimers: FakeTimers
interface MockMessage {
info: {
id: string
role: "user" | "assistant"
error?: { name: string; data?: { message: string } }
}
}
interface PromptRequestOptions {
path: { id: string }
body: {
agent?: string
model?: { providerID?: string; modelID?: string }
parts: Array<{ text: string }>
}
}
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
return {
client: {
session: {
todo: async () => ({ data: [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
]}),
messages: async () => ({ data: mockMessages }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: {
showToast: async (opts: any) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
})
return {}
},
},
},
directory: "/tmp/test",
} as any
}
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
return {
getTasksByParentSession: () => runningTasks
? [{ status: "running" }]
: [],
} as any
}
beforeEach(() => {
fakeTimers = createFakeTimers()
_resetForTesting()
promptCalls = []
toastCalls = []
mockMessages = []
})
afterEach(() => {
fakeTimers.restore()
_resetForTesting()
})
test("should inject continuation when idle with incomplete todos", async () => {
fakeTimers.restore()
// given - main session with incomplete todos
const sessionID = "main-123"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(false),
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// then - countdown toast shown
await wait(50)
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Todo Continuation")
// then - after countdown, continuation injected
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
}, { timeout: 15000 })
test("should not inject when all todos are complete", async () => {
// given - session with all todos complete
const sessionID = "main-456"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
]})
const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject when remaining todos are blocked or deleted", async () => {
// given - session where non-completed todos are only blocked/deleted
const sessionID = "main-blocked-deleted"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [
{ id: "1", content: "Blocked task", status: "blocked", priority: "high" },
{ id: "2", content: "Deleted task", status: "deleted", priority: "medium" },
{ id: "3", content: "Done task", status: "completed", priority: "low" },
]})
const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject when background tasks are running", async () => {
// given - session with running background tasks
const sessionID = "main-789"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(true),
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should inject for any session with incomplete todos", async () => {
fakeTimers.restore()
//#given — any session, not necessarily main session
const otherSession = "other-session"
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when — session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: otherSession } },
})
//#then — continuation injected regardless of session type
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(otherSession)
}, { timeout: 15000 })
test("should inject for background task session (subagent)", async () => {
fakeTimers.restore()
// given - main session set, background task session registered
setMainSession("main-session")
const bgTaskSession = "bg-task-session"
subagentSessions.add(bgTaskSession)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - background task session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: bgTaskSession } },
})
// then - continuation injected for background task session
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
}, { timeout: 15000 })
test("should cancel countdown on user message after grace period", async () => {
// given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when - wait past grace period (500ms), then user sends message
await fakeTimers.advanceBy(600, true)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// then - wait past countdown time and verify no injection (countdown was cancelled)
await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
fakeTimers.restore()
// 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 wait(2500)
expect(promptCalls).toHaveLength(1)
}, { timeout: 15000 })
test("should cancel countdown on assistant activity", async () => {
// given - session starting countdown
const sessionID = "main-assistant"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when - assistant starts responding
await fakeTimers.advanceBy(500)
await hook.handler({
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } }
},
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on tool execution", async () => {
// given - session starting countdown
const sessionID = "main-tool"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when - tool starts executing
await fakeTimers.advanceBy(500)
await hook.handler({
event: { type: "tool.execute.before", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection during recovery mode", async () => {
// given - session in recovery mode
const sessionID = "main-recovery"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - mark as recovering
hook.markRecovering(sessionID)
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should inject after recovery complete", async () => {
fakeTimers.restore()
// given - session was in recovery, now complete
const sessionID = "main-recovery-done"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - mark as recovering then complete
hook.markRecovering(sessionID)
hook.markRecoveryComplete(sessionID)
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(3000)
// then - continuation injected
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should cleanup on session deleted", async () => {
// given - session starting countdown
const sessionID = "main-delete"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when - session is deleted during countdown
await fakeTimers.advanceBy(500)
await hook.handler({
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected (cleaned up)
expect(promptCalls).toHaveLength(0)
})
test("should not inject again when cooldown is active", async () => {
//#given
const sessionID = "main-cooldown-active"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(1)
})
test("should inject again when cooldown expires", async () => {
//#given
const sessionID = "main-cooldown-expired"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(2)
}, { timeout: 15000 })
test("should apply cooldown even after injection failure", async () => {
//#given
const sessionID = "main-failure-cooldown"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(1)
})
test("should stop retries after max consecutive failures", async () => {
//#given
const sessionID = "main-max-consecutive-failures"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
const incompleteCounts = [5, 4, 5, 4, 5, 4]
let todoCallCount = 0
mockInput.client.session.todo = async () => {
const countIndex = Math.min(Math.floor(todoCallCount / 2), incompleteCounts.length - 1)
const incompleteCount = incompleteCounts[countIndex] ?? incompleteCounts[incompleteCounts.length - 1] ?? 1
todoCallCount += 1
return {
data: Array.from({ length: incompleteCount }, (_, index) => ({
id: String(index + 1),
content: `Task ${index + 1}`,
status: "pending",
priority: "high",
})),
}
}
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
await fakeTimers.advanceClockBy(1_000_000)
}
}
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
}, { timeout: 30000 })
test("should not stop retries early for unchanged todos when injections keep failing", async () => {
//#given
const sessionID = "main-unchanged-todos-max-failures"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({
data: [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
],
})
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
await fakeTimers.advanceClockBy(1_000_000)
}
}
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
}, { timeout: 30000 })
test("should resume retries after reset window when max failures reached", async () => {
//#given
const sessionID = "main-recovery-after-max-failures"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
const incompleteCounts = [5, 4, 5, 4, 5, 4, 5]
let todoCallCount = 0
mockInput.client.session.todo = async () => {
const countIndex = Math.min(Math.floor(todoCallCount / 2), incompleteCounts.length - 1)
const incompleteCount = incompleteCounts[countIndex] ?? incompleteCounts[incompleteCounts.length - 1] ?? 1
todoCallCount += 1
return {
data: Array.from({ length: incompleteCount }, (_, index) => ({
id: String(index + 1),
content: `Task ${index + 1}`,
status: "pending",
priority: "high",
})),
}
}
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
await fakeTimers.advanceClockBy(1_000_000)
}
}
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(FAILURE_RESET_WINDOW_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES + 1)
}, { timeout: 30000 })
test("should increase cooldown exponentially after consecutive failures", async () => {
//#given
const sessionID = "main-exponential-backoff"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
throw new Error("simulated auth failure")
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(2)
}, { timeout: 30000 })
test("should reset consecutive failure count after successful injection", async () => {
//#given
const sessionID = "main-reset-consecutive-failures"
setMainSession(sessionID)
let shouldFail = true
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
if (shouldFail) {
shouldFail = false
throw new Error("simulated auth failure")
}
return {}
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 2)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(3)
}, { timeout: 30000 })
test("should stop injecting after max stagnation cycles when todos remain unchanged across cycles", async () => {
//#given
const sessionID = "main-no-stagnation-cap"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
]})
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when — 5 consecutive idle cycles with unchanged todos
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
// then
expect(promptCalls).toHaveLength(MAX_STAGNATION_COUNT)
}, { timeout: 60000 })
test("should skip idle handling while injection is in flight", async () => {
//#given
const sessionID = "main-in-flight"
setMainSession(sessionID)
let resolvePrompt: (() => void) | undefined
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
await new Promise((resolve) => {
resolvePrompt = resolve
})
return {}
}
const hook = createTodoContinuationEnforcer(mockInput, {})
//#when
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2100, true)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000, true)
//#then
expect(promptCalls).toHaveLength(1)
resolvePrompt?.()
await Promise.resolve()
})
test("should clear cooldown state on session deleted", async () => {
//#given
const sessionID = "main-delete-state-reset"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
await hook.handler({
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
})
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(2)
}, { timeout: 15000 })
test("should accept skipAgents option without error", async () => {
// given - session with skipAgents configured for Prometheus
const sessionID = "main-prometheus-option"
setMainSession(sessionID)
// when - create hook with skipAgents option (should not throw)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
skipAgents: ["Prometheus (Planner)", "custom-agent"],
})
// then - handler works without error
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(100)
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
})
test("should show countdown toast updates", async () => {
fakeTimers.restore()
// given - session with incomplete todos
const sessionID = "main-toast"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
await wait(2500)
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
expect(toastCalls[0].message).toContain("2s")
}, { timeout: 15000 })
test("should not have 10s throttle between injections", async () => {
// given - new hook instance (no prior state)
const sessionID = "main-no-throttle"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - first idle cycle completes
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3500, true)
// then - first injection happened
expect(promptCalls.length).toBe(1)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3500, true)
// then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2)
}, { timeout: 15000 })
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
fakeTimers.restore()
// given - session with incomplete todos
const sessionID = "main-noabort-error"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - non-abort error occurs (e.g., network error, API error)
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: { name: "NetworkError", message: "Connection failed" }
}
},
})
// when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (non-abort errors don't block)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
// ============================================================
// API-BASED ABORT DETECTION TESTS
// These tests verify that abort is detected by checking
// the last assistant message's error field via session.messages API
// ============================================================
test("should skip injection when last assistant message has MessageAbortedError", async () => {
// given - session where last assistant message was aborted
const sessionID = "main-api-abort"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "The operation was aborted" } } } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (last message was aborted)
expect(promptCalls).toHaveLength(0)
})
test("should inject when last assistant message has no error", async () => {
fakeTimers.restore()
// given - session where last assistant message completed normally
const sessionID = "main-api-no-error"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (no abort)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should inject when last message is from user (not assistant)", async () => {
fakeTimers.restore()
// given - session where last message is from user
const sessionID = "main-api-user-last"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "assistant" } },
{ info: { id: "msg-2", role: "user" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (last message is user, not aborted assistant)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should skip when last assistant message has any abort-like error", async () => {
// given - session where last assistant message has AbortError (DOMException style)
const sessionID = "main-api-abort-dom"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "AbortError" } } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (abort error detected)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when abort detected via session.error event (event-based, primary)", async () => {
// given - session with incomplete todos
const sessionID = "main-event-abort"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - abort error event fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (abort detected via event)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when AbortError detected via session.error event", async () => {
// given - session with incomplete todos
const sessionID = "main-event-abort-dom"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - AbortError event fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "AbortError" } },
},
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (abort detected via event)
expect(promptCalls).toHaveLength(0)
})
test("should inject when abort flag is stale (>3s old)", async () => {
fakeTimers.restore()
// given - session with incomplete todos and old abort timestamp
const sessionID = "main-stale-abort"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - wait >3s then idle fires
await wait(3100)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(3000)
// then - continuation injected (abort flag is stale)
expect(promptCalls.length).toBeGreaterThan(0)
}, { timeout: 15000 })
test("should clear abort flag on user message activity", async () => {
fakeTimers.restore()
// given - session with abort detected
const sessionID = "main-clear-on-user"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - user sends new message (clears abort flag)
await wait(600)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } },
},
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (abort flag was cleared by user activity)
expect(promptCalls.length).toBeGreaterThan(0)
}, { timeout: 15000 })
test("should clear abort flag on assistant message activity", async () => {
fakeTimers.restore()
// given - session with abort detected
const sessionID = "main-clear-on-assistant"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - assistant starts responding (clears abort flag)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "assistant" } },
},
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (abort flag was cleared by assistant activity)
expect(promptCalls.length).toBeGreaterThan(0)
}, { timeout: 15000 })
test("should clear abort flag on tool execution", async () => {
fakeTimers.restore()
// given - session with abort detected
const sessionID = "main-clear-on-tool"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - tool executes (clears abort flag)
await hook.handler({
event: {
type: "tool.execute.before",
properties: { sessionID },
},
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (abort flag was cleared by tool execution)
expect(promptCalls.length).toBeGreaterThan(0)
}, { timeout: 15000 })
test("should use event-based detection even when API indicates no abort (event wins)", async () => {
// given - session with abort event but API shows no error
const sessionID = "main-event-wins"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - abort error event fires (but API doesn't have it yet)
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { name: "MessageAbortedError" } },
},
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (event-based detection wins over API)
expect(promptCalls).toHaveLength(0)
})
test("should use API fallback when event is missed but API shows abort", async () => {
// given - session where event was missed but API shows abort
const sessionID = "main-api-fallback"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle without prior session.error event
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (API fallback detected the abort)
expect(promptCalls).toHaveLength(0)
})
test("should pass model property in prompt call (undefined when no message context)", async () => {
fakeTimers.restore()
// given - session with incomplete todos, no prior message context available
const sessionID = "main-model-preserve"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(false),
})
// when - session goes idle and continuation is injected
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - prompt call made, model is undefined when no context (expected behavior)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
expect("model" in promptCalls[0]).toBe(true)
}, { timeout: 15000 })
test("should extract model from assistant message with flat modelID/providerID", async () => {
// given - session with assistant message that has flat modelID/providerID (OpenCode API format)
const sessionID = "main-assistant-model"
setMainSession(sessionID)
// OpenCode returns assistant messages with flat modelID/providerID, not nested model object
const mockMessagesWithAssistant = [
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-5.4" } } },
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus", modelID: "gpt-5.4", providerID: "openai" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesWithAssistant }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500)
// then - model should be extracted from assistant message's flat modelID/providerID
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
})
// ============================================================
// COMPACTION AGENT FILTERING TESTS
// These tests verify that compaction agent messages are filtered
// when resolving agent info, preventing infinite continuation loops
// ============================================================
test("should skip compaction agent messages when resolving agent info", async () => {
// given - session where last message is from compaction agent but previous was Sisyphus
const sessionID = "main-compaction-filter"
setMainSession(sessionID)
const mockMessagesWithCompaction = [
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" } } },
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{ info: { id: "msg-3", role: "assistant", agent: "compaction", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesWithCompaction }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500)
// then - continuation uses Sisyphus (skipped compaction agent)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].agent).toBe("sisyphus")
})
test("should skip injection when only compaction agent messages exist", async () => {
// given - session with only compaction agent (post-compaction, no prior agent info)
const sessionID = "main-only-compaction"
setMainSession(sessionID)
const mockMessagesOnlyCompaction = [
{ info: { id: "msg-1", role: "assistant", agent: "compaction" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesOnlyCompaction }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (compaction is in default skipAgents)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when prometheus agent is after compaction", async () => {
// given - prometheus session that was compacted
const sessionID = "main-prometheus-compacted"
setMainSession(sessionID)
const mockMessagesPrometheusCompacted = [
{ info: { id: "msg-1", role: "user", agent: "prometheus" } },
{ info: { id: "msg-2", role: "assistant", agent: "prometheus" } },
{ info: { id: "msg-3", role: "assistant", agent: "compaction" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesPrometheusCompacted }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
expect(promptCalls).toHaveLength(0)
})
test("should inject when agent info is undefined but skipAgents is empty", async () => {
fakeTimers.restore()
// given - session with no agent info but skipAgents is empty
const sessionID = "main-no-agent-no-skip"
setMainSession(sessionID)
const mockMessagesNoAgent = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesNoAgent }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {
skipAgents: [],
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (no agents to skip)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should not inject when isContinuationStopped returns true", async () => {
// given - session with continuation stopped
const sessionID = "main-stopped"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
isContinuationStopped: (id) => id === sessionID,
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected (stopped flag is true)
expect(promptCalls).toHaveLength(0)
})
test("should not inject when isContinuationStopped becomes true during countdown", async () => {
// given - session where continuation is not stopped at idle time but stops during countdown
const sessionID = "main-race-condition"
setMainSession(sessionID)
let stopped = false
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
isContinuationStopped: () => stopped,
})
// when - session goes idle with continuation not yet stopped
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when - stop-continuation fires during the 2s countdown window
stopped = true
// when - countdown elapses and injectContinuation fires
await fakeTimers.advanceBy(3000)
// then - no injection because isContinuationStopped became true before injectContinuation ran
expect(promptCalls).toHaveLength(0)
})
test("should inject when isContinuationStopped returns false", async () => {
fakeTimers.restore()
// given - session with continuation not stopped
const sessionID = "main-not-stopped"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
isContinuationStopped: () => false,
})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await wait(2500)
// then - continuation injected (stopped flag is false)
expect(promptCalls.length).toBe(1)
}, { timeout: 15000 })
test("should cancel all countdowns via cancelAllCountdowns", async () => {
// given - multiple sessions with running countdowns
const session1 = "main-cancel-all-1"
setMainSession(session1)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - first session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: session1 } },
})
await fakeTimers.advanceBy(500)
// when - cancel all countdowns
hook.cancelAllCountdowns()
// when - advance past countdown time
await fakeTimers.advanceBy(3000)
// then - no continuation injected (all countdowns cancelled)
expect(promptCalls).toHaveLength(0)
})
})