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