Files
oh-my-opencode/src/features/background-agent/task-completion-cleanup.test.ts
T
2026-05-11 12:38:04 +09:00

347 lines
14 KiB
TypeScript

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<typeof setTimeout>) => number | undefined
run: (timer: ReturnType<typeof setTimeout>) => void
restore: () => void
}
let managerUnderTest: BackgroundManager | undefined
let fakeTimers: FakeTimers | undefined
afterEach(() => {
managerUnderTest?.shutdown()
fakeTimers?.restore()
managerUnderTest = undefined
fakeTimers = undefined
})
function createTask(overrides: Partial<BackgroundTask> & { 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<string, { type: string }>,
): {
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<ReturnType<typeof setTimeout>, () => void>()
const delays = new Map<ReturnType<typeof setTimeout>, number>()
globalThis.setTimeout = ((handler: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]): ReturnType<typeof setTimeout> => {
if (typeof handler !== "function") {
throw new Error("Expected function timeout handler")
}
const timer = originalSetTimeout(() => {}, 60_000)
originalClearTimeout(timer)
const callback = handler as (...callbackArgs: Array<unknown>) => void
callbacks.set(timer, () => callback(...args))
delays.set(timer, delay ?? 0)
return timer
}) as typeof setTimeout
globalThis.clearTimeout = ((timer: ReturnType<typeof setTimeout>): 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<string, BackgroundTask> {
return Reflect.get(manager, "tasks") as Map<string, BackgroundTask>
}
function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>> {
return Reflect.get(manager, "pendingByParent") as Map<string, Set<string>>
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return Reflect.get(manager, "completionTimers") as Map<string, ReturnType<typeof setTimeout>>
}
async function notifyParentSessionForTest(manager: BackgroundManager, task: BackgroundTask): Promise<void> {
const notifyParentSession = Reflect.get(manager, "notifyParentSession") as (task: BackgroundTask) => Promise<void>
return notifyParentSession.call(manager, task)
}
function waitForDeferredWake(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 180))
}
function waitForDeferredWakeRetry(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_180))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
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 background tasks for same parent", () => {
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<string, { type: string }> = {
"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<string, { type: string }> = {
"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")
})
test("#when a single background task finishes during a stale busy parent status #then wake prompt is sent after the parent becomes idle", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"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)
sessionStatuses["parent-1"] = { type: "idle" }
await waitForDeferredWakeRetry()
// 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)
})
})
})