Files
oh-my-opencode/src/features/background-agent/manager.test.ts
T
YeonGyu-Kim 3b41191980 fix(background-agent): honor explicit model override in manager
Keep BackgroundManager launch and resume from sending both agent and model so OpenCode does not override configured subagent models. Add launch and resume regressions for the live production path.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-24 20:28:01 +09:00

4800 lines
145 KiB
TypeScript

declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, ResumeInput } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
import { ConcurrencyManager } from "./concurrency"
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
const TASK_TTL_MS = 30 * 60 * 1000
class MockBackgroundManager {
private tasks: Map<string, BackgroundTask> = new Map()
private notifications: Map<string, BackgroundTask[]> = new Map()
public resumeCalls: Array<{ sessionId: string; prompt: string }> = []
addTask(task: BackgroundTask): void {
this.tasks.set(task.id, task)
}
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id)
}
findBySession(sessionID: string): BackgroundTask | undefined {
for (const task of this.tasks.values()) {
if (task.sessionID === sessionID) {
return task
}
}
return undefined
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
for (const task of this.tasks.values()) {
if (task.parentSessionID === sessionID) {
result.push(task)
}
}
return result
}
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
const directChildren = this.getTasksByParentSession(sessionID)
for (const child of directChildren) {
result.push(child)
if (child.sessionID) {
const descendants = this.getAllDescendantTasks(child.sessionID)
result.push(...descendants)
}
}
return result
}
markForNotification(task: BackgroundTask): void {
const queue = this.notifications.get(task.parentSessionID) ?? []
queue.push(task)
this.notifications.set(task.parentSessionID, queue)
}
getPendingNotifications(sessionID: string): BackgroundTask[] {
return this.notifications.get(sessionID) ?? []
}
private clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === 0) {
this.notifications.delete(sessionID)
} else {
this.notifications.set(sessionID, filtered)
}
}
}
pruneStaleTasksAndNotifications(): { prunedTasks: string[]; prunedNotifications: number } {
const now = Date.now()
const prunedTasks: string[] = []
let prunedNotifications = 0
for (const [taskId, task] of this.tasks.entries()) {
if (!task.startedAt) continue
const age = now - task.startedAt.getTime()
if (age > TASK_TTL_MS) {
prunedTasks.push(taskId)
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
}
}
for (const [sessionID, notifications] of this.notifications.entries()) {
if (notifications.length === 0) {
this.notifications.delete(sessionID)
continue
}
const validNotifications = notifications.filter((task) => {
if (!task.startedAt) return false
const age = now - task.startedAt.getTime()
return age <= TASK_TTL_MS
})
const removed = notifications.length - validNotifications.length
prunedNotifications += removed
if (validNotifications.length === 0) {
this.notifications.delete(sessionID)
} else if (validNotifications.length !== notifications.length) {
this.notifications.set(sessionID, validNotifications)
}
}
return { prunedTasks, prunedNotifications }
}
getTaskCount(): number {
return this.tasks.size
}
getNotificationCount(): number {
let count = 0
for (const notifications of this.notifications.values()) {
count += notifications.length
}
return count
}
resume(input: ResumeInput): BackgroundTask {
const existingTask = this.findBySession(input.sessionId)
if (!existingTask) {
throw new Error(`Task not found for session: ${input.sessionId}`)
}
if (existingTask.status === "running") {
return existingTask
}
this.resumeCalls.push({ sessionId: input.sessionId, prompt: input.prompt })
existingTask.status = "running"
existingTask.completedAt = undefined
existingTask.error = undefined
existingTask.parentSessionID = input.parentSessionID
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.progress = {
toolCalls: existingTask.progress?.toolCalls ?? 0,
lastUpdate: new Date(),
}
return existingTask
}
}
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask {
return {
parentMessageID: "mock-message-id",
description: "test task",
prompt: "test prompt",
agent: "test-agent",
status: "running",
startedAt: new Date(),
...overrides,
}
}
function createBackgroundManager(): BackgroundManager {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
}
function getConcurrencyManager(manager: BackgroundManager): ConcurrencyManager {
return (manager as unknown as { concurrencyManager: ConcurrencyManager }).concurrencyManager
}
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
}
function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>> {
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
}
function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
return (manager as unknown as { pendingNotifications: Map<string, string[]> }).pendingNotifications
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function getQueuesByKey(
manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
return (manager as unknown as {
queuesByKey: Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>>
}).queuesByKey
}
async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
return (manager as unknown as { processKey: (key: string) => Promise<void> }).processKey(key)
}
function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
;(manager as unknown as { pruneStaleTasksAndNotifications: () => void }).pruneStaleTasksAndNotifications()
}
async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
return (manager as unknown as { tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> })
.tryCompleteTask(task, "test")
}
function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
}
async function flushBackgroundNotifications(): Promise<void> {
for (let i = 0; i < 6; i++) {
await Promise.resolve()
}
}
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
_resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager({
tui: { showToast: async () => {} },
} as unknown as PluginInput["client"])
const removeTaskCalls: string[] = []
const originalRemoveTask = toastManager.removeTask.bind(toastManager)
toastManager.removeTask = (taskId: string): void => {
removeTaskCalls.push(taskId)
originalRemoveTask(taskId)
}
return {
removeTaskCalls,
resetToastManager: _resetTaskToastManagerForTesting,
}
}
function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
if (process.platform === "win32") {
signals.push("SIGBREAK")
}
return signals
}
function getListenerCounts(signals: Array<NodeJS.Signals | "beforeExit" | "exit">): Record<string, number> {
return Object.fromEntries(signals.map((signal) => [signal, process.listenerCount(signal)]))
}
describe("BackgroundManager.getAllDescendantTasks", () => {
let manager: MockBackgroundManager
beforeEach(() => {
// given
manager = new MockBackgroundManager()
})
test("should return empty array when no tasks exist", () => {
// given - empty manager
// when
const result = manager.getAllDescendantTasks("session-a")
// then
expect(result).toEqual([])
})
test("should return direct children only when no nested tasks", () => {
// given
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID: "session-a",
})
manager.addTask(taskB)
// when
const result = manager.getAllDescendantTasks("session-a")
// then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("task-b")
})
test("should return all nested descendants (2 levels deep)", () => {
// given
// Session A -> Task B -> Task C
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID: "session-a",
})
const taskC = createMockTask({
id: "task-c",
sessionID: "session-c",
parentSessionID: "session-b",
})
manager.addTask(taskB)
manager.addTask(taskC)
// when
const result = manager.getAllDescendantTasks("session-a")
// then
expect(result).toHaveLength(2)
expect(result.map(t => t.id)).toContain("task-b")
expect(result.map(t => t.id)).toContain("task-c")
})
test("should return all nested descendants (3 levels deep)", () => {
// given
// Session A -> Task B -> Task C -> Task D
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID: "session-a",
})
const taskC = createMockTask({
id: "task-c",
sessionID: "session-c",
parentSessionID: "session-b",
})
const taskD = createMockTask({
id: "task-d",
sessionID: "session-d",
parentSessionID: "session-c",
})
manager.addTask(taskB)
manager.addTask(taskC)
manager.addTask(taskD)
// when
const result = manager.getAllDescendantTasks("session-a")
// then
expect(result).toHaveLength(3)
expect(result.map(t => t.id)).toContain("task-b")
expect(result.map(t => t.id)).toContain("task-c")
expect(result.map(t => t.id)).toContain("task-d")
})
test("should handle multiple branches (tree structure)", () => {
// given
// Session A -> Task B1 -> Task C1
// -> Task B2 -> Task C2
const taskB1 = createMockTask({
id: "task-b1",
sessionID: "session-b1",
parentSessionID: "session-a",
})
const taskB2 = createMockTask({
id: "task-b2",
sessionID: "session-b2",
parentSessionID: "session-a",
})
const taskC1 = createMockTask({
id: "task-c1",
sessionID: "session-c1",
parentSessionID: "session-b1",
})
const taskC2 = createMockTask({
id: "task-c2",
sessionID: "session-c2",
parentSessionID: "session-b2",
})
manager.addTask(taskB1)
manager.addTask(taskB2)
manager.addTask(taskC1)
manager.addTask(taskC2)
// when
const result = manager.getAllDescendantTasks("session-a")
// then
expect(result).toHaveLength(4)
expect(result.map(t => t.id)).toContain("task-b1")
expect(result.map(t => t.id)).toContain("task-b2")
expect(result.map(t => t.id)).toContain("task-c1")
expect(result.map(t => t.id)).toContain("task-c2")
})
test("should not include tasks from unrelated sessions", () => {
// given
// Session A -> Task B
// Session X -> Task Y (unrelated)
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID: "session-a",
})
const taskY = createMockTask({
id: "task-y",
sessionID: "session-y",
parentSessionID: "session-x",
})
manager.addTask(taskB)
manager.addTask(taskY)
// when
const result = manager.getAllDescendantTasks("session-a")
// then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("task-b")
expect(result.map(t => t.id)).not.toContain("task-y")
})
test("getTasksByParentSession should only return direct children (not recursive)", () => {
// given
// Session A -> Task B -> Task C
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID: "session-a",
})
const taskC = createMockTask({
id: "task-c",
sessionID: "session-c",
parentSessionID: "session-b",
})
manager.addTask(taskB)
manager.addTask(taskC)
// when
const result = manager.getTasksByParentSession("session-a")
// then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("task-b")
})
})
describe("BackgroundManager.notifyParentSession - release ordering", () => {
test("should unblock queued task even when prompt hangs", async () => {
// given - concurrency limit 1, task1 running, task2 waiting
const { ConcurrencyManager } = await import("./concurrency")
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
await concurrencyManager.acquire("explore")
let task2Resolved = false
const task2Promise = concurrencyManager.acquire("explore").then(() => {
task2Resolved = true
})
await Promise.resolve()
expect(task2Resolved).toBe(false)
// when - simulate notifyParentSession: release BEFORE prompt (fixed behavior)
let promptStarted = false
const simulateNotifyParentSession = async () => {
concurrencyManager.release("explore")
promptStarted = true
await new Promise(() => {})
}
simulateNotifyParentSession()
await Promise.resolve()
await Promise.resolve()
// then - task2 should be unblocked even though prompt never completes
expect(promptStarted).toBe(true)
await task2Promise
expect(task2Resolved).toBe(true)
})
test("should keep queue blocked if release is after prompt (demonstrates the bug)", async () => {
// given - same setup
const { ConcurrencyManager } = await import("./concurrency")
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
await concurrencyManager.acquire("explore")
let task2Resolved = false
concurrencyManager.acquire("explore").then(() => {
task2Resolved = true
})
await Promise.resolve()
expect(task2Resolved).toBe(false)
// when - simulate BUGGY behavior: release AFTER prompt (in finally)
const simulateBuggyNotifyParentSession = async () => {
try {
await new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 50))
} finally {
concurrencyManager.release("explore")
}
}
await simulateBuggyNotifyParentSession().catch(() => {})
// then - task2 resolves only after prompt completes (blocked during hang)
await Promise.resolve()
expect(task2Resolved).toBe(true)
})
})
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
let manager: MockBackgroundManager
beforeEach(() => {
// given
manager = new MockBackgroundManager()
})
test("should not prune fresh tasks", () => {
// given
const task = createMockTask({
id: "task-fresh",
sessionID: "session-fresh",
parentSessionID: "session-parent",
startedAt: new Date(),
})
manager.addTask(task)
// when
const result = manager.pruneStaleTasksAndNotifications()
// then
expect(result.prunedTasks).toHaveLength(0)
expect(manager.getTaskCount()).toBe(1)
})
test("should prune tasks older than 30 minutes", () => {
// given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.addTask(task)
// when
const result = manager.pruneStaleTasksAndNotifications()
// then
expect(result.prunedTasks).toContain("task-stale")
expect(manager.getTaskCount()).toBe(0)
})
test("should prune stale notifications", () => {
// given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.markForNotification(task)
// when
const result = manager.pruneStaleTasksAndNotifications()
// then
expect(result.prunedNotifications).toBe(1)
expect(manager.getNotificationCount()).toBe(0)
})
test("should clean up notifications when task is pruned", () => {
// given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.addTask(task)
manager.markForNotification(task)
// when
manager.pruneStaleTasksAndNotifications()
// then
expect(manager.getTaskCount()).toBe(0)
expect(manager.getNotificationCount()).toBe(0)
})
test("should keep fresh tasks while pruning stale ones", () => {
// given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const staleTask = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
const freshTask = createMockTask({
id: "task-fresh",
sessionID: "session-fresh",
parentSessionID: "session-parent",
startedAt: new Date(),
})
manager.addTask(staleTask)
manager.addTask(freshTask)
// when
const result = manager.pruneStaleTasksAndNotifications()
// then
expect(result.prunedTasks).toHaveLength(1)
expect(result.prunedTasks).toContain("task-stale")
expect(manager.getTaskCount()).toBe(1)
expect(manager.getTask("task-fresh")).toBeDefined()
})
})
describe("BackgroundManager.resume", () => {
let manager: MockBackgroundManager
beforeEach(() => {
// given
manager = new MockBackgroundManager()
})
test("should throw error when task not found", () => {
// given - empty manager
// when / #then
expect(() => manager.resume({
sessionId: "non-existent",
prompt: "continue",
parentSessionID: "session-new",
parentMessageID: "msg-new",
})).toThrow("Task not found for session: non-existent")
})
test("should resume existing task and reset state to running", () => {
// given
const completedTask = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID: "session-parent",
status: "completed",
})
completedTask.completedAt = new Date()
completedTask.error = "previous error"
manager.addTask(completedTask)
// when
const result = manager.resume({
sessionId: "session-a",
prompt: "continue the work",
parentSessionID: "session-new-parent",
parentMessageID: "msg-new",
})
// then
expect(result.status).toBe("running")
expect(result.completedAt).toBeUndefined()
expect(result.error).toBeUndefined()
expect(result.parentSessionID).toBe("session-new-parent")
expect(result.parentMessageID).toBe("msg-new")
})
test("should preserve task identity while updating parent context", () => {
// given
const existingTask = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID: "old-parent",
description: "original description",
agent: "explore",
status: "completed",
})
manager.addTask(existingTask)
// when
const result = manager.resume({
sessionId: "session-a",
prompt: "new prompt",
parentSessionID: "new-parent",
parentMessageID: "new-msg",
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
})
// then
expect(result.id).toBe("task-a")
expect(result.sessionID).toBe("session-a")
expect(result.description).toBe("original description")
expect(result.agent).toBe("explore")
expect(result.parentModel).toEqual({ providerID: "anthropic", modelID: "claude-opus" })
})
test("should track resume calls with prompt", () => {
// given
const task = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID: "session-parent",
status: "completed",
})
manager.addTask(task)
// when
manager.resume({
sessionId: "session-a",
prompt: "continue with additional context",
parentSessionID: "session-new",
parentMessageID: "msg-new",
})
// then
expect(manager.resumeCalls).toHaveLength(1)
expect(manager.resumeCalls[0]).toEqual({
sessionId: "session-a",
prompt: "continue with additional context",
})
})
test("should preserve existing tool call count in progress", () => {
// given
const taskWithProgress = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID: "session-parent",
status: "completed",
})
taskWithProgress.progress = {
toolCalls: 42,
lastTool: "read",
lastUpdate: new Date(),
}
manager.addTask(taskWithProgress)
// when
const result = manager.resume({
sessionId: "session-a",
prompt: "continue",
parentSessionID: "session-new",
parentMessageID: "msg-new",
})
// then
expect(result.progress?.toolCalls).toBe(42)
})
test("should ignore resume when task is already running", () => {
// given
const runningTask = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID: "session-parent",
status: "running",
})
manager.addTask(runningTask)
// when
const result = manager.resume({
sessionId: "session-a",
prompt: "resume should be ignored",
parentSessionID: "new-parent",
parentMessageID: "new-msg",
})
// then
expect(result.parentSessionID).toBe("session-parent")
expect(manager.resumeCalls).toHaveLength(0)
})
})
describe("LaunchInput.skillContent", () => {
test("skillContent should be optional in LaunchInput type", () => {
// given
const input: import("./types").LaunchInput = {
description: "test",
prompt: "test prompt",
agent: "explore",
parentSessionID: "parent-session",
parentMessageID: "parent-msg",
}
// when / #then - should compile without skillContent
expect(input.skillContent).toBeUndefined()
})
test("skillContent can be provided in LaunchInput", () => {
// given
const input: import("./types").LaunchInput = {
description: "test",
prompt: "test prompt",
agent: "explore",
parentSessionID: "parent-session",
parentMessageID: "parent-msg",
skillContent: "You are a playwright expert",
}
// when / #then
expect(input.skillContent).toBe("You are a playwright expert")
})
})
interface CurrentMessage {
agent?: string
model?: { providerID?: string; modelID?: string }
}
describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => {
test("should skip compaction agent and use nearest non-compaction message", async () => {
//#given
let capturedBody: Record<string, unknown> | undefined
const client = {
session: {
prompt: async () => ({}),
promptAsync: async (args: { body: Record<string, unknown> }) => {
capturedBody = args.body
return {}
},
abort: async () => ({}),
messages: async () => ({
data: [
{
info: {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
},
},
{
info: {
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
},
},
],
}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-skip-compaction",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task with compaction at tail",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "fallback-agent",
}
getPendingByParent(manager).set("session-parent", new Set([task.id, "still-running"]))
//#when
await (manager as unknown as { notifyParentSession: (value: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(capturedBody?.agent).toBe("sisyphus")
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
manager.shutdown()
})
test("should use currentMessage model/agent when available", async () => {
// given - currentMessage has model and agent
const task: BackgroundTask = {
id: "task-1",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task with dynamic lookup",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "OldAgent",
parentModel: { providerID: "old", modelID: "old-model" },
}
const currentMessage: CurrentMessage = {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
// when
const promptBody = buildNotificationPromptBody(task, currentMessage)
// then - uses currentMessage values, not task.parentModel/parentAgent
expect(promptBody.agent).toBe("sisyphus")
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})
test("should fallback to parentAgent when currentMessage.agent is undefined", async () => {
// given
const task: BackgroundTask = {
id: "task-2",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task fallback agent",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "FallbackAgent",
parentModel: undefined,
}
const currentMessage: CurrentMessage = { agent: undefined, model: undefined }
// when
const promptBody = buildNotificationPromptBody(task, currentMessage)
// then - falls back to task.parentAgent
expect(promptBody.agent).toBe("FallbackAgent")
expect("model" in promptBody).toBe(false)
})
test("should not pass model when currentMessage.model is incomplete", async () => {
// given - model missing modelID
const task: BackgroundTask = {
id: "task-3",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task incomplete model",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "sisyphus",
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
}
const currentMessage: CurrentMessage = {
agent: "sisyphus",
model: { providerID: "anthropic" },
}
// when
const promptBody = buildNotificationPromptBody(task, currentMessage)
// then - model not passed due to incomplete data
expect(promptBody.agent).toBe("sisyphus")
expect("model" in promptBody).toBe(false)
})
test("should handle null currentMessage gracefully", async () => {
// given - no message found (messageDir lookup failed)
const task: BackgroundTask = {
id: "task-4",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task no message",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "sisyphus",
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
}
// when
const promptBody = buildNotificationPromptBody(task, null)
// then - falls back to task.parentAgent, no model
expect(promptBody.agent).toBe("sisyphus")
expect("model" in promptBody).toBe(false)
})
})
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should fall back and still notify when parent session messages are aborted", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
promptCalled = true
return {}
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
messages: async () => {
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-parent",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task aborted parent",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id, "task-remaining"]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(true)
manager.shutdown()
})
test("should swallow aborted error from prompt", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
promptCalled = true
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-prompt",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task aborted prompt",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(true)
manager.shutdown()
})
test("should queue notification when promptAsync aborts while parent is idle", async () => {
//#given
const promptMock = async () => {
const error = new Error("Request aborted while waiting for input")
error.name = "MessageAbortedError"
throw error
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-idle-queue",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task idle queue",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
expect(queuedNotifications).toHaveLength(1)
expect(queuedNotifications[0]).toContain("<system-reminder>")
expect(queuedNotifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
manager.shutdown()
})
})
describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
test("should skip parent prompt injection when notifications are disabled", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
promptCalled = true
return {}
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
undefined,
{ enableParentSessionNotifications: false },
)
const task: BackgroundTask = {
id: "task-no-parent-notification",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task notifications disabled",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(false)
manager.shutdown()
})
})
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
test("should prepend queued notifications to first text part and clear queue", () => {
// given
const manager = createBackgroundManager()
manager.queuePendingNotification("session-parent", "<system-reminder>queued-one</system-reminder>")
manager.queuePendingNotification("session-parent", "<system-reminder>queued-two</system-reminder>")
const output = {
parts: [{ type: "text", text: "User prompt" }],
}
// when
manager.injectPendingNotificationsIntoChatMessage(output, "session-parent")
// then
expect(output.parts[0].text).toContain("<system-reminder>queued-one</system-reminder>")
expect(output.parts[0].text).toContain("<system-reminder>queued-two</system-reminder>")
expect(output.parts[0].text).toContain("User prompt")
expect(getPendingNotifications(manager).get("session-parent")).toBeUndefined()
manager.shutdown()
})
})
function buildNotificationPromptBody(
task: BackgroundTask,
currentMessage: CurrentMessage | null
): Record<string, unknown> {
const body: Record<string, unknown> = {
parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }],
}
const agent = currentMessage?.agent ?? task.parentAgent
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
if (agent !== undefined) {
body.agent = agent
}
if (model !== undefined) {
body.model = model
}
return body
}
describe("BackgroundManager.tryCompleteTask", () => {
let manager: BackgroundManager
beforeEach(() => {
// given
manager = createBackgroundManager()
stubNotifyParentSession(manager)
})
afterEach(() => {
manager.shutdown()
})
test("should release concurrency and clear key on completion", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
await concurrencyManager.acquire(concurrencyKey)
const task: BackgroundTask = {
id: "task-1",
sessionID: "session-1",
parentSessionID: "session-parent",
parentMessageID: "msg-1",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
concurrencyKey,
}
// when
const completed = await tryCompleteTaskForTest(manager, task)
// then
expect(completed).toBe(true)
expect(task.status).toBe("completed")
expect(task.concurrencyKey).toBeUndefined()
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
})
test("should prevent double completion and double release", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
await concurrencyManager.acquire(concurrencyKey)
const task: BackgroundTask = {
id: "task-1",
sessionID: "session-1",
parentSessionID: "session-parent",
parentMessageID: "msg-1",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
concurrencyKey,
}
// when
await tryCompleteTaskForTest(manager, task)
const secondAttempt = await tryCompleteTaskForTest(manager, task)
// then
expect(secondAttempt).toBe(false)
expect(task.status).toBe("completed")
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
})
test("should abort session on completion", async () => {
// #given
const abortedSessionIDs: string[] = []
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async (args: { path: { id: string } }) => {
abortedSessionIDs.push(args.path.id)
return {}
},
messages: async () => ({ data: [] }),
},
}
manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-1",
sessionID: "session-1",
parentSessionID: "session-parent",
parentMessageID: "msg-1",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
}
// #when
await tryCompleteTaskForTest(manager, task)
// #then
expect(abortedSessionIDs).toEqual(["session-1"])
})
test("should clean pendingByParent even when promptAsync notification fails", async () => {
// given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => {
throw new Error("notify failed")
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-pending-cleanup",
sessionID: "session-pending-cleanup",
parentSessionID: "parent-pending-cleanup",
parentMessageID: "msg-1",
description: "pending cleanup task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
// when
await tryCompleteTaskForTest(manager, task)
// then
expect(task.status).toBe("completed")
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
})
test("should remove toast tracking before notifying completed task", async () => {
// given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const task: BackgroundTask = {
id: "task-toast-complete",
sessionID: "session-toast-complete",
parentSessionID: "parent-toast-complete",
parentMessageID: "msg-1",
description: "toast completion task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
}
try {
// when
await tryCompleteTaskForTest(manager, task)
// then
expect(removeTaskCalls).toContain(task.id)
} finally {
resetToastManager()
}
})
test("should release task concurrencyKey when startTask throws after assigning it", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
id: "task-process-key-concurrency",
sessionID: "session-process-key-concurrency",
parentSessionID: "parent-process-key-concurrency",
status: "pending",
agent: "explore",
})
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
;(manager as unknown as { startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }).startTask = async (item) => {
item.task.concurrencyKey = concurrencyKey
throw new Error("startTask failed after assigning concurrencyKey")
}
// when
await processKeyForTest(manager, concurrencyKey)
// then
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(task.concurrencyKey).toBeUndefined()
})
test("should release queue slot when queued task is already interrupt", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
id: "task-process-key-interrupt",
sessionID: "session-process-key-interrupt",
parentSessionID: "parent-process-key-interrupt",
status: "interrupt",
agent: "explore",
})
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
// when
await processKeyForTest(manager, concurrencyKey)
// then
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getQueuesByKey(manager).get(concurrencyKey)).toEqual([])
})
test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => {
// given
type PromptAsyncBody = Record<string, unknown> & { noReply?: boolean }
let resolveMessages: ((value: { data: unknown[] }) => void) | undefined
const messagesBarrier = new Promise<{ data: unknown[] }>((resolve) => {
resolveMessages = resolve
})
const promptBodies: PromptAsyncBody[] = []
let promptInFlight = false
let rejectedCount = 0
let promptCallCount = 0
let releaseFirstPrompt: (() => void) | undefined
let resolveFirstStarted: (() => void) | undefined
const firstStarted = new Promise<void>((resolve) => {
resolveFirstStarted = resolve
})
const client = {
session: {
prompt: async () => ({}),
abort: async () => ({}),
messages: async () => messagesBarrier,
promptAsync: async (args: { path: { id: string }; body: PromptAsyncBody }) => {
promptBodies.push(args.body)
if (!promptInFlight) {
promptCallCount += 1
if (promptCallCount === 1) {
promptInFlight = true
resolveFirstStarted?.()
return await new Promise((resolve) => {
releaseFirstPrompt = () => {
promptInFlight = false
resolve({})
}
})
}
return {}
}
rejectedCount += 1
throw new Error("BUSY")
},
},
}
manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const parentSessionID = "parent-session"
const taskA = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID,
})
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID,
})
getTaskMap(manager).set(taskA.id, taskA)
getTaskMap(manager).set(taskB.id, taskB)
getPendingByParent(manager).set(parentSessionID, new Set([taskA.id, taskB.id]))
// when
const completionA = tryCompleteTaskForTest(manager, taskA)
const completionB = tryCompleteTaskForTest(manager, taskB)
resolveMessages?.({ data: [] })
await firstStarted
// Give the second completion a chance to attempt promptAsync while the first is in-flight.
// In the buggy implementation, this triggers an overlap and increments rejectedCount.
for (let i = 0; i < 20; i++) {
await Promise.resolve()
if (rejectedCount > 0) break
if (promptBodies.length >= 2) break
}
releaseFirstPrompt?.()
await Promise.all([completionA, completionB])
// then
expect(rejectedCount).toBe(0)
expect(promptBodies.length).toBe(2)
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
})
})
describe("BackgroundManager.trackTask", () => {
let manager: BackgroundManager
beforeEach(() => {
// given
manager = createBackgroundManager()
stubNotifyParentSession(manager)
})
afterEach(() => {
manager.shutdown()
})
test("should not double acquire on duplicate registration", async () => {
// given
const input = {
taskId: "task-1",
sessionID: "session-1",
parentSessionID: "parent-session",
description: "external task",
agent: "task",
concurrencyKey: "external-key",
}
// when
await manager.trackTask(input)
await manager.trackTask(input)
// then
const concurrencyManager = getConcurrencyManager(manager)
expect(concurrencyManager.getCount("external-key")).toBe(1)
expect(getTaskMap(manager).size).toBe(1)
})
})
describe("BackgroundManager.resume concurrency key", () => {
let manager: BackgroundManager
beforeEach(() => {
// given
manager = createBackgroundManager()
stubNotifyParentSession(manager)
})
afterEach(() => {
manager.shutdown()
})
test("should re-acquire using external task concurrency key", async () => {
// given
const task = await manager.trackTask({
taskId: "task-1",
sessionID: "session-1",
parentSessionID: "parent-session",
description: "external task",
agent: "task",
concurrencyKey: "external-key",
})
await tryCompleteTaskForTest(manager, task)
// when
await manager.resume({
sessionId: "session-1",
prompt: "resume",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
// then
const concurrencyManager = getConcurrencyManager(manager)
expect(concurrencyManager.getCount("external-key")).toBe(1)
expect(task.concurrencyKey).toBe("external-key")
})
})
describe("BackgroundManager.resume model persistence", () => {
let manager: BackgroundManager
let promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }>
beforeEach(() => {
// given
promptCalls = []
const promptMock = async (args: { path: { id: string }; body: Record<string, unknown> }) => {
promptCalls.push(args)
return {}
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
},
}
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
})
afterEach(() => {
manager.shutdown()
})
test("should pass model when task has a configured model", async () => {
// given - task with model from category config
const taskWithModel: BackgroundTask = {
id: "task-with-model",
sessionID: "session-1",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task with model override",
prompt: "original prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
concurrencyGroup: "explore",
}
getTaskMap(manager).set(taskWithModel.id, taskWithModel)
// when
await manager.resume({
sessionId: "session-1",
prompt: "continue the work",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
// then - model should be passed in prompt body
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })
expect("agent" in promptCalls[0].body).toBe(false)
})
test("should NOT pass model when task has no model (backward compatibility)", async () => {
// given - task without model (default behavior)
const taskWithoutModel: BackgroundTask = {
id: "task-no-model",
sessionID: "session-2",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task without model",
prompt: "original prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
concurrencyGroup: "explore",
}
getTaskMap(manager).set(taskWithoutModel.id, taskWithoutModel)
// when
await manager.resume({
sessionId: "session-2",
prompt: "continue the work",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
// then - model should NOT be in prompt body
expect(promptCalls).toHaveLength(1)
expect("model" in promptCalls[0].body).toBe(false)
expect(promptCalls[0].body.agent).toBe("explore")
})
})
describe("BackgroundManager process cleanup", () => {
test("should remove listeners after last shutdown", () => {
// given
const signals = getCleanupSignals()
const baseline = getListenerCounts(signals)
const managerA = createBackgroundManager()
const managerB = createBackgroundManager()
// when
const afterCreate = getListenerCounts(signals)
managerA.shutdown()
const afterFirstShutdown = getListenerCounts(signals)
managerB.shutdown()
const afterSecondShutdown = getListenerCounts(signals)
// then
for (const signal of signals) {
expect(afterCreate[signal]).toBe(baseline[signal] + 1)
expect(afterFirstShutdown[signal]).toBe(baseline[signal] + 1)
expect(afterSecondShutdown[signal]).toBe(baseline[signal])
}
})
})
describe("BackgroundManager - Non-blocking Queue Integration", () => {
let manager: BackgroundManager
let mockClient: ReturnType<typeof createMockClient>
function createMockClient() {
return {
session: {
create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async () => ({ data: { directory: "/test/dir" } }),
prompt: async () => ({}),
promptAsync: async () => ({}),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
}
}
function createMockClientWithSessionChain(
sessions: Record<string, { directory: string; parentID?: string }>,
options?: { sessionLookupError?: Error }
) {
return {
session: {
create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async ({ path }: { path: { id: string } }) => {
if (options?.sessionLookupError) {
throw options.sessionLookupError
}
return {
data: sessions[path.id] ?? { directory: "/test/dir" },
}
},
prompt: async () => ({}),
promptAsync: async () => ({}),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
}
}
beforeEach(() => {
// given
mockClient = createMockClient()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput)
})
afterEach(() => {
manager.shutdown()
})
describe("launch() returns immediately with pending status", () => {
test("should return task with pending status immediately", async () => {
// given
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
// then
expect(task.status).toBe("pending")
expect(task.id).toMatch(/^bg_/)
expect(task.description).toBe("Test task")
expect(task.agent).toBe("test-agent")
expect(task.queuedAt).toBeInstanceOf(Date)
expect(task.startedAt).toBeUndefined()
expect(task.sessionID).toBeUndefined()
})
test("should return immediately even with concurrency limit", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const startTime = Date.now()
const task1 = await manager.launch(input)
const task2 = await manager.launch(input)
const endTime = Date.now()
// then
expect(endTime - startTime).toBeLessThan(100) // Should be instant
expect(task1.status).toBe("pending")
expect(task2.status).toBe("pending")
})
test("should omit agent when launch has model and keep agent without model", async () => {
// given
const promptBodies: Array<Record<string, unknown>> = []
let resolveFirstPromptStarted: (() => void) | undefined
let resolveSecondPromptStarted: (() => void) | undefined
const firstPromptStarted = new Promise<void>((resolve) => {
resolveFirstPromptStarted = resolve
})
const secondPromptStarted = new Promise<void>((resolve) => {
resolveSecondPromptStarted = resolve
})
const customClient = {
session: {
create: async (_args?: unknown) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async () => ({ data: { directory: "/test/dir" } }),
prompt: async () => ({}),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
promptBodies.push(args.body)
if (promptBodies.length === 1) {
resolveFirstPromptStarted?.()
}
if (promptBodies.length === 2) {
resolveSecondPromptStarted?.()
}
return {}
},
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
}
manager.shutdown()
manager = new BackgroundManager({ client: customClient, directory: tmpdir() } as unknown as PluginInput)
const launchInputWithModel = {
description: "Test task with model",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
const launchInputWithoutModel = {
description: "Test task without model",
prompt: "Do something else",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const taskWithModel = await manager.launch(launchInputWithModel)
await firstPromptStarted
const taskWithoutModel = await manager.launch(launchInputWithoutModel)
await secondPromptStarted
// then
expect(taskWithModel.status).toBe("pending")
expect(taskWithoutModel.status).toBe("pending")
expect(promptBodies).toHaveLength(2)
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
expect("agent" in promptBodies[0]).toBe(false)
expect(promptBodies[1].agent).toBe("test-agent")
expect("model" in promptBodies[1]).toBe(false)
})
test("should queue multiple tasks without blocking", async () => {
// given
const config = { defaultConcurrency: 2 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const tasks = await Promise.all([
manager.launch(input),
manager.launch(input),
manager.launch(input),
manager.launch(input),
manager.launch(input),
])
// then
expect(tasks).toHaveLength(5)
tasks.forEach(task => {
expect(task.status).toBe("pending")
expect(task.queuedAt).toBeInstanceOf(Date)
})
})
})
describe("task transitions pending→running when slot available", () => {
test("does not override parent session permission when creating child session", async () => {
// given
const createCalls: any[] = []
const parentPermission = [
{ permission: "question", action: "allow" as const, pattern: "*" },
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
]
const customClient = {
session: {
create: async (args?: any) => {
createCalls.push(args)
return { data: { id: `ses_${crypto.randomUUID()}` } }
},
get: async () => ({ data: { directory: "/test/dir", permission: parentPermission } }),
prompt: async () => ({}),
promptAsync: async () => ({}),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
}
manager.shutdown()
manager = new BackgroundManager({ client: customClient, directory: tmpdir() } as unknown as PluginInput, {
defaultConcurrency: 5,
})
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
await manager.launch(input)
await new Promise(resolve => setTimeout(resolve, 50))
// then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toBeUndefined()
})
test("should transition first task to running immediately", async () => {
// given
const config = { defaultConcurrency: 5 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
// Give processKey time to run
await new Promise(resolve => setTimeout(resolve, 50))
// then
const updatedTask = manager.getTask(task.id)
expect(updatedTask?.status).toBe("running")
expect(updatedTask?.startedAt).toBeInstanceOf(Date)
expect(updatedTask?.sessionID).toBeDefined()
expect(updatedTask?.sessionID).toBeTruthy()
})
test("should set startedAt when transitioning to running", async () => {
// given
const config = { defaultConcurrency: 5 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
const queuedAt = task.queuedAt
// Wait for transition
await new Promise(resolve => setTimeout(resolve, 50))
// then
const updatedTask = manager.getTask(task.id)
expect(updatedTask?.startedAt).toBeInstanceOf(Date)
if (updatedTask?.startedAt && queuedAt) {
expect(updatedTask.startedAt.getTime()).toBeGreaterThanOrEqual(queuedAt.getTime())
}
})
test("should track rootSessionID and spawnDepth from the parent chain", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDepth: 3 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-depth-2",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
// then
expect(task.rootSessionID).toBe("session-root")
expect(task.spawnDepth).toBe(3)
})
test("should block launches that exceed maxDepth", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" },
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDepth: 3 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-depth-3",
parentMessageID: "parent-message",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDepth=3")
})
test("should block launches when maxDescendants is reached", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
await manager.launch(input)
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
})
test("should consume descendant quota for reserved sync spawns", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
await manager.reserveSubagentSpawn("session-root")
// when
const result = manager.assertCanSpawn("session-root")
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
})
test("should fail closed when session lineage lookup fails", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain(
{
"session-root": { directory: "/test/dir" },
},
{ sessionLookupError: new Error("session lookup failed") }
),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
})
test("should release descendant quota when queued task is cancelled before session starts", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ defaultConcurrency: 1, maxDescendants: 2 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
await manager.launch(input)
const queuedTask = await manager.launch(input)
await new Promise(resolve => setTimeout(resolve, 50))
expect(manager.getTask(queuedTask.id)?.status).toBe("pending")
// when
const cancelled = manager.cancelPendingTask(queuedTask.id)
const replacementTask = await manager.launch(input)
// then
expect(cancelled).toBe(true)
expect(replacementTask.status).toBe("pending")
})
test("should release descendant quota when session creation fails before session starts", async () => {
// given
let createAttempts = 0
manager.shutdown()
manager = new BackgroundManager(
{
client: {
session: {
create: async () => {
createAttempts += 1
if (createAttempts === 1) {
return { error: "session create failed", data: undefined }
}
return { data: { id: `ses_${crypto.randomUUID()}` } }
},
get: async () => ({ data: { directory: "/test/dir" } }),
prompt: async () => ({}),
promptAsync: async () => ({}),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
},
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
await manager.launch(input)
await new Promise(resolve => setTimeout(resolve, 50))
expect(createAttempts).toBe(1)
// when
const retryTask = await manager.launch(input)
// then
expect(retryTask.status).toBe("pending")
})
test("should keep the next queued task when the first task is cancelled during session creation", async () => {
// given
const firstSessionID = "ses-first-cancelled-during-create"
const secondSessionID = "ses-second-survives-queue"
let createCallCount = 0
let resolveFirstCreate: ((value: { data: { id: string } }) => void) | undefined
let resolveFirstCreateStarted: (() => void) | undefined
let resolveSecondPromptAsync: (() => void) | undefined
const firstCreateStarted = new Promise<void>((resolve) => {
resolveFirstCreateStarted = resolve
})
const secondPromptAsyncStarted = new Promise<void>((resolve) => {
resolveSecondPromptAsync = resolve
})
manager.shutdown()
manager = new BackgroundManager(
{
client: {
session: {
create: async () => {
createCallCount += 1
if (createCallCount === 1) {
resolveFirstCreateStarted?.()
return await new Promise<{ data: { id: string } }>((resolve) => {
resolveFirstCreate = resolve
})
}
return { data: { id: secondSessionID } }
},
get: async () => ({ data: { directory: "/test/dir" } }),
prompt: async () => ({}),
promptAsync: async ({ path }: { path: { id: string } }) => {
if (path.id === secondSessionID) {
resolveSecondPromptAsync?.()
}
return {}
},
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
},
directory: tmpdir(),
} as unknown as PluginInput,
{ defaultConcurrency: 1 }
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const firstTask = await manager.launch(input)
const secondTask = await manager.launch(input)
await firstCreateStarted
// when
const cancelled = await manager.cancelTask(firstTask.id, {
source: "test",
abortSession: false,
})
resolveFirstCreate?.({ data: { id: firstSessionID } })
await Promise.race([
secondPromptAsyncStarted,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 100)),
])
// then
expect(cancelled).toBe(true)
expect(createCallCount).toBe(2)
expect(manager.getTask(firstTask.id)?.status).toBe("cancelled")
expect(manager.getTask(secondTask.id)?.status).toBe("running")
expect(manager.getTask(secondTask.id)?.sessionID).toBe(secondSessionID)
})
test("should keep task cancelled and abort the session when cancellation wins during session creation", async () => {
// given
const createdSessionID = "ses-cancelled-during-create"
let resolveCreate: ((value: { data: { id: string } }) => void) | undefined
let resolveCreateStarted: (() => void) | undefined
let resolveAbortCalled: (() => void) | undefined
const createStarted = new Promise<void>((resolve) => {
resolveCreateStarted = resolve
})
const abortCalled = new Promise<void>((resolve) => {
resolveAbortCalled = resolve
})
const abortCalls: string[] = []
const promptAsyncSessionIDs: string[] = []
manager.shutdown()
manager = new BackgroundManager(
{
client: {
session: {
create: async () => {
resolveCreateStarted?.()
return await new Promise<{ data: { id: string } }>((resolve) => {
resolveCreate = resolve
})
},
get: async () => ({ data: { directory: "/test/dir" } }),
prompt: async () => ({}),
promptAsync: async ({ path }: { path: { id: string } }) => {
promptAsyncSessionIDs.push(path.id)
return {}
},
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async ({ path }: { path: { id: string } }) => {
abortCalls.push(path.id)
resolveAbortCalled?.()
return {}
},
},
},
directory: tmpdir(),
} as unknown as PluginInput,
{ defaultConcurrency: 1 }
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const task = await manager.launch(input)
await createStarted
// when
const cancelled = await manager.cancelTask(task.id, {
source: "test",
abortSession: false,
})
resolveCreate?.({ data: { id: createdSessionID } })
await Promise.race([
abortCalled,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 100)),
])
await Promise.resolve()
// then
const updatedTask = manager.getTask(task.id)
expect(cancelled).toBe(true)
expect(updatedTask?.status).toBe("cancelled")
expect(updatedTask?.sessionID).toBeUndefined()
expect(promptAsyncSessionIDs).not.toContain(createdSessionID)
expect(abortCalls).toEqual([createdSessionID])
expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0)
})
})
describe("pending task can be cancelled", () => {
test("should cancel pending task successfully", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const task1 = await manager.launch(input)
const task2 = await manager.launch(input)
// Wait for first task to start
await new Promise(resolve => setTimeout(resolve, 50))
// when
const cancelled = manager.cancelPendingTask(task2.id)
// then
expect(cancelled).toBe(true)
const updatedTask2 = manager.getTask(task2.id)
expect(updatedTask2?.status).toBe("cancelled")
expect(updatedTask2?.completedAt).toBeInstanceOf(Date)
})
test("should not cancel running task", async () => {
// given
const config = { defaultConcurrency: 5 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const task = await manager.launch(input)
// Wait for task to start
await new Promise(resolve => setTimeout(resolve, 50))
// when
const cancelled = manager.cancelPendingTask(task.id)
// then
expect(cancelled).toBe(false)
const updatedTask = manager.getTask(task.id)
expect(updatedTask?.status).toBe("running")
})
test("should remove cancelled task from queue", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const task1 = await manager.launch(input)
const task2 = await manager.launch(input)
const task3 = await manager.launch(input)
// Wait for first task to start
await new Promise(resolve => setTimeout(resolve, 100))
// when - cancel middle task
const cancelledTask2 = manager.getTask(task2.id)
expect(cancelledTask2?.status).toBe("pending")
manager.cancelPendingTask(task2.id)
const afterCancel = manager.getTask(task2.id)
expect(afterCancel?.status).toBe("cancelled")
// then - verify task3 is still pending (task1 still running)
const task3BeforeRelease = manager.getTask(task3.id)
expect(task3BeforeRelease?.status).toBe("pending")
})
})
describe("cancelTask", () => {
test("should cancel running task and release concurrency", async () => {
// given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
await concurrencyManager.acquire(concurrencyKey)
const task = createMockTask({
id: "task-cancel-running",
sessionID: "session-cancel-running",
parentSessionID: "parent-cancel",
status: "running",
concurrencyKey,
})
getTaskMap(manager).set(task.id, task)
const pendingByParent = getPendingByParent(manager)
pendingByParent.set(task.parentSessionID, new Set([task.id]))
// when
const cancelled = await manager.cancelTask(task.id, { source: "test" })
// then
const updatedTask = manager.getTask(task.id)
expect(cancelled).toBe(true)
expect(updatedTask?.status).toBe("cancelled")
expect(updatedTask?.completedAt).toBeInstanceOf(Date)
expect(updatedTask?.concurrencyKey).toBeUndefined()
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
const pendingSet = pendingByParent.get(task.parentSessionID)
expect(pendingSet?.has(task.id) ?? false).toBe(false)
})
test("should remove task from toast manager when notification is skipped", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const task = createMockTask({
id: "task-cancel-skip-notification",
sessionID: "session-cancel-skip-notification",
parentSessionID: "parent-cancel-skip-notification",
status: "running",
})
getTaskMap(manager).set(task.id, task)
//#when
const cancelled = await manager.cancelTask(task.id, {
source: "test",
skipNotification: true,
})
//#then
expect(cancelled).toBe(true)
expect(removeTaskCalls).toContain(task.id)
manager.shutdown()
resetToastManager()
})
})
describe("multiple keys process in parallel", () => {
test("should process different concurrency keys in parallel", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input1 = {
description: "Task 1",
prompt: "Do something",
agent: "agent-a",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const input2 = {
description: "Task 2",
prompt: "Do something else",
agent: "agent-b",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task1 = await manager.launch(input1)
const task2 = await manager.launch(input2)
// Wait for both to start
await new Promise(resolve => setTimeout(resolve, 50))
// then - both should be running despite limit of 1 (different keys)
const updatedTask1 = manager.getTask(task1.id)
const updatedTask2 = manager.getTask(task2.id)
expect(updatedTask1?.status).toBe("running")
expect(updatedTask2?.status).toBe("running")
})
test("should respect per-key concurrency limits", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task1 = await manager.launch(input)
const task2 = await manager.launch(input)
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 50))
// then - same key should respect limit
const updatedTask1 = manager.getTask(task1.id)
const updatedTask2 = manager.getTask(task2.id)
expect(updatedTask1?.status).toBe("running")
expect(updatedTask2?.status).toBe("pending")
})
test("should process model-based keys in parallel", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input1 = {
description: "Task 1",
prompt: "Do something",
agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const input2 = {
description: "Task 2",
prompt: "Do something else",
agent: "test-agent",
model: { providerID: "openai", modelID: "gpt-5.4" },
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task1 = await manager.launch(input1)
const task2 = await manager.launch(input2)
// Wait for both to start
await new Promise(resolve => setTimeout(resolve, 50))
// then - different models should run in parallel
const updatedTask1 = manager.getTask(task1.id)
const updatedTask2 = manager.getTask(task2.id)
expect(updatedTask1?.status).toBe("running")
expect(updatedTask2?.status).toBe("running")
})
})
describe("TTL uses queuedAt for pending, startedAt for running", () => {
test("should use queuedAt for pending task TTL", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// Launch two tasks (second will be pending)
await manager.launch(input)
const task2 = await manager.launch(input)
// Wait for first to start
await new Promise(resolve => setTimeout(resolve, 50))
// when
const pendingTask = manager.getTask(task2.id)
// then
expect(pendingTask?.status).toBe("pending")
expect(pendingTask?.queuedAt).toBeInstanceOf(Date)
expect(pendingTask?.startedAt).toBeUndefined()
// Verify TTL would use queuedAt (implementation detail check)
const now = Date.now()
const age = now - pendingTask!.queuedAt!.getTime()
expect(age).toBeGreaterThanOrEqual(0)
})
test("should use startedAt for running task TTL", async () => {
// given
const config = { defaultConcurrency: 5 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
// Wait for task to start
await new Promise(resolve => setTimeout(resolve, 50))
// then
const runningTask = manager.getTask(task.id)
expect(runningTask?.status).toBe("running")
expect(runningTask?.startedAt).toBeInstanceOf(Date)
// Verify TTL would use startedAt (implementation detail check)
const now = Date.now()
const age = now - runningTask!.startedAt!.getTime()
expect(age).toBeGreaterThanOrEqual(0)
})
test("should have different timestamps for queuedAt and startedAt", async () => {
// given
const config = { defaultConcurrency: 1 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// Launch task that will queue
await manager.launch(input)
const task2 = await manager.launch(input)
const queuedAt = task2.queuedAt!
// Wait for first task to complete and second to start
await new Promise(resolve => setTimeout(resolve, 50))
// Simulate first task completion
const tasks = Array.from(getTaskMap(manager).values())
const runningTask = tasks.find(t => t.status === "running" && t.id !== task2.id)
if (runningTask?.concurrencyKey) {
runningTask.status = "completed"
getConcurrencyManager(manager).release(runningTask.concurrencyKey)
}
// Wait for second task to start
await new Promise(resolve => setTimeout(resolve, 100))
// then
const startedTask = manager.getTask(task2.id)
if (startedTask?.status === "running" && startedTask.startedAt) {
expect(startedTask.startedAt).toBeInstanceOf(Date)
expect(startedTask.startedAt.getTime()).toBeGreaterThan(queuedAt.getTime())
}
})
})
describe("manual verification scenario", () => {
test("should handle 10 tasks with limit 5 returning immediately", async () => {
// given
const config = { defaultConcurrency: 5 }
manager.shutdown()
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
// when
const startTime = Date.now()
const tasks = await Promise.all(
Array.from({ length: 10 }, () => manager.launch(input))
)
const endTime = Date.now()
// then
expect(endTime - startTime).toBeLessThan(200) // Should be very fast
expect(tasks).toHaveLength(10)
tasks.forEach(task => {
expect(task.status).toBe("pending")
expect(task.id).toMatch(/^bg_/)
})
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 100))
// Verify 5 running, 5 pending
const updatedTasks = tasks.map(t => manager.getTask(t.id))
const runningCount = updatedTasks.filter(t => t?.status === "running").length
const pendingCount = updatedTasks.filter(t => t?.status === "pending").length
expect(runningCount).toBe(5)
expect(pendingCount).toBe(5)
})
})
})
describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
const originalDateNow = Date.now
let fixedTime: number
beforeEach(() => {
fixedTime = Date.now()
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
})
afterEach(() => {
Date.now = originalDateNow
})
test("should NOT interrupt task running less than 30 seconds (min runtime guard)", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-1",
sessionID: "session-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Test task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 20_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 200_000),
},
}
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("running")
})
test("should NOT interrupt task with recent lastUpdate", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-2",
sessionID: "session-2",
parentSessionID: "parent-2",
parentMessageID: "msg-2",
description: "Test task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 5,
lastUpdate: new Date(Date.now() - 30_000),
},
}
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("running")
})
test("should interrupt task with stale lastUpdate (> 3min)", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-3",
sessionID: "session-3",
parentSessionID: "parent-3",
parentMessageID: "msg-3",
description: "Stale task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 200_000),
},
}
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
expect(task.error).toContain("3min")
expect(task.completedAt).toBeDefined()
})
test("should respect custom staleTimeoutMs config", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 60_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-4",
sessionID: "session-4",
parentSessionID: "parent-4",
parentMessageID: "msg-4",
description: "Custom timeout task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 120_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 90_000),
},
}
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
test("should release concurrency before abort", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-5",
sessionID: "session-5",
parentSessionID: "parent-5",
parentMessageID: "msg-5",
description: "Concurrency test",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
concurrencyKey: "test-agent",
}
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
expect(task.concurrencyKey).toBeUndefined()
expect(task.status).toBe("cancelled")
})
test("should handle multiple stale tasks in same poll cycle", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task1: BackgroundTask = {
id: "task-6",
sessionID: "session-6",
parentSessionID: "parent-6",
parentMessageID: "msg-6",
description: "Stale 1",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
}
const task2: BackgroundTask = {
id: "task-7",
sessionID: "session-7",
parentSessionID: "parent-7",
parentMessageID: "msg-7",
description: "Stale 2",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 400_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 250_000),
},
}
getTaskMap(manager).set(task1.id, task1)
getTaskMap(manager).set(task2.id, task2)
await manager["checkAndInterruptStaleTasks"]()
expect(task1.status).toBe("cancelled")
expect(task2.status).toBe("cancelled")
})
test("should use default timeout when config not provided", async () => {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-8",
sessionID: "session-8",
parentSessionID: "parent-8",
parentMessageID: "msg-8",
description: "Default timeout",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 50 * 60 * 1000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 46 * 60 * 1000),
},
}
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
})
test("should NOT interrupt task when session is running, even with stale lastUpdate", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-running-session",
sessionID: "session-running",
parentSessionID: "parent-rs",
parentMessageID: "msg-rs",
description: "Task with running session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is actively running
await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })
//#then — task survives because session is running
expect(task.status).toBe("running")
})
test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-idle-session",
sessionID: "session-idle",
parentSessionID: "parent-is",
parentMessageID: "msg-is",
description: "Task with idle session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is idle
await manager["checkAndInterruptStaleTasks"]({ "session-idle": { type: "idle" } })
//#then — killed because session is idle with stale lastUpdate
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
test("should NOT interrupt running session even with very old lastUpdate (no safety net)", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-long-running",
sessionID: "session-long",
parentSessionID: "parent-lr",
parentMessageID: "msg-lr",
description: "Long running task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 5,
lastUpdate: new Date(Date.now() - 900_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is running, lastUpdate 15min old
await manager["checkAndInterruptStaleTasks"]({ "session-long": { type: "running" } })
//#then — running sessions are NEVER stale-killed
expect(task.status).toBe("running")
})
test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, but session is running
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-running-no-progress",
sessionID: "session-rnp",
parentSessionID: "parent-rnp",
parentMessageID: "msg-rnp",
description: "Running no progress",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — session is running despite no progress
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })
//#then — running sessions are NEVER killed
expect(task.status).toBe("running")
})
test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-no-update",
sessionID: "session-no-update",
parentSessionID: "parent-nu",
parentMessageID: "msg-nu",
description: "No update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — no progress update for 15 minutes
await manager["checkAndInterruptStaleTasks"]({})
//#then — killed after messageStalenessTimeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
test("should NOT interrupt task with no lastUpdate within messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-fresh-no-update",
sessionID: "session-fresh",
parentSessionID: "parent-fn",
parentMessageID: "msg-fn",
description: "Fresh no-update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 5 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — only 5 min since start, within 10min timeout
await manager["checkAndInterruptStaleTasks"]({})
//#then — task survives
expect(task.status).toBe("running")
})
})
describe("BackgroundManager.shutdown session abort", () => {
test("should call session.abort for all running tasks during shutdown", () => {
// given
const abortedSessionIDs: string[] = []
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async (args: { path: { id: string } }) => {
abortedSessionIDs.push(args.path.id)
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task1: BackgroundTask = {
id: "task-1",
sessionID: "session-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Running task 1",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(),
}
const task2: BackgroundTask = {
id: "task-2",
sessionID: "session-2",
parentSessionID: "parent-2",
parentMessageID: "msg-2",
description: "Running task 2",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(),
}
getTaskMap(manager).set(task1.id, task1)
getTaskMap(manager).set(task2.id, task2)
// when
manager.shutdown()
// then
expect(abortedSessionIDs).toContain("session-1")
expect(abortedSessionIDs).toContain("session-2")
expect(abortedSessionIDs).toHaveLength(2)
})
test("should not call session.abort for completed or cancelled tasks", () => {
// given
const abortedSessionIDs: string[] = []
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async (args: { path: { id: string } }) => {
abortedSessionIDs.push(args.path.id)
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const completedTask: BackgroundTask = {
id: "task-completed",
sessionID: "session-completed",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Completed task",
prompt: "Test",
agent: "test-agent",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
const cancelledTask: BackgroundTask = {
id: "task-cancelled",
sessionID: "session-cancelled",
parentSessionID: "parent-2",
parentMessageID: "msg-2",
description: "Cancelled task",
prompt: "Test",
agent: "test-agent",
status: "cancelled",
startedAt: new Date(),
completedAt: new Date(),
}
const pendingTask: BackgroundTask = {
id: "task-pending",
parentSessionID: "parent-3",
parentMessageID: "msg-3",
description: "Pending task",
prompt: "Test",
agent: "test-agent",
status: "pending",
queuedAt: new Date(),
}
getTaskMap(manager).set(completedTask.id, completedTask)
getTaskMap(manager).set(cancelledTask.id, cancelledTask)
getTaskMap(manager).set(pendingTask.id, pendingTask)
// when
manager.shutdown()
// then
expect(abortedSessionIDs).toHaveLength(0)
})
test("should call onShutdown callback during shutdown", () => {
// given
let shutdownCalled = false
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
undefined,
{
onShutdown: () => {
shutdownCalled = true
},
}
)
// when
manager.shutdown()
// then
expect(shutdownCalled).toBe(true)
})
test("should not throw when onShutdown callback throws", () => {
// given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
undefined,
{
onShutdown: () => {
throw new Error("cleanup failed")
},
}
)
// when / #then
expect(() => manager.shutdown()).not.toThrow()
})
})
describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
test("should cancel descendant tasks and keep them until delayed cleanup", async () => {
// given
const manager = createBackgroundManager()
const parentSessionID = "session-parent"
const childTask = createMockTask({
id: "task-child",
sessionID: "session-child",
parentSessionID,
status: "running",
})
const siblingTask = createMockTask({
id: "task-sibling",
sessionID: "session-sibling",
parentSessionID,
status: "running",
})
const grandchildTask = createMockTask({
id: "task-grandchild",
sessionID: "session-grandchild",
parentSessionID: "session-child",
status: "pending",
startedAt: undefined,
queuedAt: new Date(),
})
const unrelatedTask = createMockTask({
id: "task-unrelated",
sessionID: "session-unrelated",
parentSessionID: "other-parent",
status: "running",
})
const taskMap = getTaskMap(manager)
taskMap.set(childTask.id, childTask)
taskMap.set(siblingTask.id, siblingTask)
taskMap.set(grandchildTask.id, grandchildTask)
taskMap.set(unrelatedTask.id, unrelatedTask)
const pendingByParent = getPendingByParent(manager)
pendingByParent.set(parentSessionID, new Set([childTask.id, siblingTask.id]))
pendingByParent.set("session-child", new Set([grandchildTask.id]))
// when
manager.handleEvent({
type: "session.deleted",
properties: { info: { id: parentSessionID } },
})
await flushBackgroundNotifications()
// then
expect(taskMap.has(childTask.id)).toBe(true)
expect(taskMap.has(siblingTask.id)).toBe(true)
expect(taskMap.has(grandchildTask.id)).toBe(true)
expect(taskMap.has(unrelatedTask.id)).toBe(true)
expect(childTask.status).toBe("cancelled")
expect(siblingTask.status).toBe("cancelled")
expect(grandchildTask.status).toBe("cancelled")
expect(pendingByParent.get(parentSessionID)).toBeUndefined()
expect(pendingByParent.get("session-child")).toBeUndefined()
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(siblingTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
manager.shutdown()
})
test("should remove cancelled tasks from toast manager while preserving delayed cleanup", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const parentSessionID = "session-parent-toast"
const childTask = createMockTask({
id: "task-child-toast",
sessionID: "session-child-toast",
parentSessionID,
status: "running",
})
const grandchildTask = createMockTask({
id: "task-grandchild-toast",
sessionID: "session-grandchild-toast",
parentSessionID: "session-child-toast",
status: "pending",
startedAt: undefined,
queuedAt: new Date(),
})
const taskMap = getTaskMap(manager)
taskMap.set(childTask.id, childTask)
taskMap.set(grandchildTask.id, grandchildTask)
//#when
manager.handleEvent({
type: "session.deleted",
properties: { info: { id: parentSessionID } },
})
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(childTask.id)
expect(removeTaskCalls).toContain(grandchildTask.id)
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
manager.shutdown()
resetToastManager()
})
test("should clean pending notifications for deleted sessions", () => {
//#given
const manager = createBackgroundManager()
const sessionID = "session-pending-notifications"
manager.queuePendingNotification(sessionID, "<system-reminder>queued</system-reminder>")
expect(getPendingNotifications(manager).get(sessionID)).toEqual([
"<system-reminder>queued</system-reminder>",
])
//#when
manager.handleEvent({
type: "session.deleted",
properties: { info: { id: sessionID } },
})
//#then
expect(getPendingNotifications(manager).has(sessionID)).toBe(false)
manager.shutdown()
})
})
describe("BackgroundManager.handleEvent - session.error", () => {
const defaultRetryFallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["anthropic"], model: "gpt-5.3-codex", variant: "high" },
]
const stubProcessKey = (manager: BackgroundManager) => {
;(manager as unknown as { processKey: (key: string) => Promise<void> }).processKey = async () => {}
}
const createRetryTask = (manager: BackgroundManager, input: {
id: string
sessionID: string
description: string
concurrencyKey?: string
fallbackChain?: typeof defaultRetryFallbackChain
}) => {
const task = createMockTask({
id: input.id,
sessionID: input.sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-retry",
description: input.description,
agent: "sisyphus",
status: "running",
concurrencyKey: input.concurrencyKey,
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
fallbackChain: input.fallbackChain ?? defaultRetryFallbackChain,
attemptCount: 0,
})
getTaskMap(manager).set(task.id, task)
return task
}
test("sets task to error, releases concurrency, and keeps it until delayed cleanup", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
await concurrencyManager.acquire(concurrencyKey)
const sessionID = "ses_error_1"
const task = createMockTask({
id: "task-session-error",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task that errors",
agent: "explore",
status: "running",
concurrencyKey,
})
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: { message: "Model not found: kimi-for-coding/k2p5." },
},
},
})
await flushBackgroundNotifications()
//#then
expect(task.status).toBe("error")
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
expect(task.completedAt).toBeInstanceOf(Date)
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getTaskMap(manager).has(task.id)).toBe(true)
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
test("should remove errored task from toast manager while preserving delayed cleanup", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const sessionID = "ses_error_toast"
const task = createMockTask({
id: "task-session-error-toast",
sessionID,
parentSessionID: "parent-session",
status: "running",
})
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", message: "boom" },
},
})
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(task.id)
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
resetToastManager()
})
test("ignores session.error for non-running tasks", () => {
//#given
const manager = createBackgroundManager()
const sessionID = "ses_error_ignored"
const task = createMockTask({
id: "task-non-running",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task already done",
agent: "explore",
status: "completed",
})
task.completedAt = new Date()
task.error = "previous"
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", message: "should not matter" },
},
})
//#then
expect(task.status).toBe("completed")
expect(task.error).toBe("previous")
expect(getTaskMap(manager).has(task.id)).toBe(true)
manager.shutdown()
})
test("ignores session.error for unknown session", () => {
//#given
const manager = createBackgroundManager()
//#when
const handler = () =>
manager.handleEvent({
type: "session.error",
properties: {
sessionID: "ses_unknown",
error: { name: "UnknownError", message: "Model not found" },
},
})
//#then
expect(handler).not.toThrow()
manager.shutdown()
})
test("retry path releases current concurrency slot and prefers current provider in fallback entry", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "anthropic/claude-opus-4-6-thinking"
await concurrencyManager.acquire(concurrencyKey)
stubProcessKey(manager)
const sessionID = "ses_error_retry"
const task = createRetryTask(manager, {
id: "task-session-error-retry",
sessionID,
description: "task that should retry",
concurrencyKey,
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["anthropic"], model: "claude-opus-4-5", variant: "max" },
],
})
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
})
//#then
expect(task.status).toBe("pending")
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
})
expect(task.concurrencyKey).toBeUndefined()
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
manager.shutdown()
})
test("retry path triggers on session.status retry events", async () => {
//#given
const manager = createBackgroundManager()
stubProcessKey(manager)
const sessionID = "ses_status_retry"
const task = createRetryTask(manager, {
id: "task-status-retry",
sessionID,
description: "task that should retry on status",
})
//#when
manager.handleEvent({
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
message: "Provider is overloaded",
},
},
})
//#then
expect(task.status).toBe("pending")
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
})
manager.shutdown()
})
test("retry path triggers on message.updated assistant error events", async () => {
//#given
const manager = createBackgroundManager()
stubProcessKey(manager)
const sessionID = "ses_message_updated_retry"
const task = createRetryTask(manager, {
id: "task-message-updated-retry",
sessionID,
description: "task that should retry on message.updated",
})
//#when
const messageInfo = {
id: "msg_errored",
sessionID,
role: "assistant",
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
}
manager.handleEvent({
type: "message.updated",
properties: {
info: messageInfo,
},
})
//#then
expect(task.status).toBe("pending")
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
})
manager.shutdown()
})
})
describe("BackgroundManager queue processing - error tasks are skipped", () => {
test("does not start tasks with status=error", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
{ defaultConcurrency: 1 }
)
const key = "test-key"
const task: BackgroundTask = {
id: "task-error-queued",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "queued error task",
prompt: "test",
agent: "test-agent",
status: "error",
queuedAt: new Date(),
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
let startCalled = false
;(manager as unknown as { startTask: (item: unknown) => Promise<void> }).startTask = async () => {
startCalled = true
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(key, [{ task, input }])
//#when
await processKeyForTest(manager, key)
//#then
expect(startCalled).toBe(false)
expect(getQueuesByKey(manager).get(key)?.length ?? 0).toBe(0)
manager.shutdown()
})
})
describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tasks from queuesByKey", () => {
test("removes stale pending task from queue", () => {
//#given
const manager = createBackgroundManager()
const queuedAt = new Date(Date.now() - 31 * 60 * 1000)
const task: BackgroundTask = {
id: "task-stale-pending",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "stale pending",
prompt: "test",
agent: "test-agent",
status: "pending",
queuedAt,
}
const key = task.agent
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(key, [{ task, input }])
//#when
pruneStaleTasksAndNotificationsForTest(manager)
//#then
expect(getQueuesByKey(manager).get(key)).toBeUndefined()
manager.shutdown()
})
test("removes stale task from toast manager", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const staleTask = createMockTask({
id: "task-stale-toast",
sessionID: "session-stale-toast",
parentSessionID: "parent-session",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
getTaskMap(manager).set(staleTask.id, staleTask)
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(staleTask.id)
manager.shutdown()
resetToastManager()
})
test("keeps stale task until notification cleanup after notifying parent", async () => {
//#given
const notifications: string[] = []
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const client = {
session: {
prompt: async () => ({}),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> & { noReply?: boolean; parts?: unknown[] } }) => {
const firstPart = args.body.parts?.[0]
if (firstPart && typeof firstPart === "object" && "text" in firstPart && typeof firstPart.text === "string") {
notifications.push(firstPart.text)
}
return {}
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const staleTask = createMockTask({
id: "task-stale-notify-cleanup",
sessionID: "session-stale-notify-cleanup",
parentSessionID: "parent-stale-notify-cleanup",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
getTaskMap(manager).set(staleTask.id, staleTask)
getPendingByParent(manager).set(staleTask.parentSessionID, new Set([staleTask.id]))
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
//#then
const retainedTask = getTaskMap(manager).get(staleTask.id)
expect(retainedTask?.status).toBe("error")
expect(getTaskMap(manager).has(staleTask.id)).toBe(true)
expect(notifications).toHaveLength(1)
expect(notifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
expect(notifications[0]).toContain(staleTask.description)
expect(getCompletionTimers(manager).has(staleTask.id)).toBe(true)
expect(removeTaskCalls).toContain(staleTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
function setCompletionTimer(manager: BackgroundManager, taskId: string): void {
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
completionTimers.delete(taskId)
}, 5 * 60 * 1000)
completionTimers.set(taskId, timer)
}
test("should have completionTimers Map initialized", () => {
// given
const manager = createBackgroundManager()
// when
const completionTimers = getCompletionTimers(manager)
// then
expect(completionTimers).toBeDefined()
expect(completionTimers).toBeInstanceOf(Map)
expect(completionTimers.size).toBe(0)
manager.shutdown()
})
test("should start per-task cleanup timers independently of sibling completion", async () => {
// given
const client = {
session: {
prompt: async () => ({}),
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const taskA: BackgroundTask = {
id: "task-timer-a",
sessionID: "session-timer-a",
parentSessionID: "parent-session",
parentMessageID: "msg-a",
description: "Task A",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
const taskB: BackgroundTask = {
id: "task-timer-b",
sessionID: "session-timer-b",
parentSessionID: "parent-session",
parentMessageID: "msg-b",
description: "Task B",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(taskA.id, taskA)
getTaskMap(manager).set(taskB.id, taskB)
;(manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent.set(
"parent-session",
new Set([taskA.id, taskB.id])
)
// when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(taskA)
// then
const completionTimers = getCompletionTimers(manager)
expect(completionTimers.size).toBe(1)
// when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(taskB)
// then
expect(completionTimers.size).toBe(2)
expect(completionTimers.has(taskA.id)).toBe(true)
expect(completionTimers.has(taskB.id)).toBe(true)
manager.shutdown()
})
test("should clear all completion timers on shutdown", () => {
// given
const manager = createBackgroundManager()
setCompletionTimer(manager, "task-1")
setCompletionTimer(manager, "task-2")
const completionTimers = getCompletionTimers(manager)
expect(completionTimers.size).toBe(2)
// when
manager.shutdown()
// then
expect(completionTimers.size).toBe(0)
})
test("should preserve cleanup timer when terminal task session is deleted", () => {
// given
const manager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-timer-4",
sessionID: "session-timer-4",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "Test task",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
setCompletionTimer(manager, task.id)
const completionTimers = getCompletionTimers(manager)
expect(completionTimers.size).toBe(1)
// when
manager.handleEvent({
type: "session.deleted",
properties: {
info: { id: "session-timer-4" },
},
})
// then
expect(completionTimers.has(task.id)).toBe(true)
manager.shutdown()
})
test("should not leak timers across multiple shutdown calls", () => {
// given
const manager = createBackgroundManager()
setCompletionTimer(manager, "task-1")
// when
manager.shutdown()
manager.shutdown()
// then
const completionTimers = getCompletionTimers(manager)
expect(completionTimers.size).toBe(0)
})
})
describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
test("should defer and retry when session.idle fires before MIN_IDLE_TIME_MS", async () => {
//#given - a running task started less than MIN_IDLE_TIME_MS ago
const sessionID = "session-early-idle"
const messagesCalls: string[] = []
const realDateNow = Date.now
const baseNow = realDateNow()
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
messages: async (args: { path: { id: string } }) => {
messagesCalls.push(args.path.id)
return {
data: [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "ok" }],
},
],
}
},
todo: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
const remainingMs = 1200
const task: BackgroundTask = {
id: "task-early-idle",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "early idle task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(baseNow),
}
getTaskMap(manager).set(task.id, task)
//#when - session.idle fires
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
manager.handleEvent({ type: "session.idle", properties: { sessionID } })
// Advance time so deferred callback (if any) sees elapsed >= MIN_IDLE_TIME_MS
Date.now = () => baseNow + (MIN_IDLE_TIME_MS + 10)
//#then - idle should be deferred (not dropped), and task should eventually complete
expect(task.status).toBe("running")
await new Promise((resolve) => setTimeout(resolve, 220))
expect(task.status).toBe("completed")
expect(messagesCalls).toEqual([sessionID])
} finally {
Date.now = realDateNow
manager.shutdown()
}
})
test("should not defer when session.idle fires after MIN_IDLE_TIME_MS", async () => {
//#given - a running task started more than MIN_IDLE_TIME_MS ago
const sessionID = "session-late-idle"
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
messages: async () => ({
data: [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "ok" }],
},
],
}),
todo: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-late-idle",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "late idle task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
}
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({ type: "session.idle", properties: { sessionID } })
//#then - should be processed immediately
await new Promise((resolve) => setTimeout(resolve, 10))
expect(task.status).toBe("completed")
manager.shutdown()
})
test("should not process deferred idle if task already completed by other means", async () => {
//#given - a running task
const sessionID = "session-deferred-noop"
let messagesCallCount = 0
const realDateNow = Date.now
const baseNow = realDateNow()
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
messages: async () => {
messagesCallCount += 1
return {
data: [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "ok" }],
},
],
}
},
todo: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
const remainingMs = 120
const task: BackgroundTask = {
id: "task-deferred-noop",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "deferred noop task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(baseNow),
}
getTaskMap(manager).set(task.id, task)
//#when - session.idle fires early, then task completes via another path before defer timer
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
manager.handleEvent({ type: "session.idle", properties: { sessionID } })
expect(messagesCallCount).toBe(0)
await tryCompleteTaskForTest(manager, task)
expect(task.status).toBe("completed")
// Advance time so deferred callback (if any) sees elapsed >= MIN_IDLE_TIME_MS
Date.now = () => baseNow + (MIN_IDLE_TIME_MS + 10)
//#then - deferred callback should be a no-op
await new Promise((resolve) => setTimeout(resolve, remainingMs + 80))
expect(task.status).toBe("completed")
expect(messagesCallCount).toBe(0)
} finally {
Date.now = realDateNow
manager.shutdown()
}
})
})
describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
test("should update lastUpdate on text-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-text-1",
sessionID: "session-text-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Thinking task",
prompt: "Think deeply",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 2,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a text-type message.part.updated event arrives
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-text-1", type: "text" },
})
//#then - lastUpdate should be refreshed, toolCalls should NOT change
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(2)
})
test("should update lastUpdate on thinking-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-thinking-1",
sessionID: "session-thinking-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task",
prompt: "Reason about architecture",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a thinking-type message.part.updated event arrives
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-thinking-1", type: "thinking" },
})
//#then - lastUpdate should be refreshed, toolCalls should remain 0
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(0)
})
test("should initialize progress on first non-tool event", () => {
//#given - a running task with NO progress field
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-init-1",
sessionID: "session-init-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "New task",
prompt: "Start thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 60_000),
}
getTaskMap(manager).set(task.id, task)
//#when - a text-type event arrives before any tool call
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-init-1", type: "text" },
})
//#then - progress should be initialized with toolCalls: 0 and fresh lastUpdate
expect(task.progress).toBeDefined()
expect(task.progress!.toolCalls).toBe(0)
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(Date.now() - 5000)
})
test("should NOT mark thinking model as stale when text events refresh lastUpdate", async () => {
//#given - a running task where text events keep lastUpdate fresh
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-alive-1",
sessionID: "session-alive-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Long thinking task",
prompt: "Deep reasoning",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a text event arrives, then stale check runs
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-alive-1", type: "text" },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (text event refreshed lastUpdate)
expect(task.status).toBe("running")
})
test("should refresh lastUpdate on message.part.delta events (OpenCode >=1.2.0)", async () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-delta-1",
sessionID: "session-delta-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task with delta events",
prompt: "Extended thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a message.part.delta event arrives (reasoning-delta or text-delta in OpenCode >=1.2.0)
manager.handleEvent({
type: "message.part.delta",
properties: { sessionID: "session-delta-1", field: "text", delta: "thinking..." },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (delta event refreshed lastUpdate)
expect(task.status).toBe("running")
})
})
describe("BackgroundManager regression fixes - resume and aborted notification", () => {
test("should keep resumed task in memory after previous completion timer deadline", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-resume-timer-regression",
sessionID: "session-resume-timer-regression",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "resume timer regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
concurrencyGroup: "explore",
}
getTaskMap(manager).set(task.id, task)
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
completionTimers.delete(task.id)
getTaskMap(manager).delete(task.id)
}, 25)
completionTimers.set(task.id, timer)
//#when
await manager.resume({
sessionId: "session-resume-timer-regression",
prompt: "resume task",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
await new Promise((resolve) => setTimeout(resolve, 60))
//#then
expect(getTaskMap(manager).has(task.id)).toBe(true)
expect(completionTimers.has(task.id)).toBe(false)
manager.shutdown()
})
test("should start cleanup timer even when promptAsync aborts", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => {
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-cleanup-regression",
sessionID: "session-aborted-cleanup-regression",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "aborted prompt cleanup regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> }).notifyParentSession(task)
//#then
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
})
describe("BackgroundManager - tool permission spread order", () => {
test("startTask respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
const client = {
session: {
get: async () => ({ data: { directory: "/test/dir" } }),
create: async () => ({ data: { id: "session-1" } }),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
capturedTools = args.body.tools as Record<string, unknown>
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-1",
status: "pending",
queuedAt: new Date(),
description: "test task",
prompt: "test prompt",
agent: "explore",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
//#when
await (manager as unknown as { startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> })
.startTask({ task, input })
//#then
expect(capturedTools).toBeDefined()
expect(capturedTools?.call_omo_agent).toBe(false)
expect(capturedTools?.task).toBe(false)
expect(capturedTools?.write).toBe(false)
expect(capturedTools?.edit).toBe(false)
manager.shutdown()
})
test("resume respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
const client = {
session: {
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
capturedTools = args.body.tools as Record<string, unknown>
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-2",
sessionID: "session-2",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
description: "resume task",
prompt: "resume prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
//#when
await manager.resume({
sessionId: "session-2",
prompt: "continue",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
})
//#then
expect(capturedTools).toBeDefined()
expect(capturedTools?.call_omo_agent).toBe(false)
expect(capturedTools?.task).toBe(false)
expect(capturedTools?.write).toBe(false)
expect(capturedTools?.edit).toBe(false)
manager.shutdown()
})
})