/// 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) }) })