Add targeted regression coverage for pending parent bookkeeping and launch/resume prompt failure behavior before extracting narrow helper methods inside BackgroundManager. Keep launch-only skill content and missing-agent formatting explicit, keep resume on direct promptAsync semantics, and reuse shared helper paths for pending registration, prompt body construction, and prompt-dispatch cleanup. Co-authored-by: Codex <noreply@openai.com>
4904 lines
149 KiB
TypeScript
4904 lines
149 KiB
TypeScript
declare const require: (name: string) => any
|
|
const { describe, test, expect, beforeEach, afterEach } = 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.launch and resume cleanup regressions", () => {
|
|
test("launch should register pending task under parent before background start finishes", async () => {
|
|
//#given
|
|
let releaseCreate: (() => void) | undefined
|
|
const createGate = new Promise<void>((resolve) => {
|
|
releaseCreate = resolve
|
|
})
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
|
create: async () => {
|
|
await createGate
|
|
return { data: { id: "session-launch-pending" } }
|
|
},
|
|
promptAsync: async () => ({}),
|
|
abort: async () => ({}),
|
|
},
|
|
}
|
|
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
|
|
|
//#when
|
|
const task = await manager.launch({
|
|
description: "pending registration",
|
|
prompt: "launch prompt",
|
|
agent: "explore",
|
|
parentSessionID: "parent-launch-pending",
|
|
parentMessageID: "msg-launch-pending",
|
|
})
|
|
|
|
//#then
|
|
expect(getPendingByParent(manager).get("parent-launch-pending")?.has(task.id)).toBe(true)
|
|
expect(manager.getTask(task.id)?.status).toBe("pending")
|
|
|
|
releaseCreate?.()
|
|
await flushBackgroundNotifications()
|
|
manager.shutdown()
|
|
})
|
|
|
|
test("launch should clean pending bookkeeping and format missing-agent prompt errors", async () => {
|
|
//#given
|
|
const abortedSessionIDs: string[] = []
|
|
const promptAsyncCalls: string[] = []
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/test/dir" } }),
|
|
create: async () => ({ data: { id: "session-launch-error" } }),
|
|
promptAsync: async (args: { path: { id: string } }) => {
|
|
promptAsyncCalls.push(args.path.id)
|
|
if (args.path.id === "session-launch-error") {
|
|
throw new Error("agent.name is undefined")
|
|
}
|
|
return {}
|
|
},
|
|
abort: async (args: { path: { id: string } }) => {
|
|
abortedSessionIDs.push(args.path.id)
|
|
return {}
|
|
},
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
|
|
|
//#when
|
|
const launchedTask = await manager.launch({
|
|
description: "launch prompt error",
|
|
prompt: "launch prompt",
|
|
agent: "missing-agent",
|
|
parentSessionID: "parent-launch-error",
|
|
parentMessageID: "msg-launch-error",
|
|
})
|
|
await flushBackgroundNotifications()
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
|
|
//#then
|
|
const storedTask = manager.getTask(launchedTask.id)
|
|
expect(storedTask?.status).toBe("interrupt")
|
|
expect(storedTask?.error).toBe('Agent "missing-agent" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.')
|
|
expect(storedTask?.concurrencyKey).toBeUndefined()
|
|
expect(storedTask?.completedAt).toBeInstanceOf(Date)
|
|
expect(getPendingByParent(manager).get("parent-launch-error")).toBeUndefined()
|
|
expect(abortedSessionIDs).toContain("session-launch-error")
|
|
expect(promptAsyncCalls).toContain("parent-launch-error")
|
|
|
|
manager.shutdown()
|
|
})
|
|
|
|
test("resume should clean pending bookkeeping and preserve raw prompt errors", async () => {
|
|
//#given
|
|
const abortedSessionIDs: string[] = []
|
|
const promptAsyncCalls: string[] = []
|
|
const client = {
|
|
session: {
|
|
promptAsync: async (args: { path: { id: string } }) => {
|
|
promptAsyncCalls.push(args.path.id)
|
|
if (args.path.id === "session-resume-error") {
|
|
throw new Error("resume prompt exploded")
|
|
}
|
|
return {}
|
|
},
|
|
abort: async (args: { path: { id: string } }) => {
|
|
abortedSessionIDs.push(args.path.id)
|
|
return {}
|
|
},
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
|
const task: BackgroundTask = {
|
|
id: "task-resume-error",
|
|
sessionID: "session-resume-error",
|
|
parentSessionID: "parent-before-resume-error",
|
|
parentMessageID: "msg-before-resume-error",
|
|
description: "resume prompt error",
|
|
prompt: "resume prompt",
|
|
agent: "explore",
|
|
status: "completed",
|
|
startedAt: new Date(),
|
|
completedAt: new Date(),
|
|
concurrencyGroup: "explore",
|
|
}
|
|
getTaskMap(manager).set(task.id, task)
|
|
|
|
//#when
|
|
await manager.resume({
|
|
sessionId: "session-resume-error",
|
|
prompt: "resume now",
|
|
parentSessionID: "parent-resume-error",
|
|
parentMessageID: "msg-resume-error",
|
|
})
|
|
await flushBackgroundNotifications()
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
|
|
//#then
|
|
expect(task.status).toBe("interrupt")
|
|
expect(task.error).toBe("resume prompt exploded")
|
|
expect(task.concurrencyKey).toBeUndefined()
|
|
expect(task.completedAt).toBeInstanceOf(Date)
|
|
expect(getPendingByParent(manager).get("parent-resume-error")).toBeUndefined()
|
|
expect(abortedSessionIDs).toContain("session-resume-error")
|
|
expect(promptAsyncCalls).toContain("parent-resume-error")
|
|
|
|
manager.shutdown()
|
|
})
|
|
|
|
test("trackTask should move pending bookkeeping when parent session changes", async () => {
|
|
//#given
|
|
const manager = createBackgroundManager()
|
|
stubNotifyParentSession(manager)
|
|
const existingTask: BackgroundTask = {
|
|
id: "task-parent-move",
|
|
sessionID: "session-parent-move",
|
|
parentSessionID: "parent-before-move",
|
|
parentMessageID: "msg-before-move",
|
|
description: "tracked external task",
|
|
prompt: "",
|
|
agent: "task",
|
|
status: "running",
|
|
startedAt: new Date(),
|
|
progress: {
|
|
toolCalls: 0,
|
|
lastUpdate: new Date(),
|
|
},
|
|
}
|
|
getTaskMap(manager).set(existingTask.id, existingTask)
|
|
getPendingByParent(manager).set("parent-before-move", new Set([existingTask.id]))
|
|
|
|
//#when
|
|
await manager.trackTask({
|
|
taskId: existingTask.id,
|
|
sessionID: existingTask.sessionID!,
|
|
parentSessionID: "parent-after-move",
|
|
description: existingTask.description,
|
|
agent: existingTask.agent,
|
|
})
|
|
|
|
//#then
|
|
expect(getPendingByParent(manager).get("parent-before-move")).toBeUndefined()
|
|
expect(getPendingByParent(manager).get("parent-after-move")?.has(existingTask.id)).toBe(true)
|
|
|
|
manager.shutdown()
|
|
})
|
|
})
|
|
|
|
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(promptCalls[0].body.agent).toBe("explore")
|
|
})
|
|
|
|
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 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", () => {
|
|
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() - 300_000),
|
|
progress: {
|
|
toolCalls: 1,
|
|
lastUpdate: new Date(Date.now() - 200_000),
|
|
},
|
|
}
|
|
|
|
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()
|
|
})
|
|
})
|