import { tmpdir } from "node:os" import { afterEach, describe, expect, test } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { BackgroundManager } from "./manager" import type { BackgroundTask } from "./types" import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate" type PromptAsyncCall = { path: { id: string } body: { noReply?: boolean parts?: unknown[] } query?: { directory: string } } type PendingParentWakeForTest = { notifications: string[] shouldReply: boolean } let managerUnderTest: BackgroundManager | undefined afterEach(() => { managerUnderTest?.shutdown() releaseAllPromptAsyncReservationsForTesting() managerUnderTest = 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-05-20T14:19:10.000Z"), ...rest, id, parentSessionId: parentSessionID, } } function createManager(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 ctx: PluginInput = { client: client as PluginInput["client"], project: {} as PluginInput["project"], directory: tmpdir(), worktree: tmpdir(), serverUrl: new URL("http://localhost"), $: {} as PluginInput["$"], } const manager = new BackgroundManager({ pluginContext: ctx, config: undefined, enableParentSessionNotifications: true, }) return { manager, promptAsyncCalls } } 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 getPendingParentWakes(manager: BackgroundManager): Map { const parentWakeNotifier = Reflect.get(manager, "parentWakeNotifier") as { getPendingParentWakes: () => Map } return parentWakeNotifier.getPendingParentWakes() } async function notifyParentSessionForTest(manager: BackgroundManager, task: BackgroundTask): Promise { const notifyParentSession = Reflect.get(manager, "notifyParentSession") as (task: BackgroundTask) => Promise return notifyParentSession.call(manager, task) } async function flushPendingParentWakeForTest(manager: BackgroundManager, sessionID: string): Promise { const flushPendingParentWake = Reflect.get(manager, "flushPendingParentWake") as (sessionID: string) => Promise return flushPendingParentWake.call(manager, sessionID) } describe("BackgroundManager parent wake active turn events", () => { test("#when background task completes during active parent turn #then parent wake stays deferred", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, } const { manager, promptAsyncCalls } = createManager(sessionStatuses) managerUnderTest = manager const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-05-20T14:19:14.625Z"), }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) // when await notifyParentSessionForTest(manager, task) await flushPendingParentWakeForTest(manager, "parent-1") // then expect(promptAsyncCalls).toHaveLength(0) expect(getPendingParentWakes(manager).has("parent-1")).toBe(true) }) test("#when duplicate background completions overlap an active parent turn #then one coalesced wake dispatches after idle", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, } const { manager, promptAsyncCalls } = createManager(sessionStatuses) managerUnderTest = manager const taskA = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-05-20T14:19:14.625Z"), }) const taskB = createTask({ id: "task-b", parentSessionId: "parent-1", description: "task B", status: "completed", completedAt: new Date("2026-05-20T14:19:15.625Z"), }) getTasks(manager).set(taskA.id, taskA) getTasks(manager).set(taskB.id, taskB) getPendingByParent(manager).set(taskA.parentSessionId, new Set([taskA.id, taskB.id])) // when await notifyParentSessionForTest(manager, taskA) await notifyParentSessionForTest(manager, taskB) await Promise.all([ flushPendingParentWakeForTest(manager, "parent-1"), flushPendingParentWakeForTest(manager, "parent-1"), ]) // then expect(promptAsyncCalls).toHaveLength(0) expect(getPendingParentWakes(manager).has("parent-1")).toBe(true) // when sessionStatuses["parent-1"] = { type: "idle" } await Promise.all([ flushPendingParentWakeForTest(manager, "parent-1"), flushPendingParentWakeForTest(manager, "parent-1"), ]) // then expect(promptAsyncCalls).toHaveLength(1) expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("ALL BACKGROUND TASKS COMPLETE") expect(getPendingParentWakes(manager).has("parent-1")).toBe(false) }) test("#when background task fails during active parent turn #then parent wake stays deferred", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "busy" }, } const { manager, promptAsyncCalls } = createManager(sessionStatuses) managerUnderTest = manager const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "error", error: "UnknownError: UnknownError", completedAt: new Date("2026-05-20T14:19:14.625Z"), }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) // when await notifyParentSessionForTest(manager, task) await flushPendingParentWakeForTest(manager, "parent-1") // then expect(promptAsyncCalls).toHaveLength(0) expect(getPendingParentWakes(manager).has("parent-1")).toBe(true) }) test("#when parent reasoning delta is newer than stale idle state #then background completion does not fork a reply", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "idle" }, } const { manager, promptAsyncCalls } = createManager(sessionStatuses) managerUnderTest = manager manager.handleEvent({ type: "message.part.delta", properties: { sessionID: "parent-1", field: "reasoning", delta: "still thinking", }, }) const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-05-20T14:19:14.625Z"), }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) // when await notifyParentSessionForTest(manager, task) await flushPendingParentWakeForTest(manager, "parent-1") // then expect(promptAsyncCalls).toHaveLength(0) expect(getPendingParentWakes(manager).has("parent-1")).toBe(true) }) test("#when parent idle event follows fresh reasoning delta #then background completion still does not fork a reply", async () => { // given const sessionStatuses: Record = { "parent-1": { type: "idle" }, } const { manager, promptAsyncCalls } = createManager(sessionStatuses) managerUnderTest = manager manager.handleEvent({ type: "message.part.delta", properties: { sessionID: "parent-1", field: "reasoning", delta: "still thinking", }, }) const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-05-20T14:19:14.625Z"), }) getTasks(manager).set(task.id, task) getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) // when await notifyParentSessionForTest(manager, task) manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } }) await flushPendingParentWakeForTest(manager, "parent-1") // then expect(promptAsyncCalls).toHaveLength(0) expect(getPendingParentWakes(manager).has("parent-1")).toBe(true) }) })