fix(todo-continuation-enforcer): fire continuation for all sessions with incomplete todos
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.
This commit is contained in:
parent
9742f7d0b9
commit
7a3df05e47
@ -18,4 +18,3 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
|||||||
|
|
||||||
export const ABORT_WINDOW_MS = 3000
|
export const ABORT_WINDOW_MS = 3000
|
||||||
export const CONTINUATION_COOLDOWN_MS = 30_000
|
export const CONTINUATION_COOLDOWN_MS = 30_000
|
||||||
export const MAX_UNCHANGED_CYCLES = 3
|
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
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 type { ToolPermission } from "../../features/hook-message-injector"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
@ -11,7 +9,6 @@ import {
|
|||||||
CONTINUATION_COOLDOWN_MS,
|
CONTINUATION_COOLDOWN_MS,
|
||||||
DEFAULT_SKIP_AGENTS,
|
DEFAULT_SKIP_AGENTS,
|
||||||
HOOK_NAME,
|
HOOK_NAME,
|
||||||
MAX_UNCHANGED_CYCLES,
|
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||||
import { getIncompleteCount } from "./todo"
|
import { getIncompleteCount } from "./todo"
|
||||||
@ -38,16 +35,6 @@ export async function handleSessionIdle(args: {
|
|||||||
|
|
||||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
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)
|
const state = sessionStateStore.getState(sessionID)
|
||||||
if (state.isRecovering) {
|
if (state.isRecovering) {
|
||||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||||
@ -117,19 +104,6 @@ export async function handleSessionIdle(args: {
|
|||||||
return
|
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 resolvedInfo: ResolvedMessageInfo | undefined
|
||||||
let hasCompactionMessage = false
|
let hasCompactionMessage = false
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -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 { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
@ -122,32 +120,6 @@ function createFakeTimers(): FakeTimers {
|
|||||||
|
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>((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", () => {
|
describe("todo-continuation-enforcer", () => {
|
||||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||||
let toastCalls: Array<{ title: string; message: string }>
|
let toastCalls: Array<{ title: string; message: string }>
|
||||||
@ -224,14 +196,13 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
cleanupBoulderFile()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should inject continuation when idle with incomplete todos", async () => {
|
test("should inject continuation when idle with incomplete todos", async () => {
|
||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - main session with incomplete todos
|
// given - main session with incomplete todos
|
||||||
const sessionID = "main-123"
|
const sessionID = "main-123"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
backgroundManager: createMockBackgroundManager(false),
|
backgroundManager: createMockBackgroundManager(false),
|
||||||
@ -256,7 +227,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should not inject when all todos are complete", async () => {
|
test("should not inject when all todos are complete", async () => {
|
||||||
// given - session with all todos complete
|
// given - session with all todos complete
|
||||||
const sessionID = "main-456"
|
const sessionID = "main-456"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const mockInput = createMockPluginInput()
|
const mockInput = createMockPluginInput()
|
||||||
mockInput.client.session.todo = async () => ({ data: [
|
mockInput.client.session.todo = async () => ({ data: [
|
||||||
@ -279,7 +250,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should not inject when background tasks are running", async () => {
|
test("should not inject when background tasks are running", async () => {
|
||||||
// given - session with running background tasks
|
// given - session with running background tasks
|
||||||
const sessionID = "main-789"
|
const sessionID = "main-789"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
backgroundManager: createMockBackgroundManager(true),
|
backgroundManager: createMockBackgroundManager(true),
|
||||||
@ -296,23 +267,23 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
expect(promptCalls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not inject for non-main session", async () => {
|
test("should inject for any session with incomplete todos", async () => {
|
||||||
// given - main session set, different session goes idle
|
fakeTimers.restore()
|
||||||
setMainSession("main-session")
|
//#given — any session, not necessarily main session
|
||||||
const otherSession = "other-session"
|
const otherSession = "other-session"
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
// when - non-main session goes idle
|
//#when — session goes idle
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
||||||
})
|
})
|
||||||
|
|
||||||
await fakeTimers.advanceBy(3000)
|
//#then — continuation injected regardless of session type
|
||||||
|
await wait(2500)
|
||||||
// then - no continuation injected
|
expect(promptCalls.length).toBe(1)
|
||||||
expect(promptCalls).toHaveLength(0)
|
expect(promptCalls[0].sessionID).toBe(otherSession)
|
||||||
})
|
}, { timeout: 15000 })
|
||||||
|
|
||||||
test("should inject for background task session (subagent)", async () => {
|
test("should inject for background task session (subagent)", async () => {
|
||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
@ -339,7 +310,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should cancel countdown on user message after grace period", async () => {
|
test("should cancel countdown on user message after grace period", async () => {
|
||||||
// given - session starting countdown
|
// given - session starting countdown
|
||||||
const sessionID = "main-cancel"
|
const sessionID = "main-cancel"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -366,7 +337,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session starting countdown
|
// given - session starting countdown
|
||||||
const sessionID = "main-grace"
|
const sessionID = "main-grace"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -392,7 +363,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should cancel countdown on assistant activity", async () => {
|
test("should cancel countdown on assistant activity", async () => {
|
||||||
// given - session starting countdown
|
// given - session starting countdown
|
||||||
const sessionID = "main-assistant"
|
const sessionID = "main-assistant"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -419,7 +390,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should cancel countdown on tool execution", async () => {
|
test("should cancel countdown on tool execution", async () => {
|
||||||
// given - session starting countdown
|
// given - session starting countdown
|
||||||
const sessionID = "main-tool"
|
const sessionID = "main-tool"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -443,7 +414,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should skip injection during recovery mode", async () => {
|
test("should skip injection during recovery mode", async () => {
|
||||||
// given - session in recovery mode
|
// given - session in recovery mode
|
||||||
const sessionID = "main-recovery"
|
const sessionID = "main-recovery"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -465,7 +436,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session was in recovery, now complete
|
// given - session was in recovery, now complete
|
||||||
const sessionID = "main-recovery-done"
|
const sessionID = "main-recovery-done"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -487,7 +458,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should cleanup on session deleted", async () => {
|
test("should cleanup on session deleted", async () => {
|
||||||
// given - session starting countdown
|
// given - session starting countdown
|
||||||
const sessionID = "main-delete"
|
const sessionID = "main-delete"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -511,7 +482,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should not inject again when cooldown is active", async () => {
|
test("should not inject again when cooldown is active", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "main-cooldown-active"
|
const sessionID = "main-cooldown-active"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
@ -531,7 +502,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should inject again when cooldown expires", async () => {
|
test("should inject again when cooldown expires", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "main-cooldown-expired"
|
const sessionID = "main-cooldown-expired"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
@ -549,19 +520,22 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(2)
|
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
|
//#given
|
||||||
const sessionID = "main-stagnation-cap"
|
const sessionID = "main-no-stagnation-cap"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
let mutableTodoStatus: "pending" | "in_progress" = "pending"
|
|
||||||
const mockInput = createMockPluginInput()
|
const mockInput = createMockPluginInput()
|
||||||
mockInput.client.session.todo = async () => ({ data: [
|
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" },
|
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||||
]})
|
]})
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
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 hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
await fakeTimers.advanceBy(2500, true)
|
await fakeTimers.advanceBy(2500, true)
|
||||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, 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 hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
await fakeTimers.advanceBy(2500, true)
|
await fakeTimers.advanceBy(2500, true)
|
||||||
|
|
||||||
mutableTodoStatus = "in_progress"
|
//#then — all 5 injections should fire (no stagnation cap)
|
||||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
expect(promptCalls).toHaveLength(5)
|
||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
|
||||||
await fakeTimers.advanceBy(2500, true)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(promptCalls).toHaveLength(4)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should skip idle handling while injection is in flight", async () => {
|
test("should skip idle handling while injection is in flight", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "main-in-flight"
|
const sessionID = "main-in-flight"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
let resolvePrompt: (() => void) | undefined
|
let resolvePrompt: (() => void) | undefined
|
||||||
const mockInput = createMockPluginInput()
|
const mockInput = createMockPluginInput()
|
||||||
mockInput.client.session.promptAsync = async (opts: any) => {
|
mockInput.client.session.promptAsync = async (opts: any) => {
|
||||||
@ -623,10 +592,10 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should clear cooldown and stagnation state on session deleted", async () => {
|
test("should clear cooldown state on session deleted", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "main-delete-state-reset"
|
const sessionID = "main-delete-state-reset"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
@ -649,7 +618,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should accept skipAgents option without error", async () => {
|
test("should accept skipAgents option without error", async () => {
|
||||||
// given - session with skipAgents configured for Prometheus
|
// given - session with skipAgents configured for Prometheus
|
||||||
const sessionID = "main-prometheus-option"
|
const sessionID = "main-prometheus-option"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
// when - create hook with skipAgents option (should not throw)
|
// when - create hook with skipAgents option (should not throw)
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
@ -669,7 +638,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with incomplete todos
|
// given - session with incomplete todos
|
||||||
const sessionID = "main-toast"
|
const sessionID = "main-toast"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -687,7 +656,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should not have 10s throttle between injections", async () => {
|
test("should not have 10s throttle between injections", async () => {
|
||||||
// given - new hook instance (no prior state)
|
// given - new hook instance (no prior state)
|
||||||
const sessionID = "main-no-throttle"
|
const sessionID = "main-no-throttle"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -720,7 +689,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with incomplete todos
|
// given - session with incomplete todos
|
||||||
const sessionID = "main-noabort-error"
|
const sessionID = "main-noabort-error"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -759,7 +728,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should skip injection when last assistant message has MessageAbortedError", async () => {
|
test("should skip injection when last assistant message has MessageAbortedError", async () => {
|
||||||
// given - session where last assistant message was aborted
|
// given - session where last assistant message was aborted
|
||||||
const sessionID = "main-api-abort"
|
const sessionID = "main-api-abort"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
@ -783,7 +752,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session where last assistant message completed normally
|
// given - session where last assistant message completed normally
|
||||||
const sessionID = "main-api-no-error"
|
const sessionID = "main-api-no-error"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
@ -807,7 +776,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session where last message is from user
|
// given - session where last message is from user
|
||||||
const sessionID = "main-api-user-last"
|
const sessionID = "main-api-user-last"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "assistant" } },
|
{ 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 () => {
|
test("should skip when last assistant message has any abort-like error", async () => {
|
||||||
// given - session where last assistant message has AbortError (DOMException style)
|
// given - session where last assistant message has AbortError (DOMException style)
|
||||||
const sessionID = "main-api-abort-dom"
|
const sessionID = "main-api-abort-dom"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ 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 () => {
|
test("should skip injection when abort detected via session.error event (event-based, primary)", async () => {
|
||||||
// given - session with incomplete todos
|
// given - session with incomplete todos
|
||||||
const sessionID = "main-event-abort"
|
const sessionID = "main-event-abort"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ 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 () => {
|
test("should skip injection when AbortError detected via session.error event", async () => {
|
||||||
// given - session with incomplete todos
|
// given - session with incomplete todos
|
||||||
const sessionID = "main-event-abort-dom"
|
const sessionID = "main-event-abort-dom"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ info: { id: "msg-2", role: "assistant" } },
|
||||||
@ -914,7 +883,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with incomplete todos and old abort timestamp
|
// given - session with incomplete todos and old abort timestamp
|
||||||
const sessionID = "main-stale-abort"
|
const sessionID = "main-stale-abort"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ info: { id: "msg-2", role: "assistant" } },
|
||||||
@ -947,7 +916,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with abort detected
|
// given - session with abort detected
|
||||||
const sessionID = "main-clear-on-user"
|
const sessionID = "main-clear-on-user"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ info: { id: "msg-2", role: "assistant" } },
|
||||||
@ -987,7 +956,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with abort detected
|
// given - session with abort detected
|
||||||
const sessionID = "main-clear-on-assistant"
|
const sessionID = "main-clear-on-assistant"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ info: { id: "msg-2", role: "assistant" } },
|
||||||
@ -1026,7 +995,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with abort detected
|
// given - session with abort detected
|
||||||
const sessionID = "main-clear-on-tool"
|
const sessionID = "main-clear-on-tool"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ 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 () => {
|
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
|
// given - session with abort event but API shows no error
|
||||||
const sessionID = "main-event-wins"
|
const sessionID = "main-event-wins"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant" } },
|
{ 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 () => {
|
test("should use API fallback when event is missed but API shows abort", async () => {
|
||||||
// given - session where event was missed but API shows abort
|
// given - session where event was missed but API shows abort
|
||||||
const sessionID = "main-api-fallback"
|
const sessionID = "main-api-fallback"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
mockMessages = [
|
mockMessages = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } },
|
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } },
|
||||||
@ -1117,7 +1086,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with incomplete todos, no prior message context available
|
// given - session with incomplete todos, no prior message context available
|
||||||
const sessionID = "main-model-preserve"
|
const sessionID = "main-model-preserve"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
backgroundManager: createMockBackgroundManager(false),
|
backgroundManager: createMockBackgroundManager(false),
|
||||||
@ -1139,7 +1108,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should extract model from assistant message with flat modelID/providerID", async () => {
|
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)
|
// given - session with assistant message that has flat modelID/providerID (OpenCode API format)
|
||||||
const sessionID = "main-assistant-model"
|
const sessionID = "main-assistant-model"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
// OpenCode returns assistant messages with flat modelID/providerID, not nested model object
|
// OpenCode returns assistant messages with flat modelID/providerID, not nested model object
|
||||||
const mockMessagesWithAssistant = [
|
const mockMessagesWithAssistant = [
|
||||||
@ -1200,7 +1169,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should skip compaction agent messages when resolving agent info", async () => {
|
test("should skip compaction agent messages when resolving agent info", async () => {
|
||||||
// given - session where last message is from compaction agent but previous was Sisyphus
|
// given - session where last message is from compaction agent but previous was Sisyphus
|
||||||
const sessionID = "main-compaction-filter"
|
const sessionID = "main-compaction-filter"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const mockMessagesWithCompaction = [
|
const mockMessagesWithCompaction = [
|
||||||
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } } },
|
{ 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 () => {
|
test("should skip injection when only compaction agent messages exist", async () => {
|
||||||
// given - session with only compaction agent (post-compaction, no prior agent info)
|
// given - session with only compaction agent (post-compaction, no prior agent info)
|
||||||
const sessionID = "main-only-compaction"
|
const sessionID = "main-only-compaction"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const mockMessagesOnlyCompaction = [
|
const mockMessagesOnlyCompaction = [
|
||||||
{ info: { id: "msg-1", role: "assistant", agent: "compaction" } },
|
{ 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 () => {
|
test("should skip injection when prometheus agent is after compaction", async () => {
|
||||||
// given - prometheus session that was compacted
|
// given - prometheus session that was compacted
|
||||||
const sessionID = "main-prometheus-compacted"
|
const sessionID = "main-prometheus-compacted"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const mockMessagesPrometheusCompacted = [
|
const mockMessagesPrometheusCompacted = [
|
||||||
{ info: { id: "msg-1", role: "user", agent: "prometheus" } },
|
{ info: { id: "msg-1", role: "user", agent: "prometheus" } },
|
||||||
@ -1364,7 +1333,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with no agent info but skipAgents is empty
|
// given - session with no agent info but skipAgents is empty
|
||||||
const sessionID = "main-no-agent-no-skip"
|
const sessionID = "main-no-agent-no-skip"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const mockMessagesNoAgent = [
|
const mockMessagesNoAgent = [
|
||||||
{ info: { id: "msg-1", role: "user" } },
|
{ info: { id: "msg-1", role: "user" } },
|
||||||
@ -1420,7 +1389,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
test("should not inject when isContinuationStopped returns true", async () => {
|
test("should not inject when isContinuationStopped returns true", async () => {
|
||||||
// given - session with continuation stopped
|
// given - session with continuation stopped
|
||||||
const sessionID = "main-stopped"
|
const sessionID = "main-stopped"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
isContinuationStopped: (id) => id === sessionID,
|
isContinuationStopped: (id) => id === sessionID,
|
||||||
@ -1441,7 +1410,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - session with continuation not stopped
|
// given - session with continuation not stopped
|
||||||
const sessionID = "main-not-stopped"
|
const sessionID = "main-not-stopped"
|
||||||
setupMainSessionWithBoulder(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
isContinuationStopped: () => false,
|
isContinuationStopped: () => false,
|
||||||
@ -1462,7 +1431,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
// given - multiple sessions with running countdowns
|
// given - multiple sessions with running countdowns
|
||||||
const session1 = "main-cancel-all-1"
|
const session1 = "main-cancel-all-1"
|
||||||
const session2 = "main-cancel-all-2"
|
const session2 = "main-cancel-all-2"
|
||||||
setupMainSessionWithBoulder(session1)
|
setMainSession(session1)
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
@ -1482,97 +1451,4 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
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 })
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -29,8 +29,6 @@ export interface SessionState {
|
|||||||
abortDetectedAt?: number
|
abortDetectedAt?: number
|
||||||
lastInjectedAt?: number
|
lastInjectedAt?: number
|
||||||
inFlight?: boolean
|
inFlight?: boolean
|
||||||
lastTodoHash?: string
|
|
||||||
unchangedCycles?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInfo {
|
export interface MessageInfo {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user