diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 1f944db59..6797c97c1 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -16,6 +16,7 @@ import { } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" import { hasUnansweredQuestion } from "./pending-question-detection" +import { shouldStopForStagnation } from "./stagnation-detection" import { getIncompleteCount } from "./todo" import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" import type { SessionStateStore } from "./session-state" @@ -93,12 +94,14 @@ export async function handleSessionIdle(args: { } if (!todos || todos.length === 0) { + sessionStateStore.resetContinuationProgress(sessionID) log(`[${HOOK_NAME}] No todos`, { sessionID }) return } const incompleteCount = getIncompleteCount(todos) if (incompleteCount === 0) { + sessionStateStore.resetContinuationProgress(sessionID) log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) return } @@ -183,6 +186,11 @@ export async function handleSessionIdle(args: { return } + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount) + if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { + return + } + startCountdown({ ctx, sessionID, diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index dee3d0bf4..2e41b3bc7 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -8,6 +8,7 @@ import { CONTINUATION_COOLDOWN_MS, FAILURE_RESET_WINDOW_MS, MAX_CONSECUTIVE_FAILURES, + MAX_STAGNATION_COUNT, } from "./constants" type TimerCallback = (...args: any[]) => void @@ -626,6 +627,21 @@ describe("todo-continuation-enforcer", () => { 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, @@ -657,6 +673,21 @@ describe("todo-continuation-enforcer", () => { 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, @@ -753,7 +784,7 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(3) }, { timeout: 30000 }) - test("should keep injecting even when todos remain unchanged across cycles", async () => { + test("should stop injecting after max stagnation cycles when todos remain unchanged across cycles", async () => { //#given const sessionID = "main-no-stagnation-cap" setMainSession(sessionID) @@ -784,8 +815,8 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) - //#then — all 5 injections should fire (no stagnation cap) - expect(promptCalls).toHaveLength(5) + // then + expect(promptCalls).toHaveLength(MAX_STAGNATION_COUNT) }, { timeout: 60000 }) test("should skip idle handling while injection is in flight", async () => {