fix(tests): properly stub notifyParentSession and fix timer-based tests

- Add stubNotifyParentSession implementation to stub manager's notifyParentSession method
- Add stubNotifyParentSession calls to checkAndInterruptStaleTasks tests
- Add messages mock to client mocks for completeness
- Fix timer-based tests by using real timers (fakeTimers.restore) with wait()
- Increase timeout for tests that need real time delays
This commit is contained in:
justsisyphus
2026-02-01 18:33:06 +09:00
parent 8bf3202552
commit 7f9fcc708f
6 changed files with 133 additions and 96 deletions

View File

@@ -5,8 +5,6 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, ResumeInput } from "./types"
import { BackgroundManager } from "./manager"
import { ConcurrencyManager } from "./concurrency"
import { TaskStateManager } from "./state"
import { tryCompleteTask as tryCompleteTaskFn } from "./result-handler"
const TASK_TTL_MS = 30 * 60 * 1000
@@ -183,33 +181,17 @@ function getConcurrencyManager(manager: BackgroundManager): ConcurrencyManager {
}
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
return (manager as unknown as { state: { tasks: Map<string, BackgroundTask> } }).state.tasks
}
function getManagerInternals(manager: BackgroundManager): {
client: unknown
concurrencyManager: ConcurrencyManager
state: TaskStateManager
} {
return manager as unknown as {
client: unknown
concurrencyManager: ConcurrencyManager
state: TaskStateManager
}
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
}
async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
const internals = getManagerInternals(manager)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return tryCompleteTaskFn(task, "test", {
client: internals.client as any,
concurrencyManager: internals.concurrencyManager,
state: internals.state,
})
return (manager as unknown as { tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> })
.tryCompleteTask(task, "test")
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function stubNotifyParentSession(_manager: BackgroundManager): void {}
function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
}
function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
@@ -993,6 +975,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
abortedSessionIDs.push(args.path.id)
return {}
},
messages: async () => ({ data: [] }),
},
}
manager.shutdown()
@@ -1758,7 +1741,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
manager["state"].tasks.set(task.id, task)
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
@@ -1790,7 +1773,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
manager["state"].tasks.set(task.id, task)
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
@@ -1805,6 +1788,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-3",
@@ -1822,7 +1806,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
manager["state"].tasks.set(task.id, task)
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
@@ -1840,6 +1824,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 60_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-4",
@@ -1857,7 +1842,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
manager["state"].tasks.set(task.id, task)
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
@@ -1873,6 +1858,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-5",
@@ -1891,7 +1877,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
concurrencyKey: "test-agent",
}
manager["state"].tasks.set(task.id, task)
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
@@ -1907,6 +1893,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task1: BackgroundTask = {
id: "task-6",
@@ -1940,8 +1927,8 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
manager["state"].tasks.set(task1.id, task1)
manager["state"].tasks.set(task2.id, task2)
getTaskMap(manager).set(task1.id, task1)
getTaskMap(manager).set(task2.id, task2)
await manager["checkAndInterruptStaleTasks"]()
@@ -1957,6 +1944,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-8",
@@ -1974,7 +1962,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
},
}
manager["state"].tasks.set(task.id, task)
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
@@ -2143,7 +2131,7 @@ describe("BackgroundManager.shutdown session abort", () => {
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { state: { completionTimers: Map<string, ReturnType<typeof setTimeout>> } }).state.completionTimers
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function setCompletionTimer(manager: BackgroundManager, taskId: string): void {

View File

@@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createAtlasHook } from "./index"
import { randomUUID } from "node:crypto"
import {
writeBoulderState,
clearBoulderState,
@@ -10,11 +10,22 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
describe("atlas hook", () => {
const TEST_DIR = join(tmpdir(), "atlas-test-" + Date.now())
const SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
let TEST_DIR: string
let SISYPHUS_DIR: string
function createMockPluginInput(overrides?: { promptMock?: ReturnType<typeof mock> }) {
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
@@ -49,6 +60,8 @@ describe("atlas hook", () => {
}
beforeEach(() => {
TEST_DIR = join(tmpdir(), `atlas-test-${randomUUID()}`)
SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
}
@@ -63,6 +76,7 @@ describe("atlas hook", () => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("tool.execute.after handler", () => {

View File

@@ -1,11 +1,24 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { createPrometheusMdOnlyHook } from "./index"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { SYSTEM_DIRECTIVE_PREFIX, createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { clearSessionAgent } from "../../features/claude-code-session-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `prometheus-md-only-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
const { createPrometheusMdOnlyHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
describe("prometheus-md-only", () => {
const TEST_SESSION_ID = "test-session-prometheus"
let testMessageDir: string
@@ -39,6 +52,7 @@ describe("prometheus-md-only", () => {
// ignore
}
}
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("with Prometheus agent in message storage", () => {
@@ -342,7 +356,7 @@ describe("prometheus-md-only", () => {
describe("cross-platform path validation", () => {
beforeEach(() => {
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
setupMessageStorage(TEST_SESSION_ID, "prometheus")
})
test("should allow Windows-style backslash paths under .sisyphus/", async () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir, homedir } from "node:os"
import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index"
import {
writeBoulderState,
@@ -11,30 +12,32 @@ import type { BoulderState } from "../../features/boulder-state"
import * as sessionState from "../../features/claude-code-session-state"
describe("start-work hook", () => {
const TEST_DIR = join(tmpdir(), "start-work-test-" + Date.now())
const SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
let testDir: string
let sisyphusDir: string
function createMockPluginInput() {
return {
directory: TEST_DIR,
directory: testDir,
client: {},
} as Parameters<typeof createStartWorkHook>[0]
}
beforeEach(() => {
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
testDir = join(tmpdir(), `start-work-test-${randomUUID()}`)
sisyphusDir = join(testDir, ".sisyphus")
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true })
}
if (!existsSync(SISYPHUS_DIR)) {
mkdirSync(SISYPHUS_DIR, { recursive: true })
if (!existsSync(sisyphusDir)) {
mkdirSync(sisyphusDir, { recursive: true })
}
clearBoulderState(TEST_DIR)
clearBoulderState(testDir)
})
afterEach(() => {
clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
clearBoulderState(testDir)
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true })
}
})
@@ -80,7 +83,7 @@ describe("start-work hook", () => {
test("should inject resume info when existing boulder state found", async () => {
// given - existing boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
@@ -89,7 +92,7 @@ describe("start-work hook", () => {
session_ids: ["session-1"],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
writeBoulderState(testDir, state)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
@@ -155,7 +158,7 @@ describe("start-work hook", () => {
test("should auto-select when only one incomplete plan among multiple plans", async () => {
// given - multiple plans but only one incomplete
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
// Plan 1: complete (all checked)
@@ -185,7 +188,7 @@ describe("start-work hook", () => {
test("should wrap multiple plans message in system-reminder tag", async () => {
// given - multiple incomplete plans
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const plan1Path = join(plansDir, "plan-a.md")
@@ -213,7 +216,7 @@ describe("start-work hook", () => {
test("should use 'ask user' prompt style for multiple plans", async () => {
// given - multiple incomplete plans
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const plan1Path = join(plansDir, "plan-x.md")
@@ -240,7 +243,7 @@ describe("start-work hook", () => {
test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => {
// given - existing boulder state pointing to old plan
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
// Old plan (in boulder state)
@@ -258,7 +261,7 @@ describe("start-work hook", () => {
session_ids: ["old-session"],
plan_name: "old-plan",
}
writeBoulderState(TEST_DIR, staleState)
writeBoulderState(testDir, staleState)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
@@ -286,7 +289,7 @@ describe("start-work hook", () => {
test("should strip ultrawork/ulw keywords from plan name argument", async () => {
// given - plan with ultrawork keyword in user-request
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const planPath = join(plansDir, "my-feature-plan.md")
@@ -317,7 +320,7 @@ describe("start-work hook", () => {
test("should strip ulw keyword from plan name argument", async () => {
// given - plan with ulw keyword in user-request
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const planPath = join(plansDir, "api-refactor.md")
@@ -348,7 +351,7 @@ describe("start-work hook", () => {
test("should match plan by partial name", async () => {
// given - user specifies partial plan name
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
const planPath = join(plansDir, "2026-01-15-feature-implementation.md")

View File

@@ -117,6 +117,8 @@ function createFakeTimers(): FakeTimers {
return { advanceBy, restore }
}
const wait = (ms: number) => new Promise<void>((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 }>
@@ -187,6 +189,7 @@ describe("todo-continuation-enforcer", () => {
})
test("should inject continuation when idle with incomplete todos", async () => {
fakeTimers.restore()
// given - main session with incomplete todos
const sessionID = "main-123"
setMainSession(sessionID)
@@ -201,15 +204,15 @@ describe("todo-continuation-enforcer", () => {
})
// then - countdown toast shown
await fakeTimers.advanceBy(100)
await wait(50)
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Todo Continuation")
// then - after countdown, continuation injected
await fakeTimers.advanceBy(2500)
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
@@ -273,6 +276,7 @@ describe("todo-continuation-enforcer", () => {
})
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"
@@ -286,10 +290,10 @@ describe("todo-continuation-enforcer", () => {
})
// then - continuation injected for background task session
await fakeTimers.advanceBy(2500)
await wait(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
})
}, { timeout: 15000 })
@@ -320,6 +324,7 @@ describe("todo-continuation-enforcer", () => {
})
test("should ignore user message within grace period", async () => {
fakeTimers.restore()
// given - session starting countdown
const sessionID = "main-grace"
setMainSession(sessionID)
@@ -341,9 +346,9 @@ describe("todo-continuation-enforcer", () => {
// then - countdown should continue (message was ignored)
// wait past 2s countdown and verify injection happens
await fakeTimers.advanceBy(2500)
await wait(2500)
expect(promptCalls).toHaveLength(1)
})
}, { timeout: 15000 })
test("should cancel countdown on assistant activity", async () => {
// given - session starting countdown
@@ -418,6 +423,7 @@ describe("todo-continuation-enforcer", () => {
})
test("should inject after recovery complete", async () => {
fakeTimers.restore()
// given - session was in recovery, now complete
const sessionID = "main-recovery-done"
setMainSession(sessionID)
@@ -433,11 +439,11 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
await wait(3000)
// then - continuation injected
expect(promptCalls.length).toBe(1)
})
}, { timeout: 15000 })
test("should cleanup on session deleted", async () => {
// given - session starting countdown
@@ -483,6 +489,7 @@ describe("todo-continuation-enforcer", () => {
})
test("should show countdown toast updates", async () => {
fakeTimers.restore()
// given - session with incomplete todos
const sessionID = "main-toast"
setMainSession(sessionID)
@@ -495,10 +502,10 @@ describe("todo-continuation-enforcer", () => {
})
// then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
await fakeTimers.advanceBy(2500)
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)
@@ -533,6 +540,7 @@ describe("todo-continuation-enforcer", () => {
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)
@@ -555,11 +563,11 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500)
await wait(2500)
// then - continuation injected (non-abort errors don't block)
expect(promptCalls.length).toBe(1)
})
}, { timeout: 15000 })
@@ -595,6 +603,7 @@ describe("todo-continuation-enforcer", () => {
})
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)
@@ -611,13 +620,14 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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)
@@ -634,11 +644,11 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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)
@@ -724,6 +734,7 @@ describe("todo-continuation-enforcer", () => {
})
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)
@@ -743,19 +754,20 @@ describe("todo-continuation-enforcer", () => {
})
// when - wait >3s then idle fires
await fakeTimers.advanceBy(3100, true)
await wait(3100)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
await wait(3000)
// then - continuation injected (abort flag is stale)
expect(promptCalls.length).toBeGreaterThan(0)
}, 10000)
}, { 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)
@@ -775,7 +787,7 @@ describe("todo-continuation-enforcer", () => {
})
// when - user sends new message (clears abort flag)
await fakeTimers.advanceBy(600)
await wait(600)
await hook.handler({
event: {
type: "message.updated",
@@ -788,13 +800,14 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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)
@@ -826,13 +839,14 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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)
@@ -864,11 +878,11 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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
@@ -923,6 +937,7 @@ describe("todo-continuation-enforcer", () => {
})
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)
@@ -936,13 +951,13 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500)
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)
@@ -1133,6 +1148,7 @@ describe("todo-continuation-enforcer", () => {
})
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)
@@ -1173,11 +1189,11 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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
@@ -1200,6 +1216,7 @@ describe("todo-continuation-enforcer", () => {
})
test("should inject when isContinuationStopped returns false", async () => {
fakeTimers.restore()
// given - session with continuation not stopped
const sessionID = "main-not-stopped"
setMainSession(sessionID)
@@ -1213,11 +1230,11 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
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

View File

@@ -2,8 +2,9 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
const TEST_DIR = join(tmpdir(), "omo-test-session-manager")
const TEST_DIR = join(tmpdir(), `omo-test-session-manager-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
const TEST_PART_STORAGE = join(TEST_DIR, "part")
const TEST_SESSION_STORAGE = join(TEST_DIR, "session")