From 7a3df05e47a340cb5af883129a32b3407e1b1231 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 18:50:53 +0900 Subject: [PATCH] fix(todo-continuation-enforcer): fire continuation for all sessions with incomplete todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove boulder session restriction (f84ef532) and stagnation cap (10a60854) that prevented continuation from firing in regular sessions. Changes: - Remove boulder/subagent session gate in idle-event.ts — continuation now fires for ANY session with incomplete todos, as originally intended - Remove stagnation cap (MAX_UNCHANGED_CYCLES) — agent must keep rolling the boulder until all todos are complete, no giving up after 3 attempts - Remove lastTodoHash and unchangedCycles from SessionState type - Keep 30s cooldown (CONTINUATION_COOLDOWN_MS) as safety net against re-injection loops - Update tests: remove boulder gate tests, update stagnation test to verify continuous injection, update non-main-session test to verify injection 42 tests pass, typecheck and build clean. --- .../todo-continuation-enforcer/constants.ts | 1 - .../todo-continuation-enforcer/idle-event.ts | 26 -- .../todo-continuation-enforcer.test.ts | 244 +++++------------- src/hooks/todo-continuation-enforcer/types.ts | 2 - 4 files changed, 60 insertions(+), 213 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index f8b94fde..b57a8164 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -18,4 +18,3 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 export const CONTINUATION_COOLDOWN_MS = 30_000 -export const MAX_UNCHANGED_CYCLES = 3 diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index c5181d48..2bfb96bf 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -1,8 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { readBoulderState } from "../../features/boulder-state" -import { subagentSessions } from "../../features/claude-code-session-state" import type { ToolPermission } from "../../features/hook-message-injector" import { log } from "../../shared/logger" @@ -11,7 +9,6 @@ import { CONTINUATION_COOLDOWN_MS, DEFAULT_SKIP_AGENTS, HOOK_NAME, - MAX_UNCHANGED_CYCLES, } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" import { getIncompleteCount } from "./todo" @@ -38,16 +35,6 @@ export async function handleSessionIdle(args: { log(`[${HOOK_NAME}] session.idle`, { sessionID }) - const isBackgroundTaskSession = subagentSessions.has(sessionID) - const boulderState = readBoulderState(ctx.directory) - const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false - - // Continuation is restricted to boulder/background sessions to prevent accidental continuation in regular sessions, ensuring controlled task resumption. - if (!isBackgroundTaskSession && !isBoulderSession) { - log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID }) - return - } - const state = sessionStateStore.getState(sessionID) if (state.isRecovering) { log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) @@ -117,19 +104,6 @@ export async function handleSessionIdle(args: { return } - const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") - const todoHash = incompleteTodos.map((todo) => `${todo.id}:${todo.status}`).join("|") - if (state.lastTodoHash === todoHash) { - state.unchangedCycles = (state.unchangedCycles ?? 0) + 1 - if (state.unchangedCycles >= MAX_UNCHANGED_CYCLES) { - log(`[${HOOK_NAME}] Skipped: stagnation cap reached`, { sessionID, cycles: state.unchangedCycles }) - return - } - } else { - state.unchangedCycles = 0 - } - state.lastTodoHash = todoHash - let resolvedInfo: ResolvedMessageInfo | undefined let hasCompactionMessage = false try { 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 43c681ba..91cb173b 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1,5 +1,3 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" @@ -122,32 +120,6 @@ function createFakeTimers(): FakeTimers { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -const TEST_BOULDER_DIR = join("/tmp/test", ".sisyphus") -const TEST_BOULDER_FILE = join(TEST_BOULDER_DIR, "boulder.json") - -function writeBoulderJsonForSession(sessionIds: string[]): void { - if (!existsSync(TEST_BOULDER_DIR)) { - mkdirSync(TEST_BOULDER_DIR, { recursive: true }) - } - writeFileSync(TEST_BOULDER_FILE, JSON.stringify({ - active_plan: "/tmp/test/.sisyphus/plans/test-plan.md", - started_at: new Date().toISOString(), - session_ids: sessionIds, - plan_name: "test-plan", - }), "utf-8") -} - -function cleanupBoulderFile(): void { - if (existsSync(TEST_BOULDER_FILE)) { - rmSync(TEST_BOULDER_FILE) - } -} - -function setupMainSessionWithBoulder(sessionID: string): void { - setMainSession(sessionID) - writeBoulderJsonForSession([sessionID]) -} - 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 }> @@ -224,14 +196,13 @@ describe("todo-continuation-enforcer", () => { afterEach(() => { fakeTimers.restore() _resetForTesting() - cleanupBoulderFile() }) test("should inject continuation when idle with incomplete todos", async () => { fakeTimers.restore() // given - main session with incomplete todos const sessionID = "main-123" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { backgroundManager: createMockBackgroundManager(false), @@ -256,7 +227,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject when all todos are complete", async () => { // given - session with all todos complete const sessionID = "main-456" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockInput = createMockPluginInput() mockInput.client.session.todo = async () => ({ data: [ @@ -279,7 +250,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject when background tasks are running", async () => { // given - session with running background tasks const sessionID = "main-789" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { backgroundManager: createMockBackgroundManager(true), @@ -296,23 +267,23 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) - test("should not inject for non-main session", async () => { - // given - main session set, different session goes idle - setMainSession("main-session") + 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 - non-main session goes idle + //#when — session goes idle await hook.handler({ event: { type: "session.idle", properties: { sessionID: otherSession } }, }) - await fakeTimers.advanceBy(3000) - - // then - no continuation injected - expect(promptCalls).toHaveLength(0) - }) + //#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() @@ -339,7 +310,7 @@ describe("todo-continuation-enforcer", () => { test("should cancel countdown on user message after grace period", async () => { // given - session starting countdown const sessionID = "main-cancel" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -366,7 +337,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session starting countdown const sessionID = "main-grace" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -392,7 +363,7 @@ describe("todo-continuation-enforcer", () => { test("should cancel countdown on assistant activity", async () => { // given - session starting countdown const sessionID = "main-assistant" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -419,7 +390,7 @@ describe("todo-continuation-enforcer", () => { test("should cancel countdown on tool execution", async () => { // given - session starting countdown const sessionID = "main-tool" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -443,7 +414,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection during recovery mode", async () => { // given - session in recovery mode const sessionID = "main-recovery" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -465,7 +436,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session was in recovery, now complete const sessionID = "main-recovery-done" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -487,7 +458,7 @@ describe("todo-continuation-enforcer", () => { test("should cleanup on session deleted", async () => { // given - session starting countdown const sessionID = "main-delete" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -511,7 +482,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject again when cooldown is active", async () => { //#given const sessionID = "main-cooldown-active" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) //#when @@ -531,7 +502,7 @@ describe("todo-continuation-enforcer", () => { test("should inject again when cooldown expires", async () => { //#given const sessionID = "main-cooldown-expired" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) //#when @@ -549,19 +520,22 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(2) }) - test("should stop after stagnation cap and reset when todo hash changes", async () => { + test("should keep injecting even when todos remain unchanged across cycles", async () => { //#given - const sessionID = "main-stagnation-cap" - setupMainSessionWithBoulder(sessionID) - let mutableTodoStatus: "pending" | "in_progress" = "pending" + const sessionID = "main-no-stagnation-cap" + setMainSession(sessionID) const mockInput = createMockPluginInput() mockInput.client.session.todo = async () => ({ data: [ - { id: "1", content: "Task 1", status: mutableTodoStatus, priority: "high" }, + { id: "1", content: "Task 1", status: "pending", priority: "high" }, { id: "2", content: "Task 2", status: "completed", priority: "medium" }, ]}) const hook = createTodoContinuationEnforcer(mockInput, {}) - //#when + //#when — 5 consecutive idle cycles with unchanged todos + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) @@ -577,19 +551,14 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) - mutableTodoStatus = "in_progress" - await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(2500, true) - - //#then - expect(promptCalls).toHaveLength(4) + //#then — all 5 injections should fire (no stagnation cap) + expect(promptCalls).toHaveLength(5) }) test("should skip idle handling while injection is in flight", async () => { //#given const sessionID = "main-in-flight" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) let resolvePrompt: (() => void) | undefined const mockInput = createMockPluginInput() mockInput.client.session.promptAsync = async (opts: any) => { @@ -623,10 +592,10 @@ describe("todo-continuation-enforcer", () => { await Promise.resolve() }) - test("should clear cooldown and stagnation state on session deleted", async () => { + test("should clear cooldown state on session deleted", async () => { //#given const sessionID = "main-delete-state-reset" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) //#when @@ -649,7 +618,7 @@ describe("todo-continuation-enforcer", () => { test("should accept skipAgents option without error", async () => { // given - session with skipAgents configured for Prometheus const sessionID = "main-prometheus-option" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) // when - create hook with skipAgents option (should not throw) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { @@ -669,7 +638,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos const sessionID = "main-toast" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -687,7 +656,7 @@ describe("todo-continuation-enforcer", () => { test("should not have 10s throttle between injections", async () => { // given - new hook instance (no prior state) const sessionID = "main-no-throttle" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -720,7 +689,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos const sessionID = "main-noabort-error" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -759,7 +728,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when last assistant message has MessageAbortedError", async () => { // given - session where last assistant message was aborted const sessionID = "main-api-abort" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, @@ -783,7 +752,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session where last assistant message completed normally const sessionID = "main-api-no-error" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, @@ -807,7 +776,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session where last message is from user const sessionID = "main-api-user-last" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "assistant" } }, @@ -830,7 +799,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, @@ -853,7 +822,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -883,7 +852,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when AbortError detected via session.error event", async () => { // given - session with incomplete todos const sessionID = "main-event-abort-dom" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -914,7 +883,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos and old abort timestamp const sessionID = "main-stale-abort" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -947,7 +916,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with abort detected const sessionID = "main-clear-on-user" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -987,7 +956,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with abort detected const sessionID = "main-clear-on-assistant" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -1026,7 +995,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with abort detected const sessionID = "main-clear-on-tool" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -1064,7 +1033,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -1094,7 +1063,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } }, @@ -1117,7 +1086,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos, no prior message context available const sessionID = "main-model-preserve" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { backgroundManager: createMockBackgroundManager(false), @@ -1139,7 +1108,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) // OpenCode returns assistant messages with flat modelID/providerID, not nested model object const mockMessagesWithAssistant = [ @@ -1200,7 +1169,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesWithCompaction = [ { info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } } }, @@ -1255,7 +1224,7 @@ describe("todo-continuation-enforcer", () => { 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" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesOnlyCompaction = [ { info: { id: "msg-1", role: "assistant", agent: "compaction" } }, @@ -1308,7 +1277,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when prometheus agent is after compaction", async () => { // given - prometheus session that was compacted const sessionID = "main-prometheus-compacted" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesPrometheusCompacted = [ { info: { id: "msg-1", role: "user", agent: "prometheus" } }, @@ -1364,7 +1333,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with no agent info but skipAgents is empty const sessionID = "main-no-agent-no-skip" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesNoAgent = [ { info: { id: "msg-1", role: "user" } }, @@ -1420,7 +1389,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject when isContinuationStopped returns true", async () => { // given - session with continuation stopped const sessionID = "main-stopped" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { isContinuationStopped: (id) => id === sessionID, @@ -1441,7 +1410,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with continuation not stopped const sessionID = "main-not-stopped" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { isContinuationStopped: () => false, @@ -1462,7 +1431,7 @@ describe("todo-continuation-enforcer", () => { // given - multiple sessions with running countdowns const session1 = "main-cancel-all-1" const session2 = "main-cancel-all-2" - setupMainSessionWithBoulder(session1) + setMainSession(session1) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -1482,97 +1451,4 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) - // ============================================================ - // BOULDER SESSION GATE TESTS - // These tests verify that todo-continuation-enforcer only fires - // when the session is registered in boulder.json's session_ids - // (i.e., /start-work was executed in the session) - // ============================================================ - - test("should NOT inject for main session when session is NOT in boulder.json session_ids", async () => { - // given - main session that is NOT registered in boulder.json - const sessionID = "main-no-boulder-entry" - setMainSession(sessionID) - writeBoulderJsonForSession(["some-other-session"]) - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - backgroundManager: createMockBackgroundManager(false), - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await fakeTimers.advanceBy(3000) - - // then - no continuation injected (session not in boulder) - expect(promptCalls).toHaveLength(0) - }) - - test("should inject for main session when session IS in boulder.json session_ids", async () => { - fakeTimers.restore() - // given - main session that IS registered in boulder.json - const sessionID = "main-in-boulder" - setMainSession(sessionID) - writeBoulderJsonForSession([sessionID]) - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - backgroundManager: createMockBackgroundManager(false), - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await wait(2500) - - // then - continuation injected (session is in boulder) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].text).toContain("TODO CONTINUATION") - }, { timeout: 15000 }) - - test("should NOT inject for main session when no boulder.json exists", async () => { - // given - main session with no boulder.json at all - const sessionID = "main-no-boulder-file" - setMainSession(sessionID) - cleanupBoulderFile() - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - backgroundManager: createMockBackgroundManager(false), - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await fakeTimers.advanceBy(3000) - - // then - no continuation injected (no boulder state) - expect(promptCalls).toHaveLength(0) - }) - - test("should still inject for background task session regardless of boulder state", async () => { - fakeTimers.restore() - // given - background task session with no boulder entry - setMainSession("main-session") - const bgTaskSession = "bg-task-boulder-test" - subagentSessions.add(bgTaskSession) - cleanupBoulderFile() - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) - - // when - background task session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID: bgTaskSession } }, - }) - - await wait(2500) - - // then - continuation still injected (background tasks bypass boulder check) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].sessionID).toBe(bgTaskSession) - }, { timeout: 15000 }) }) diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index c3384aa0..7d702b0e 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -29,8 +29,6 @@ export interface SessionState { abortDetectedAt?: number lastInjectedAt?: number inFlight?: boolean - lastTodoHash?: string - unchangedCycles?: number } export interface MessageInfo {