import { tmpdir } from "node:os" import { afterEach, describe, expect, test } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { TASK_CLEANUP_DELAY_MS } from "./constants" import { BackgroundManager } from "./manager" import type { BackgroundTask } from "./types" type PromptAsyncCall = { path: { id: string } body: { noReply?: boolean parts?: unknown[] } } type FakeTimers = { getDelay: (timer: ReturnType) => number | undefined run: (timer: ReturnType) => void restore: () => void } let managerUnderTest: BackgroundManager | undefined let fakeTimers: FakeTimers | undefined afterEach(() => { managerUnderTest?.shutdown() fakeTimers?.restore() managerUnderTest = undefined fakeTimers = undefined }) function createTask(overrides: Partial & { id: string; parentSessionId: string }): BackgroundTask { const id = overrides.id const parentSessionID = overrides.parentSessionId const { id: _ignoredID, parentSessionId: _ignoredParentSessionID, ...rest } = overrides return { parentMessageId: overrides.parentMessageId ?? "parent-message-id", description: overrides.description ?? overrides.id, prompt: overrides.prompt ?? `Prompt for ${overrides.id}`, agent: overrides.agent ?? "test-agent", status: overrides.status ?? "running", startedAt: overrides.startedAt ?? new Date("2026-03-11T00:00:00.000Z"), ...rest, id, parentSessionId: parentSessionID, } } function createManager(enableParentSessionNotifications: boolean): { manager: BackgroundManager promptAsyncCalls: PromptAsyncCall[] } function createManager( enableParentSessionNotifications: boolean, sessionStatuses?: Record, ): { manager: BackgroundManager promptAsyncCalls: PromptAsyncCall[] } { const promptAsyncCalls: PromptAsyncCall[] = [] const client = { session: { messages: async () => [], status: async () => ({ data: sessionStatuses ?? {} }), prompt: async () => ({}), promptAsync: async (call: PromptAsyncCall) => { promptAsyncCalls.push(call) return {} }, abort: async () => ({}), }, } const placeholderClient = {} as PluginInput["client"] const ctx: PluginInput = { client: placeholderClient, project: {} as PluginInput["project"], directory: tmpdir(), worktree: tmpdir(), serverUrl: new URL("http://localhost"), $: {} as PluginInput["$"], } const manager = new BackgroundManager( { pluginContext: ctx, config: undefined, enableParentSessionNotifications } ) Reflect.set(manager, "client", client) return { manager, promptAsyncCalls } } function installFakeTimers(): FakeTimers { const originalSetTimeout = globalThis.setTimeout const originalClearTimeout = globalThis.clearTimeout const callbacks = new Map, () => void>() const delays = new Map, number>() globalThis.setTimeout = ((handler: Parameters[0], delay?: number, ...args: unknown[]): ReturnType => { if (typeof handler !== "function") { throw new Error("Expected function timeout handler") } const timer = originalSetTimeout(() => {}, 60_000) originalClearTimeout(timer) const callback = handler as (...callbackArgs: Array) => void callbacks.set(timer, () => callback(...args)) delays.set(timer, delay ?? 0) return timer }) as typeof setTimeout globalThis.clearTimeout = ((timer: ReturnType): void => { callbacks.delete(timer) delays.delete(timer) }) as typeof clearTimeout return { getDelay(timer) { return delays.get(timer) }, run(timer) { const callback = callbacks.get(timer) if (!callback) { throw new Error(`Timer not found: ${String(timer)}`) } callbacks.delete(timer) delays.delete(timer) callback() }, restore() { globalThis.setTimeout = originalSetTimeout globalThis.clearTimeout = originalClearTimeout }, } } function getTasks(manager: BackgroundManager): Map { return Reflect.get(manager, "tasks") as Map } function getPendingByParent(manager: BackgroundManager): Map> { return Reflect.get(manager, "pendingByParent") as Map> } function getCompletionTimers(manager: BackgroundManager): Map> { return Reflect.get(manager, "completionTimers") as Map> } async function notifyParentSessionForTest(manager: BackgroundManager, task: BackgroundTask): Promise { const notifyParentSession = Reflect.get(manager, "notifyParentSession") as (task: BackgroundTask) => Promise return notifyParentSession.call(manager, task) } function waitForDeferredWake(): Promise { return new Promise((resolve) => setTimeout(resolve, 180)) } function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType { const timer = getCompletionTimers(manager).get(taskID) expect(timer).toBeDefined() if (timer === undefined) { throw new Error(`Missing completion timer for ${taskID}`) } return timer } describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { describe("#given 3 tasks for same parent and task A completed first", () => { test("#when siblings are still running or pending #then task A remains until siblings also complete", async () => { // given const { manager } = createManager(false) managerUnderTest = manager fakeTimers = installFakeTimers() const taskA = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date() }) const taskB = createTask({ id: "task-b", parentSessionId: "parent-1", description: "task B", status: "running" }) const taskC = createTask({ id: "task-c", parentSessionId: "parent-1", description: "task C", status: "pending" }) getTasks(manager).set(taskA.id, taskA) getTasks(manager).set(taskB.id, taskB) getTasks(manager).set(taskC.id, taskC) getPendingByParent(manager).set(taskA.parentSessionId, new Set([taskA.id, taskB.id, taskC.id])) // when await notifyParentSessionForTest(manager, taskA) const taskATimer = getRequiredTimer(manager, taskA.id) expect(fakeTimers.getDelay(taskATimer)).toBe(TASK_CLEANUP_DELAY_MS) fakeTimers.run(taskATimer) // then expect(fakeTimers.getDelay(taskATimer)).toBeUndefined() expect(getTasks(manager).has(taskA.id)).toBe(true) expect(getTasks(manager).get(taskB.id)).toBe(taskB) expect(getTasks(manager).get(taskC.id)).toBe(taskC) // when taskB.status = "completed" taskB.completedAt = new Date() taskC.status = "completed" taskC.completedAt = new Date() await notifyParentSessionForTest(manager, taskB) await notifyParentSessionForTest(manager, taskC) const rescheduledTaskATimer = getRequiredTimer(manager, taskA.id) expect(fakeTimers.getDelay(rescheduledTaskATimer)).toBe(TASK_CLEANUP_DELAY_MS) fakeTimers.run(rescheduledTaskATimer) // then expect(getTasks(manager).has(taskA.id)).toBe(false) }) }) describe("#given 2 tasks for same parent and both completed", () => { test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => { // given const { manager, promptAsyncCalls } = createManager(true) managerUnderTest = manager fakeTimers = installFakeTimers() const taskA = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) const taskB = createTask({ id: "task-b", parentSessionId: "parent-1", description: "task B", status: "running" }) getTasks(manager).set(taskA.id, taskA) getTasks(manager).set(taskB.id, taskB) getPendingByParent(manager).set(taskA.parentSessionId, new Set([taskA.id, taskB.id])) await notifyParentSessionForTest(manager, taskA) taskB.status = "completed" taskB.completedAt = new Date("2026-03-11T00:02:00.000Z") // when await notifyParentSessionForTest(manager, taskB) // then expect(promptAsyncCalls).toHaveLength(2) expect(getCompletionTimers(manager).size).toBe(2) const allCompleteCall = promptAsyncCalls[1] expect(allCompleteCall).toBeDefined() if (!allCompleteCall) { throw new Error("Missing all-complete notification call") } expect(allCompleteCall.body.noReply).toBe(false) const allCompletePayload = JSON.stringify(allCompleteCall.body.parts) expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE") expect(allCompletePayload).toContain(taskA.id) expect(allCompletePayload).toContain(taskB.id) expect(allCompletePayload).toContain(taskA.description) expect(allCompletePayload).toContain(taskB.description) }) test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, } const { manager, promptAsyncCalls } = createManager(true, sessionStatuses) managerUnderTest = manager const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) // when await notifyParentSessionForTest(manager, task) // then expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls[0]?.body.noReply).toBe(true) expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("ALL BACKGROUND TASKS COMPLETE") }) test("#when deferred parent session becomes idle #then wake prompt is sent once without duplicating the notification", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, } const { manager, promptAsyncCalls } = createManager(true, sessionStatuses) managerUnderTest = manager const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) await notifyParentSessionForTest(manager, task) // when sessionStatuses["parent-1"] = { type: "idle" } manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } }) await waitForDeferredWake() // then expect(promptAsyncCalls).toHaveLength(2) expect(promptAsyncCalls[0]?.body.noReply).toBe(true) expect(promptAsyncCalls[1]?.body.noReply).toBe(false) const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts) expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY") expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE") }) }) describe("#given a completed task with cleanup timer scheduled", () => { test("#when cleanup timer fires #then task is deleted from this.tasks Map", async () => { // given const { manager } = createManager(false) managerUnderTest = manager fakeTimers = installFakeTimers() const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) await notifyParentSessionForTest(manager, task) const cleanupTimer = getRequiredTimer(manager, task.id) // when expect(fakeTimers.getDelay(cleanupTimer)).toBe(TASK_CLEANUP_DELAY_MS) fakeTimers.run(cleanupTimer) // then expect(getCompletionTimers(manager).has(task.id)).toBe(false) expect(getTasks(manager).has(task.id)).toBe(false) }) }) })