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:
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user