import { tmpdir } from "node:os"
import { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import * as sharedModule from "../../shared"
import {
  clearAllDelegatedChildSessionBootstrap,
  getDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import { dispatchInternalPrompt, releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state"
import {
  getSessionAgent,
  registerAgentName,
  _resetForTesting as resetClaudeCodeSessionState,
  subagentSessions,
} from "../claude-code-session-state"
import { _resetTaskToastManagerForTesting, initTaskToastManager } from "../task-toast-manager/manager"
import type { ConcurrencyManager } from "./concurrency"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
import type { BackgroundTask, ResumeInput } from "./types"

afterAll(() => { mock.restore() })

afterEach(() => {
  clearBackgroundTaskRegistryForTesting()
  releaseAllPromptAsyncReservationsForTesting()
})

const TASK_TTL_MS = 30 * 60 * 1000
type PendingParentWakeForTest = {
  promptContext: Record<string, unknown>
  notifications: string[]
  shouldReply: boolean
  dispatchedAt?: number
}

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; parentSessionId: string; sessionId?: string }): BackgroundTask {
  return {
    parentMessageId: "mock-message-id",
    description: "test task",
    prompt: "test prompt",
    agent: "test-agent",
    status: "running",
    startedAt: new Date(),
    ...overrides,
  }
}

function cast<T>(value: unknown): T {
  return value as T
}

async function expectRejectsWithMessage(promise: Promise<unknown>, expectedMessage: string): Promise<void> {
  await promise.then(
    () => {
      throw new Error(`Expected promise to reject with ${expectedMessage}`)
    },
    (error: unknown) => {
      expect(String(error)).toContain(expectedMessage)
    },
  )
}

async function expectResolvesDefined(promise: Promise<unknown>): Promise<void> {
  const result = await promise
  expect(result).toBeDefined()
}

async function expectResolvesMatchObject<TActual extends object>(
  promise: Promise<TActual>,
  expected: Partial<TActual>,
): Promise<void> {
  const result = await promise
  expect(result).toMatchObject(expected)
}

function createPluginInput(client: unknown, directory = tmpdir()): PluginInput {
  return cast<PluginInput>({ client, directory })
}

function createBackgroundManager(): BackgroundManager {
  const client = {
    session: {
      prompt: async () => ({}),
      promptAsync: async () => ({}),
      abort: async () => ({}),
    },
  }
  return new BackgroundManager({ pluginContext: createPluginInput(client) })
}

function createBackgroundManagerWithOptions(options: Partial<ConstructorParameters<typeof BackgroundManager>[0]>): BackgroundManager {
  const client = {
    session: {
      prompt: async () => ({}),
      promptAsync: async () => ({}),
      abort: async () => ({}),
    },
  }
  return new BackgroundManager({
    pluginContext: createPluginInput(client),
    config: undefined,
    ...options,
  })
}

function getConcurrencyManager(manager: BackgroundManager): ConcurrencyManager {
  return (cast<{ concurrencyManager: ConcurrencyManager }>(manager)).concurrencyManager
}

function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
  return (cast<{ tasks: Map<string, BackgroundTask> }>(manager)).tasks
}

function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>> {
  return (cast<{ pendingByParent: Map<string, Set<string>> }>(manager)).pendingByParent
}

function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
  return (cast<{ pendingNotifications: Map<string, string[]> }>(manager)).pendingNotifications
}

function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
  return (cast<{
    parentWakeNotifier: { getPendingParentWakes: () => Map<string, PendingParentWakeForTest> }
  }>(manager)).parentWakeNotifier.getPendingParentWakes()
}

function getDispatchedParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
  return (cast<{
    parentWakeNotifier: { getDispatchedParentWakes: () => Map<string, PendingParentWakeForTest> }
  }>(manager)).parentWakeNotifier.getDispatchedParentWakes()
}

function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
  return (cast<{ completionTimers: Map<string, ReturnType<typeof setTimeout>> }>(manager)).completionTimers
}

function getRootDescendantCounts(manager: BackgroundManager): Map<string, number> {
  return (cast<{ rootDescendantCounts: Map<string, number> }>(manager)).rootDescendantCounts
}

function getPreStartDescendantReservations(manager: BackgroundManager): Set<string> {
  return (cast<{ preStartDescendantReservations: Set<string> }>(manager)).preStartDescendantReservations
}

function getQueuesByKey(
  manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
  return (cast<{
    queuesByKey: Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>>
  }>(manager)).queuesByKey
}

async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
  return (cast<{ processKey: (key: string) => Promise<void> }>(manager)).processKey(key)
}

function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
  ;(cast<{ pruneStaleTasksAndNotifications: () => void }>(manager)).pruneStaleTasksAndNotifications()
}

async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
  return (cast<{ tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> }>(manager))
    .tryCompleteTask(task, "test")
}

function stubNotifyParentSession(manager: BackgroundManager): void {
  ;(cast<{ notifyParentSession: () => Promise<void> }>(manager)).notifyParentSession = async () => {}
}

async function flushBackgroundNotifications(): Promise<void> {
  for (let i = 0; i < 12; i++) {
    await Promise.resolve()
  }
}

async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<void> {
  const startedAt = Date.now()
  while (!predicate()) {
    if (Date.now() - startedAt >= timeoutMs) {
      return
    }
    await new Promise((resolve) => setTimeout(resolve, 10))
  }
}

function waitForCoalescedFlush(): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, 400))
}

function waitForParentWakeRequeue(manager: BackgroundManager, sessionID: string): Promise<void> {
  return waitUntil(() => getPendingParentWakes(manager).has(sessionID), 600)
}

function waitForParentWakeErrorSettle(): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, 260))
}

function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
  _resetTaskToastManagerForTesting()
  const toastManager = initTaskToastManager(cast<PluginInput["client"]>({
    tui: { showToast: async () => {} },
  }))
  const removeTaskCalls: string[] = []
  const originalRemoveTask = toastManager.removeTask.bind(toastManager)
  toastManager.removeTask = (taskId: string): void => {
    removeTaskCalls.push(taskId)
    originalRemoveTask(taskId)
  }
  return {
    removeTaskCalls,
    resetToastManager: _resetTaskToastManagerForTesting,
  }
}

describe("BackgroundManager tmux callback ordering", () => {
  test("starts promptAsync before a blocking tmux callback resolves", async () => {
    //#given
    const events: string[] = []
    let resolveTmuxCallback: () => void = () => {}
    const tmuxCallbackPromise = new Promise<void>((resolve) => {
      resolveTmuxCallback = resolve
    })

    const client = {
      session: {
        get: async () => {
          events.push("session.get")
          return { data: { directory: "/tmp/test" } }
        },
        create: async () => {
          events.push("session.create")
          return { data: { id: "ses_manager_blocking_tmux" } }
        },
        promptAsync: async () => {
          events.push("promptAsync")
          return { data: {} }
        },
        abort: async () => ({ data: {} }),
      },
    }

    const onSubagentSessionCreated = mock(async () => {
      events.push("tmux.callback.start")
      await tmuxCallbackPromise
      events.push("tmux.callback.end")
    })
    const manager = new BackgroundManager({
      pluginContext: createPluginInput(client, "/tmp/test"),
      tmuxConfig: {
        enabled: true,
        layout: "main-vertical",
        main_pane_size: 60,
        main_pane_min_width: 120,
        agent_pane_min_width: 40,
        isolation: "inline",
      },
      onSubagentSessionCreated,
      enableParentSessionNotifications: false,
    })
    const originalTmux = process.env.TMUX
    process.env.TMUX = "/tmp/fake-tmux-socket"

    try {
      //#when
      await manager.launch({
        description: "Blocking tmux test",
        prompt: "Do work",
        agent: "general",
        parentSessionId: "ses_parent",
        parentMessageId: "msg_parent",
      })
      await new Promise((resolve) => setTimeout(resolve, 20))

      //#then
      expect(events).toContain("session.create")
      expect(events).toContain("promptAsync")
      expect(events).toContain("tmux.callback.start")
      const promptIdx = events.indexOf("promptAsync")
      const tmuxStartIdx = events.indexOf("tmux.callback.start")
      expect(promptIdx < tmuxStartIdx).toBe(true)
      expect(events).not.toContain("tmux.callback.end")
    } finally {
      resolveTmuxCallback()
      if (originalTmux === undefined) delete process.env.TMUX
      else process.env.TMUX = originalTmux
      manager.shutdown()
    }
  })
})

describe("BackgroundManager session.error fallback hydration", () => {
  test("hydrates fallbackChain from session fallback state before retrying sync child-session errors", async () => {
    //#given
    const fallbackChain = [
      { model: "fallback-model-1", providers: ["provider-a"], variant: undefined },
    ]
    const getSessionFallbackChain = mock((sessionId: string) =>
      sessionId === "child-session" ? fallbackChain : undefined,
    )
    const manager = createBackgroundManagerWithOptions({
      modelFallbackControllerAccessor: {
        register: () => {},
        setSessionFallbackChain: () => {},
        getSessionFallbackChain,
        clearSessionFallbackChain: () => {},
      },
    })
    const task = createMockTask({
      id: "task-sync-fallback",
      sessionId: "child-session",
      parentSessionId: "parent-session",
      fallbackChain: undefined,
    })
    let capturedFallbackChain: BackgroundTask["fallbackChain"]
    ;(cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry = async (retryTask) => {
      capturedFallbackChain = retryTask.fallbackChain
      return true
    }

    //#when
    await (cast<{
      handleSessionErrorEvent: (args: {
        task: BackgroundTask
        errorInfo: { name?: string; message?: string }
        errorName: string | undefined
        errorMessage: string | undefined
      }) => Promise<void>
    }>(manager)).handleSessionErrorEvent({
      task,
      errorInfo: {
        name: "APIError",
        message: "Forbidden: Selected provider is forbidden",
      },
      errorName: "APIError",
      errorMessage: "Forbidden: Selected provider is forbidden",
    })

    //#then
    expect(getSessionFallbackChain).toHaveBeenCalledWith("child-session")
    expect(task.fallbackChain).toEqual(fallbackChain)
    expect(capturedFallbackChain).toEqual(fallbackChain)
  })
})

describe("BackgroundManager delegated child-session bootstrap", () => {
  test("registers launch bootstrap before first prompt and clears it after completion", async () => {
    //#given
    clearAllDelegatedChildSessionBootstrap()
    const observedBootstrapPrompts: string[] = []
    const client = {
      session: {
        get: async () => ({ data: { directory: tmpdir() } }),
        create: async () => ({ data: { id: "ses_background_bootstrap" } }),
        promptAsync: async () => {
          const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
          observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "")
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)
    const task = createMockTask({
      id: "bg_bootstrap",
      parentSessionId: "parent-session",
      status: "pending",
      queuedAt: new Date(),
      prompt: "background bootstrap prompt",
      agent: "sisyphus-junior",
      skillContent: "background delegated skill system",
      category: "quick",
      model: { providerID: "anthropic", modelID: "claude-haiku-4-5" },
      fallbackChain: [{ model: "gpt-5.4", providers: ["openai"], variant: "high" }],
    })
    getTaskMap(manager).set(task.id, task)
    const input = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      parentModel: task.parentModel,
      parentAgent: task.parentAgent,
      parentTools: task.parentTools,
      model: task.model,
      fallbackChain: task.fallbackChain,
      skillContent: task.skillContent,
      category: task.category,
    }

    try {
      //#when
      await (cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }>(manager))
        .startTask({ task, input })
      await flushBackgroundNotifications()

      //#then
      expect(observedBootstrapPrompts[0]).toContain("background bootstrap prompt")
      const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
      expect(bootstrap?.system).toBe("background delegated skill system")
      expect(bootstrap?.tools?.question).toBe(false)
      expect(bootstrap?.tools?.task).toBe(false)
      expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeDefined()

      const completed = await tryCompleteTaskForTest(manager, task)
      expect(completed).toBe(true)
      expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeUndefined()
    } finally {
      manager.shutdown()
      clearAllDelegatedChildSessionBootstrap()
    }
  })
})

describe("BackgroundManager prompt rejection fallback routing", () => {
  test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
    //#given
    const promptError = {
      name: "APIError",
      data: { message: "Forbidden: Selected provider is forbidden" },
    }
    const client = {
      session: {
        get: async () => ({ data: { directory: tmpdir() } }),
        create: async () => ({ data: { id: "ses_launch_retry" } }),
        promptAsync: async () => {
          throw promptError
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)
    ;(cast<{
      reserveSubagentSpawn: () => Promise<{
        spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
        descendantCount: number
        commit: () => number
        rollback: () => void
      }>
    }>(manager)).reserveSubagentSpawn = async () => ({
      spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
      descendantCount: 1,
      commit: () => 1,
      rollback: () => {},
    })
    const retried: Array<{ taskId: string; errorInfo: { name?: string; message?: string }; source: string }> = []
    ;(cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry = async (task, errorInfo, source) => {
      retried.push({ taskId: task.id, errorInfo, source })
      task.status = "pending"
      task.error = undefined
      return true
    }

    //#when
    const launchedTask = await manager.launch({
      description: "background retry test",
      prompt: "say hi",
      agent: "sisyphus-junior",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "genai-proxy-openai", modelID: "gpt-5.4-mini" },
      fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
    })
    await flushBackgroundNotifications()

    //#then
    const storedTask = getTaskMap(manager).get(launchedTask.id)
    expect(retried).toHaveLength(1)
    expect(retried[0]?.source).toBe("promptAsync.launch")
    expect(retried[0]?.errorInfo).toEqual({
      name: "APIError",
      message: "Forbidden: Selected provider is forbidden",
    })
    expect(storedTask?.status).toBe("pending")
  })

  test("routes resume-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
    //#given
    const promptError = {
      name: "APIError",
      data: { message: "Forbidden: Selected provider is forbidden" },
    }
    const client = {
      session: {
        promptAsync: async () => {
          throw promptError
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)
    const task: BackgroundTask = {
      id: "bg_resume_retry",
      sessionId: "ses_resume_retry",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      description: "resume retry test",
      prompt: "say hi",
      agent: "sisyphus-junior",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
      model: { providerID: "genai-proxy-openai", modelID: "gpt-5.4-mini" },
      fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
      concurrencyGroup: "genai-proxy-openai/gpt-5.4-mini",
    }
    getTaskMap(manager).set(task.id, task)
    const retried: Array<{ taskId: string; errorInfo: { name?: string; message?: string }; source: string }> = []
    ;(cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry = async (retryTask, errorInfo, source) => {
      retried.push({ taskId: retryTask.id, errorInfo, source })
      retryTask.status = "pending"
      retryTask.error = undefined
      return true
    }

    //#when
    await manager.resume({
      sessionId: "ses_resume_retry",
      prompt: "continue",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message-2",
    })
    await flushBackgroundNotifications()

    //#then
    const storedTask = getTaskMap(manager).get(task.id)
    expect(retried).toHaveLength(1)
    expect(retried[0]?.source).toBe("promptAsync.resume")
    expect(retried[0]?.errorInfo).toEqual({
      name: "APIError",
      message: "Forbidden: Selected provider is forbidden",
    })
    expect(storedTask?.status).toBe("pending")
  })
})

describe("BackgroundManager retry observability", () => {
  test("queues a parent-visible retry notification when fallback retry is scheduled", async () => {
    //#given
    const client = {
      session: {
        messages: async () => [
          {
            info: {
              agent: "hephaestus",
              model: {
                providerID: "openai",
                modelID: "gpt-5",
                variant: "xhigh",
              },
              tools: { bash: "allow", edit: "deny" },
            },
          },
        ],
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task = createMockTask({
      id: "bg_retry_observable",
      parentSessionId: "parent-session",
      fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
      attemptCount: 0,
      status: "running",
      attempts: [
        {
          attemptId: "att_retry_visibility",
          attemptNumber: 1,
          sessionId: "ses_retry_visibility",
          providerId: "genai-proxy-openai",
          modelId: "gpt-5.4-mini",
          status: "running",
        },
      ],
      currentAttemptID: "att_retry_visibility",
    })
    getTaskMap(manager).set(task.id, task)
    const queuePendingParentWake = mock(() => {})
    ;(cast<{
      queuePendingParentWake: (
        sessionId: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
    }>(manager)).queuePendingParentWake = queuePendingParentWake

    //#when
    await (cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry(task, {
      name: "APIError",
      message: "Forbidden: Selected provider is forbidden",
    }, "promptAsync.launch")

    //#then
    expect(queuePendingParentWake).toHaveBeenCalledTimes(1)
    const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
      queuePendingParentWake.mock.calls,
    )[0]
    if (!retryingCall) {
      throw new Error("Expected retrying parent wake call")
    }
    const [sessionID, notification, promptContext, shouldReply] = retryingCall
    expect(sessionID).toBe("parent-session")
    expect(promptContext).toEqual({
      agent: "hephaestus",
      model: { providerID: "openai", modelID: "gpt-5" },
      variant: "xhigh",
      tools: { bash: true, edit: false },
    })
    expect(shouldReply).toBe(false)
    expect(notification).toContain("[BACKGROUND TASK RETRYING]")
    expect(notification).toContain("ses_retry_visibility")
    expect(notification).toContain("genai-proxy-openai/gpt-5.4-mini")
    expect(notification).toContain("anthropic/claude-haiku-4.5")
  })

  test("falls back to task parent agent when retrying wake cannot load parent messages", async () => {
    //#given
    const client = {
      session: {
        messages: async () => {
          throw new Error("parent messages unavailable")
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task = createMockTask({
      id: "bg_retry_parent_agent_fallback",
      parentSessionId: "parent-session-agent-fallback",
      parentAgent: "hephaestus",
      parentTools: { bash: true },
      fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
      attemptCount: 0,
      status: "running",
      attempts: [
        {
          attemptId: "att_retry_parent_agent_fallback",
          attemptNumber: 1,
          sessionId: "ses_retry_parent_agent_fallback",
          providerId: "genai-proxy-openai",
          modelId: "gpt-5.4-mini",
          status: "running",
        },
      ],
      currentAttemptID: "att_retry_parent_agent_fallback",
    })
    getTaskMap(manager).set(task.id, task)
    const queuePendingParentWake = mock(() => {})
    ;(cast<{
      queuePendingParentWake: (
        sessionId: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
    }>(manager)).queuePendingParentWake = queuePendingParentWake

    //#when
    await (cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry(task, {
      name: "APIError",
      message: "Forbidden: Selected provider is forbidden",
    }, "promptAsync.launch")

    //#then
    const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
      queuePendingParentWake.mock.calls,
    )[0]
    expect(retryingCall?.[2]).toEqual({
      agent: "hephaestus",
      tools: { bash: true },
    })
  })

  test("does not invent a parent agent when retrying wake has no context source", async () => {
    //#given
    const client = {
      session: {
        messages: async () => {
          throw new Error("parent messages unavailable")
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task = createMockTask({
      id: "bg_retry_no_parent_context",
      parentSessionId: "parent-session-no-context",
      fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
      attemptCount: 0,
      status: "running",
      attempts: [
        {
          attemptId: "att_retry_no_parent_context",
          attemptNumber: 1,
          sessionId: "ses_retry_no_parent_context",
          providerId: "genai-proxy-openai",
          modelId: "gpt-5.4-mini",
          status: "running",
        },
      ],
      currentAttemptID: "att_retry_no_parent_context",
    })
    getTaskMap(manager).set(task.id, task)
    const queuePendingParentWake = mock(() => {})
    ;(cast<{
      queuePendingParentWake: (
        sessionId: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
    }>(manager)).queuePendingParentWake = queuePendingParentWake

    //#when
    await (cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry(task, {
      name: "APIError",
      message: "Forbidden: Selected provider is forbidden",
    }, "promptAsync.launch")

    //#then
    const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
      queuePendingParentWake.mock.calls,
    )[0]
    expect(retryingCall?.[2]).toEqual({})
  })

  test("queues a second parent-visible notification once the retry session ID is created", async () => {
    //#given
    const queuePendingParentWake = mock(() => {})
    const client = {
      session: {
        get: async () => ({ data: { directory: tmpdir() } }),
        create: async () => ({ data: { id: "ses_retry_created" } }),
        messages: async () => [
          {
            info: {
              agent: "hephaestus",
              model: {
                providerID: "openai",
                modelID: "gpt-5",
                variant: "xhigh",
              },
              tools: { bash: "allow", edit: "deny" },
            },
          },
        ],
        promptAsync: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    ;(cast<{
      queuePendingParentWake: (
        sessionId: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
    }>(manager)).queuePendingParentWake = queuePendingParentWake
    const task = createMockTask({
      id: "bg_retry_ready",
      parentSessionId: "parent-session",
      status: "pending",
      attemptCount: 1,
      queuedAt: new Date(),
      model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
      fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
      concurrencyGroup: "anthropic/claude-haiku-4.5",
      retryNotification: {
        nextModel: "anthropic/claude-haiku-4.5",
      },
      attempts: [
        {
          attemptId: "att_retry_failed",
          attemptNumber: 1,
          sessionId: "ses_retry_visibility",
          providerId: "genai-proxy-openai",
          modelId: "gpt-5.4-mini",
          status: "error",
          error: "Forbidden: Selected provider is forbidden",
        },
        {
          attemptId: "att_retry_ready",
          attemptNumber: 2,
          providerId: "anthropic",
          modelId: "claude-haiku-4.5",
          status: "pending",
        },
      ],
      currentAttemptID: "att_retry_ready",
    })
    getTaskMap(manager).set(task.id, task)
    const taskInput = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: task.model,
      fallbackChain: task.fallbackChain,
      category: task.category,
    }
    type RetryReadyQueueItem = {
      task: BackgroundTask
      input: typeof taskInput
      attemptID: string
    }
    const item: RetryReadyQueueItem = {
      task,
      input: taskInput,
      attemptID: task.currentAttemptID ?? "att_retry_ready",
    }

    //#when
    await (cast<{
      startTask: (queueItem: RetryReadyQueueItem) => Promise<void>
    }>(manager)).startTask(item)

    //#then
    const retryReadyCall = cast<Array<[string, string, Record<string, unknown>, boolean, number | undefined]>>(
      queuePendingParentWake.mock.calls,
    ).find((call) => call[1].includes("[BACKGROUND TASK RETRY SESSION READY]"))
    const retryReadyNotification = retryReadyCall?.[1]
    const expectedRetryLink = `http://127.0.0.1:4096/${Buffer.from(tmpdir()).toString("base64url")}/session/ses_retry_created`
    expect(retryReadyNotification).toBeDefined()
    expect(retryReadyCall?.[2]).toEqual({
      agent: "hephaestus",
      model: { providerID: "openai", modelID: "gpt-5" },
      variant: "xhigh",
      tools: { bash: true, edit: false },
    })
    expect(retryReadyNotification).toContain("**Retry attempt:** 2")
    expect(retryReadyNotification).toContain("ses_retry_created")
    expect(retryReadyNotification).toContain(expectedRetryLink)
    expect(retryReadyNotification).toContain("ses_retry_visibility")
    expect(retryReadyNotification).toContain("genai-proxy-openai/gpt-5.4-mini")
    expect(retryReadyNotification).toContain("Forbidden: Selected provider is forbidden")
  })

  test("builds retry-ready links from the parent session directory when it differs from the manager directory", async () => {
    //#given
    const queuePendingParentWake = mock(() => {})
    const managerDirectory = "/manager/dir"
    const parentDirectory = "/parent/dir"
    const client = {
      session: {
        get: async () => ({ data: { directory: parentDirectory } }),
        create: async () => ({ data: { id: "ses_retry_created_parent_dir" } }),
        promptAsync: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client, managerDirectory) })
    ;(cast<{
      queuePendingParentWake: (
        sessionId: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
    }>(manager)).queuePendingParentWake = queuePendingParentWake
    const task = createMockTask({
      id: "bg_retry_ready_parent_dir",
      parentSessionId: "parent-session",
      status: "pending",
      attemptCount: 1,
      queuedAt: new Date(),
      model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
      retryNotification: {
        nextModel: "anthropic/claude-haiku-4.5",
      },
      attempts: [
        {
          attemptId: "att_retry_failed_parent_dir",
          attemptNumber: 1,
          sessionId: "ses_retry_failed_parent_dir",
          providerId: "genai-proxy-openai",
          modelId: "gpt-5.4-mini",
          status: "error",
          error: "Forbidden: Selected provider is forbidden",
        },
        {
          attemptId: "att_retry_ready_parent_dir",
          attemptNumber: 2,
          providerId: "anthropic",
          modelId: "claude-haiku-4.5",
          status: "pending",
        },
      ],
      currentAttemptID: "att_retry_ready_parent_dir",
    })
    getTaskMap(manager).set(task.id, task)
    const taskInput = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: task.model,
      fallbackChain: task.fallbackChain,
      category: task.category,
    }

    //#when
    await (cast<{
      startTask: (queueItem: { task: BackgroundTask; input: typeof taskInput; attemptID: string }) => Promise<void>
    }>(manager)).startTask({ task, input: taskInput, attemptID: "att_retry_ready_parent_dir" })

    //#then
    const retryReadyNotification = cast<Array<[string, string, Record<string, unknown>, boolean, number | undefined]>>(
      queuePendingParentWake.mock.calls,
    )
      .map((call) => call[1])
      .find((notification) => notification.includes("[BACKGROUND TASK RETRY SESSION READY]"))
    const expectedRetryLink = `http://127.0.0.1:4096/${Buffer.from(parentDirectory).toString("base64url")}/session/ses_retry_created_parent_dir`
    expect(retryReadyNotification).toBeDefined()
    expect(retryReadyNotification).toContain(expectedRetryLink)

    manager.shutdown()
  })
})

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", () => {
    // 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
    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", () => {
    // 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
    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.7" },
              },
            },
            {
              info: {
                agent: "compaction",
                model: { providerID: "anthropic", modelID: "claude-sonnet-4.6" },
              },
            },
          ],
        }),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    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 (cast<{ notifyParentSession: (value: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)
    await waitForCoalescedFlush()

    //#then
    expect(capturedBody?.agent).toBe("sisyphus")
    expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })

    manager.shutdown()
  })

  test("should use currentMessage model/agent when available", async () => {
    // given
    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.7" },
    }

    // when
    const promptBody = buildNotificationPromptBody(task, currentMessage)

    // then
    expect(promptBody.agent).toBe("sisyphus")
    expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
  })

  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
    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
    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
    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({ pluginContext: createPluginInput(client) })
    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 (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)
    await waitForCoalescedFlush()

    //#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({ pluginContext: createPluginInput(client) })
    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 (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)
    await waitForCoalescedFlush()

    //#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({ pluginContext: createPluginInput(client) })
    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 (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)
    await waitForCoalescedFlush()

    //#then
    const pendingWake = getPendingParentWakes(manager).get("session-parent")
    expect(pendingWake?.notifications).toHaveLength(1)
    expect(pendingWake?.notifications[0]).toContain("<system-reminder>")
    expect(pendingWake?.notifications[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: [{
            info: {
              agent: "explore",
              model: {
                providerID: "anthropic",
                modelID: "claude-opus-4.7",
                variant: "high",
              },
            },
          }],
        }),
      },
    }
    const manager = new BackgroundManager(
      { pluginContext: createPluginInput(client), config: 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 (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)

    //#then
    expect(promptCalled).toBe(false)

    manager.shutdown()
  })
})

describe("BackgroundManager.notifyParentSession - variant propagation", () => {
  test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => {
    //#given
    const promptCalls: Array<{ body: Record<string, unknown> }> = []
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCalls.push({ body: args.body })
          return {}
        },
        abort: async () => ({}),
        messages: async () => ({
          data: [{
            info: {
              agent: "explore",
              model: {
                providerID: "anthropic",
                modelID: "claude-opus-4.7",
                variant: "max",
              },
            },
          }],
        }),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-parent-variant-wins",
      sessionId: "session-child",
      parentSessionId: "session-parent",
      parentMessageId: "msg-parent",
      description: "task with mismatched variant",
      prompt: "test",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
      model: { providerID: "anthropic", modelID: "claude-opus-4.7", variant: "high" },
    }
    getPendingByParent(manager).set("session-parent", new Set([task.id]))

    //#when
    await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)
    await waitForCoalescedFlush()

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(promptCalls[0].body.variant).toBe("max")

    manager.shutdown()
  })

  test("should not include variant in promptAsync body when task has no variant", async () => {
    //#given
    const promptCalls: Array<{ body: Record<string, unknown> }> = []
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCalls.push({ body: args.body })
          return {}
        },
        abort: async () => ({}),
        messages: async () => ({ data: [] }),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-no-variant",
      sessionId: "session-child",
      parentSessionId: "session-parent",
      parentMessageId: "msg-parent",
      description: "task without variant",
      prompt: "test",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
      model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
    }
    getPendingByParent(manager).set("session-parent", new Set([task.id]))

    //#when
    await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(task)
    await waitForCoalescedFlush()

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(promptCalls[0].body.variant).toBeUndefined()

    manager.shutdown()
  })
})

describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
  test("should defer queued notifications without mutating user text", () => {
    // 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).toEqual([{ type: "text", text: "User prompt" }])
    expect(getPendingParentWakes(manager).get("session-parent")?.notifications).toEqual([
      "<system-reminder>queued-one</system-reminder>\n\n<system-reminder>queued-two</system-reminder>",
    ])
    expect(getPendingParentWakes(manager).get("session-parent")?.shouldReply).toBe(false)
    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.7"
    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.7"
    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({ pluginContext: createPluginInput(client) })
    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({ pluginContext: createPluginInput(client) })

    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.7"
    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.7" },
    }
    getTaskMap(manager).set(task.id, task)
    getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])

    ;(cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }>(manager)).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 mark task as error when startTask throws after session creation", async () => {
    //#given - startTask creates session but fails before sending prompt
    const concurrencyKey = "anthropic/claude-opus-4.7"

    const task = createMockTask({
      id: "task-zombie-session",
      sessionId: "session-zombie-placeholder",
      parentSessionId: "parent-zombie",
      status: "pending",
      agent: "explore",
    })
    delete (task as Partial<BackgroundTask>).sessionId

    const input = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
    }
    getTaskMap(manager).set(task.id, task)
    getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])

    ;(cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }>(manager)).startTask = async (item) => {
      item.task.status = "running"
      item.task.sessionId = "ses_zombie_child"
      item.task.startedAt = new Date()
      item.task.concurrencyKey = concurrencyKey
      throw new Error("crash between session creation and prompt send")
    }

    //#when
    await processKeyForTest(manager, concurrencyKey)

    //#then - task must be marked as error, not left in running zombie state
    expect(task.status).toBe("error")
    expect(task.error).toContain("crash between session creation and prompt send")
    expect(task.completedAt).toBeDefined()
  })

  test("should release queue slot when queued task is already interrupt", async () => {
    // given
    const concurrencyKey = "anthropic/claude-opus-4.7"
    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.7" },
    }
    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({ pluginContext: createPluginInput(client) })

    const parentSessionID = "parent-session"
    const taskA = createMockTask({
      id: "task-a",
      sessionId: "session-a",
      parentSessionId: parentSessionID,
    })
    const taskB = createMockTask({
      id: "task-b",
      sessionId: "session-b",
      parentSessionId: 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(1)
    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 promptAsync gate state", () => {
  test("restores completed task state when resume prompt is skipped because the session is active", async () => {
    //#given
    let promptCallCount = 0
    const client = {
      session: {
        status: async () => ({ data: { "session-active-resume": { type: "busy" } } }),
        promptAsync: async () => {
          promptCallCount += 1
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-active-resume-skip",
      sessionId: "session-active-resume",
      parentSessionId: "parent-session-original",
      parentMessageId: "msg-original",
      description: "completed task",
      prompt: "original prompt",
      agent: "explore",
      status: "completed",
      startedAt: new Date(Date.now() - 1000),
      completedAt: new Date(),
      error: "previous terminal note",
      concurrencyGroup: "explore",
    }
    const originalCompletedAt = task.completedAt
    getTaskMap(manager).set(task.id, task)

    //#when
    await manager.resume({
      sessionId: "session-active-resume",
      prompt: "continue",
      parentSessionId: "parent-session-new",
      parentMessageId: "msg-new",
    })
    await flushBackgroundNotifications()

    //#then
    expect(promptCallCount).toBe(0)
    expect(task.status).toBe("completed")
    expect(task.completedAt).toBe(originalCompletedAt)
    expect(task.error).toBe("previous terminal note")
    expect(task.parentSessionId).toBe("parent-session-original")
    expect(task.parentMessageId).toBe("msg-original")
    expect(task.concurrencyKey).toBeUndefined()
    expect(getConcurrencyManager(manager).getCount("explore")).toBe(0)
    expect(getPendingByParent(manager).get("parent-session-new")).toBeUndefined()

    manager.shutdown()
  })

  test("restores completed task state when resume prompt is skipped by an existing reservation", async () => {
    //#given
    let promptCallCount = 0
    const client = {
      session: {
        promptAsync: async () => {
          promptCallCount += 1
          return {}
        },
        abort: async () => ({}),
      },
    }
    await dispatchInternalPrompt({
      mode: "async",
      client,
      sessionID: "session-reserved-resume",
      source: "test-existing-reservation",
      settleMs: 0,
      postDispatchHoldMs: 1000,
      input: {
        path: { id: "session-reserved-resume" },
        body: { parts: [] },
      },
    })

    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-reserved-resume-skip",
      sessionId: "session-reserved-resume",
      parentSessionId: "parent-session-original",
      parentMessageId: "msg-original",
      description: "completed task",
      prompt: "original prompt",
      agent: "explore",
      status: "completed",
      startedAt: new Date(Date.now() - 1000),
      completedAt: new Date(),
      concurrencyGroup: "explore",
    }
    getTaskMap(manager).set(task.id, task)

    //#when
    await manager.resume({
      sessionId: "session-reserved-resume",
      prompt: "continue",
      parentSessionId: "parent-session-new",
      parentMessageId: "msg-new",
    })
    await flushBackgroundNotifications()

    //#then
    expect(promptCallCount).toBe(1)
    expect(task.status).toBe("completed")
    expect(task.parentSessionId).toBe("parent-session-original")
    expect(task.parentMessageId).toBe("msg-original")
    expect(task.concurrencyKey).toBeUndefined()
    expect(getConcurrencyManager(manager).getCount("explore")).toBe(0)
    expect(getPendingByParent(manager).get("parent-session-new")).toBeUndefined()

    manager.shutdown()
  })
})

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({ pluginContext: createPluginInput(client) })
     stubNotifyParentSession(manager)
   })

  afterEach(() => {
    clearSessionPromptParams("session-1")
    clearSessionPromptParams("session-advanced")
    clearSessionPromptParams("session-2")
    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
    expect(promptCalls).toHaveLength(1)
    expect(promptCalls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })
    expect(promptCalls[0].body.agent).toBe("explore")
  })

  test("should preserve promoted per-model settings when resuming a task", async () => {
    // given - task resumed after fallback promotion
    const taskWithAdvancedModel: BackgroundTask = {
      id: "task-with-advanced-model",
      sessionId: "session-advanced",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "task with advanced model settings",
      prompt: "original prompt",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
      model: {
        providerID: "openai",
        modelID: "gpt-5.4-preview",
        variant: "minimal",
        reasoningEffort: "high",
        temperature: 0.25,
        top_p: 0.55,
        maxTokens: 8192,
        thinking: { type: "disabled" },
      },
      concurrencyGroup: "explore",
    }
    getTaskMap(manager).set(taskWithAdvancedModel.id, taskWithAdvancedModel)

    // when
    await manager.resume({
      sessionId: "session-advanced",
      prompt: "continue the work",
      parentSessionId: "parent-session-2",
      parentMessageId: "msg-2",
    })

    // then
    expect(promptCalls).toHaveLength(1)
    expect(promptCalls[0].body.model).toEqual({
      providerID: "openai",
      modelID: "gpt-5.4-preview",
    })
    expect(promptCalls[0].body.variant).toBe("minimal")
    expect(promptCalls[0].body.options).toBeUndefined()
    expect(getSessionPromptParams("session-advanced")).toEqual({
      temperature: 0.25,
      topP: 0.55,
      maxOutputTokens: 8192,
      options: {
        reasoningEffort: "high",
        thinking: { type: "disabled" },
      },
    })
  })

  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
    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
    resetProcessCleanupState()
    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])
    }

    resetProcessCleanupState()
  })
})

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({ pluginContext: createPluginInput(mockClient) })
  })

  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 sanitize wrapped agent names before task creation and queueing", async () => {
      // given
      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "\\hephaestus\\",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      // when
      const task = await manager.launch(input)
      const queueItem = getQueuesByKey(manager).values().next().value?.[0]

      // then
      expect(task.agent).toBe("hephaestus")
      expect(getTaskMap(manager).get(task.id)?.agent).toBe("hephaestus")
      // queueItem may be undefined if the queue was immediately processed
      if (queueItem) {
        expect(queueItem.input.agent).toBe("hephaestus")
      }
    })

    test("should sanitize slash and quote wrapped agent names before task creation and queueing", async () => {
      // given
      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "\"/hephaestus/\"",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      // when
      const task = await manager.launch(input)
      const queueItem = getQueuesByKey(manager).values().next().value?.[0]

      // then
      expect(task.agent).toBe("hephaestus")
      expect(getTaskMap(manager).get(task.id)?.agent).toBe("hephaestus")
      // queueItem may be undefined if the queue was immediately processed
      if (queueItem) {
        expect(queueItem.input.agent).toBe("hephaestus")
      }
    })

    test("should reject wrapper-only agent names after sanitization", async () => {
      // given
      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "\\\"/'\\\"/",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      // when
      const result = manager.launch(input)

      // then
      await expectRejectsWithMessage(result, "Agent parameter is required after sanitization")
    })

    test("should initialize attempt state for a newly launched task", async () => {
      // given
      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
        model: {
          providerID: "openai",
          modelID: "gpt-5.4-mini",
          variant: "medium",
        },
      }

      // when
      const task = await manager.launch(input)

      // then
      expect(task.attempts).toHaveLength(1)
      expect(task.currentAttemptID).toBe(task.attempts?.[0]?.attemptId)
      expect(task.attempts?.[0]).toEqual({
        attemptId: task.currentAttemptID,
        attemptNumber: 1,
        providerId: "openai",
        modelId: "gpt-5.4-mini",
        variant: "medium",
        status: "pending",
      })

      expect(task.status).toBe("pending")
      expect(task.model).toEqual(input.model)
      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({ pluginContext: createPluginInput(mockClient), config: 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)
    expect(task1.status).toBe("pending")
    expect(task2.status).toBe("pending")
  })

  test("should keep 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({ pluginContext: createPluginInput(customClient) })

    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.7" },
    }
    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.7" })
    expect(promptBodies[0].agent).toBe("test-agent")
    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({ pluginContext: createPluginInput(mockClient), config: 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({ pluginContext: createPluginInput(customClient), config: {
        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({ pluginContext: createPluginInput(mockClient), config: 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)
      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({ pluginContext: createPluginInput(mockClient), config: 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

      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(
        { pluginContext: cast<PluginInput>({
          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(),
        }), config: { 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(
        { pluginContext: cast<PluginInput>({
          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(),
        }), config: { 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 expectRejectsWithMessage(result, "background_task.maxDepth=3")
    })

    test("allows multiple descendants without a root spawn cap", async () => {
      // given
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }) },
      )

      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 expectResolvesDefined(result)
    })

    test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
      // given
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }) },
      )

      await manager.reserveSubagentSpawn("session-root")

      // when
      const result = manager.assertCanSpawn("session-root")

      // then
      await expectResolvesMatchObject(result, {
        rootSessionID: "session-root",
        childDepth: 1,
      })
    })

    test("should fail closed when session lineage lookup fails", async () => {
      // given
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain(
            {
              "session-root": { directory: "/test/dir" },
            },
            { sessionLookupError: new Error("session lookup failed") }
          ),
          directory: tmpdir(),
        }) },
      )

      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 expectRejectsWithMessage(result, "background_task.maxDepth cannot be enforced safely")
    })

    test("allows replacement launch when a queued task is cancelled before session starts", async () => {
      // given
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }), config: { defaultConcurrency: 1 } },
      )

      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("allows retry after session creation fails before session starts", async () => {
      // given
      let createAttempts = 0
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          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(),
        }) },
      )

      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 only roll back the failed task reservation once when siblings still exist", async () => {
      // given
      const concurrencyKey = "test-agent"
      const task = createMockTask({
        id: "task-single-reservation-rollback",
        sessionId: "session-single-reservation-rollback",
        parentSessionId: "session-root",
        status: "pending",
        agent: "test-agent",
        rootSessionId: "session-root",
      })
      delete (task as Partial<BackgroundTask>).sessionId

      const input = {
        description: task.description,
        prompt: task.prompt,
        agent: task.agent,
        parentSessionId: task.parentSessionId,
        parentMessageId: task.parentMessageId,
      }

      getTaskMap(manager).set(task.id, task)
      getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
      getRootDescendantCounts(manager).set("session-root", 2)
      getPreStartDescendantReservations(manager).add(task.id)
      stubNotifyParentSession(manager)

      ;(cast<{
        startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void>
      }>(manager)).startTask = async () => {
        throw new Error("session create failed")
      }

      // when
      await processKeyForTest(manager, concurrencyKey)

      // then
      expect(getRootDescendantCounts(manager).get("session-root")).toBe(1)
    })

    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(
        { pluginContext: cast<PluginInput>({
          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(),
        }), config: { 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 sibling launch running when concurrent launches share a parent and the first is cancelled during session creation", async () => {
      // given
      const firstSessionID = "ses-first-concurrent-cancelled"
      const secondSessionID = "ses-second-concurrent-survives"
      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(
        { pluginContext: cast<PluginInput>({
          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(),
        }), config: { defaultConcurrency: 1 } }
      )

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      // when
      const [firstTask, secondTask] = await Promise.all([
        manager.launch(input),
        manager.launch(input),
      ])
      await firstCreateStarted

      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(
        { pluginContext: cast<PluginInput>({
          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(),
        }), config: { 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 flushBackgroundNotifications()

      // 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)
    })

      test("should start prompt before tmux callback cancellation", async () => {
      // given
      resetClaudeCodeSessionState()
      const originalTmuxEnvironment = process.env.TMUX
      process.env.TMUX = "test-session"

      try {
        const createdSessionID = "ses-cancelled-during-tmux-callback"
        const abortCalls: string[] = []
        const promptAsyncSessionIDs: string[] = []
        let taskID: string | undefined
        let resolveCancelCalled: (() => void) | undefined
        const cancelCalled = new Promise<void>((resolve) => {
          resolveCancelCalled = resolve
        })

        manager.shutdown()
        manager = new BackgroundManager(
          { pluginContext: cast<PluginInput>({
            client: {
              session: {
                create: async () => ({ data: { id: createdSessionID } }),
                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)
                  return {}
                },
              },
            },
            directory: tmpdir(),
          }), config: {
            defaultConcurrency: 1,
          }, tmuxConfig: {
              enabled: true,
              layout: "main-vertical",
              main_pane_size: 60,
              main_pane_min_width: 120,
              agent_pane_min_width: 40,
              isolation: "inline",
            },
            onSubagentSessionCreated: async () => {
              const activeTaskID = taskID ?? Array.from(getTaskMap(manager).keys())[0]

              if (!activeTaskID) {
                throw new Error("expected active task during tmux callback")
              }

              await manager.cancelTask(activeTaskID, {
                source: "test",
                abortSession: false,
              })
              resolveCancelCalled?.()
            }, }
        )

        const input = {
          description: "Test task",
          prompt: "Do something",
          agent: "test-agent",
          parentSessionId: "parent-session",
          parentMessageId: "parent-message",
        }

        const task = await manager.launch(input)
        taskID = task.id

        // when
        await Promise.race([
          cancelCalled,
          new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 500)),
        ])
        await flushBackgroundNotifications()

        // then
        const updatedTask = manager.getTask(task.id)
        expect(updatedTask?.status).toBe("cancelled")
        expect(updatedTask?.sessionId).toBe(createdSessionID)
        expect(promptAsyncSessionIDs).toContain(createdSessionID)
        expect(abortCalls).toEqual([])
        expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0)
        expect(getRootDescendantCounts(manager).has("parent-session")).toBe(false)
        expect(subagentSessions.has(createdSessionID)).toBe(true)
      } finally {
        resetClaudeCodeSessionState()
        if (originalTmuxEnvironment === undefined) {
          delete process.env.TMUX
        } else {
          process.env.TMUX = originalTmuxEnvironment
        }
      }
    })

    test("allows relaunch after task completes", async () => {
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }) },
      )
      stubNotifyParentSession(manager)

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "session-root",
        parentMessageId: "parent-message",
      }

      const task = await manager.launch(input)
      const internalTask = getTaskMap(manager).get(task.id)!
      internalTask.status = "running"
      internalTask.sessionId = "child-session-complete"
      internalTask.rootSessionId = "session-root"

      // Complete via internal method (session.status events go through the poller, not handleEvent)
      await tryCompleteTaskForTest(manager, internalTask)

      await expectResolvesDefined(manager.launch(input))
    })

    test("allows relaunch after running task is cancelled", async () => {
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }) },
      )

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "session-root",
        parentMessageId: "parent-message",
      }

      const task = await manager.launch(input)
      const internalTask = getTaskMap(manager).get(task.id)!
      internalTask.status = "running"
      internalTask.sessionId = "child-session-cancel"

      await manager.cancelTask(task.id)

      await expectResolvesDefined(manager.launch(input))
    })

    test("allows relaunch after task errors", async () => {
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }) },
      )

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "session-root",
        parentMessageId: "parent-message",
      }

      const task = await manager.launch(input)
      const internalTask = getTaskMap(manager).get(task.id)!
      internalTask.status = "running"
      internalTask.sessionId = "child-session-error"

      manager.handleEvent({
        type: "session.error",
        properties: { sessionID: internalTask.sessionId, info: { id: internalTask.sessionId } },
      })
      await new Promise((resolve) => setTimeout(resolve, 100))

      await expectResolvesDefined(manager.launch(input))
    })

    test("allows repeated relaunch after pending tasks are cancelled", async () => {
      manager.shutdown()
      manager = new BackgroundManager(
        { pluginContext: cast<PluginInput>({
          client: createMockClientWithSessionChain({
            "session-root": { directory: "/test/dir" },
          }),
          directory: tmpdir(),
        }) },
      )

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "session-root",
        parentMessageId: "parent-message",
      }

      const task1 = await manager.launch(input)
      const task2 = await manager.launch(input)

      await manager.cancelTask(task1.id)
      await manager.cancelTask(task2.id)

      await expectResolvesDefined(manager.launch(input))
      await expectResolvesDefined(manager.launch(input))
    })
  })

  describe("pending task can be cancelled", () => {
    test("should cancel pending task successfully", async () => {
      // given
      const config = { defaultConcurrency: 1 }
      manager.shutdown()
      manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      await manager.launch(input)
      const task2 = await manager.launch(input)
      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({ pluginContext: createPluginInput(mockClient), config: config })

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      const task = await manager.launch(input)
      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({ pluginContext: createPluginInput(mockClient), config: config })

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      await manager.launch(input)
      const task2 = await manager.launch(input)
      const task3 = await manager.launch(input)
      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({ pluginContext: createPluginInput(mockClient), config: 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)
      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({ pluginContext: createPluginInput(mockClient), config: 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)
      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({ pluginContext: createPluginInput(mockClient), config: config })

      const input1 = {
        description: "Task 1",
        prompt: "Do something",
        agent: "test-agent",
        model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
        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)
      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({ pluginContext: createPluginInput(mockClient), config: config })

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      await manager.launch(input)
      const task2 = await manager.launch(input)
      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()

      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({ pluginContext: createPluginInput(mockClient), config: 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)
      await new Promise(resolve => setTimeout(resolve, 50))

      // then
      const runningTask = manager.getTask(task.id)
      expect(runningTask?.status).toBe("running")
      expect(runningTask?.startedAt).toBeInstanceOf(Date)

      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({ pluginContext: createPluginInput(mockClient), config: config })

      const input = {
        description: "Test task",
        prompt: "Do something",
        agent: "test-agent",
        parentSessionId: "parent-session",
        parentMessageId: "parent-message",
      }

      await manager.launch(input)
      const task2 = await manager.launch(input)

      const queuedAt = task2.queuedAt!

      await new Promise(resolve => setTimeout(resolve, 50))

      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)
      }

      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({ pluginContext: createPluginInput(mockClient), config: 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)
      expect(tasks).toHaveLength(10)
      tasks.forEach(task => {
        expect(task.status).toBe("pending")
        expect(task.id).toMatch(/^bg_/)
      })

      await new Promise(resolve => setTimeout(resolve, 100))

      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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    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({ pluginContext: createPluginInput(client) })
    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"](undefined)

    expect(task.status).toBe("cancelled")
  })

  test("should interrupt running session when lastUpdate exceeds stale timeout", async () => {
    //#given
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
        get: async () => {
          throw new Error("missing")
        },
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
    stubNotifyParentSession(manager)

    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 still reports running, but progress is stale
    await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })

    //#then
    expect(task.status).toBe("cancelled")
    expect(task.error).toContain("Stale timeout")
  })

  test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
    //#given
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
        get: async () => {
          throw new Error("missing")
        },
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { 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 interrupt running session even with very old lastUpdate", async () => {
    //#given
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
    stubNotifyParentSession(manager)

    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
    expect(task.status).toBe("cancelled")
    expect(task.error).toContain("Stale timeout")
  })

  test("should interrupt running session with no progress after message staleness timeout", async () => {
    //#given - no progress at all, but session is running
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { messageStalenessTimeoutMs: 600_000 } })
    stubNotifyParentSession(manager)

    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,
      consecutiveMissedPolls: 2,
    }

    getTaskMap(manager).set(task.id, task)

    //#when - session is running despite no progress
    await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })

    //#then
    expect(task.status).toBe("cancelled")
    expect(task.error).toContain("no activity")
  })

  test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
    //#given
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        get: async () => ({
          error: { message: "Session not found", status: 404 },
          data: undefined,
        }),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { 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,
      consecutiveMissedPolls: 2,
    }

    getTaskMap(manager).set(task.id, task)

    //#when - no progress update for 15 minutes
    await manager["checkAndInterruptStaleTasks"]({})

    //#then - killed because session gone from status registry
    expect(task.status).toBe("cancelled")
    expect(task.error).toContain("session gone from status registry")
  })

  test("should NOT interrupt task with no lastUpdate within session-gone timeout", async () => {
    //#given
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { messageStalenessTimeoutMs: 600_000, sessionGoneTimeoutMs: 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 session-gone 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({ pluginContext: createPluginInput(client) })

    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({ pluginContext: createPluginInput(client) })

    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(
      { pluginContext: createPluginInput(client), config: 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(
      { pluginContext: createPluginInput(client), config: 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: parentSessionID,
      status: "running",
    })
    const siblingTask = createMockTask({
      id: "task-sibling",
      sessionId: "session-sibling",
      parentSessionId: 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 } },
    })

    // Flush twice: cancelTask now awaits session.abort() before cleanupPendingByParent,
    // so we need additional microtask ticks to let the cascade complete fully.
    await flushBackgroundNotifications()
    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: 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()
  })

  test("should clear session agent state for deleted sessions to prevent map leak", async () => {
    //#given
    const { setSessionAgent } = await import("../claude-code-session-state")
    resetClaudeCodeSessionState()
    const manager = createBackgroundManager()
    const sessionID = "session-deleted-agent-leak"
    setSessionAgent(sessionID, "sisyphus-junior")
    expect(getSessionAgent(sessionID)).toBe("sisyphus-junior")

    //#when
    manager.handleEvent({
      type: "session.deleted",
      properties: { info: { id: sessionID } },
    })

    //#then
    expect(getSessionAgent(sessionID)).toBeUndefined()

    manager.shutdown()
  })
})

describe("BackgroundManager.handleEvent - session.error", () => {
  const defaultRetryFallbackChain = [
    { providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
    { providers: ["anthropic"], model: "gpt-5.3-codex", variant: "high" },
  ]

  let logCalls: Array<{ message: string; data?: unknown }> = []
  let logSpy: ReturnType<typeof spyOn> | undefined
  let verifySessionExistsSpy: ReturnType<typeof spyOn> | undefined

  beforeEach(() => {
    logCalls = []
    logSpy = spyOn(sharedModule, "log").mockImplementation((message: string, data?: unknown) => {
      logCalls.push({ message, data })
    })
  })

  afterEach(() => {
    logSpy?.mockRestore()
    verifySessionExistsSpy?.mockRestore()
  })

  const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => {
    verifySessionExistsSpy?.mockRestore()
    const spy = spyOn(
      cast<{ verifySessionExists: (sessionID: string) => Promise<boolean> }>(manager),
      "verifySessionExists",
    )
    spy.mockImplementation(async () => sessionExists)
    verifySessionExistsSpy = spy
  }

  const stubProcessKey = (manager: BackgroundManager) => {
    ;(cast<{ processKey: (key: string) => Promise<void> }>(manager)).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.7-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()
    mockVerifySessionExists(manager, false)
    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: 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: 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()
    mockVerifySessionExists(manager, false)
    const sessionID = "ses_error_toast"
    const task = createMockTask({
      id: "task-session-error-toast",
      sessionId: sessionID,
      parentSessionId: "parent-session",
      status: "running",
    })
    getTaskMap(manager).set(task.id, task)

    //#when
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: 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: 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: 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("does not terminate task on session.error when session is still alive", async () => {
    //#given
    const manager = createBackgroundManagerWithOptions({
      log: (message: string, data?: unknown) => {
        logCalls.push({ message, data })
      },
    })
    mockVerifySessionExists(manager, true)

    const task = createMockTask({
      id: "task-session-error-alive",
      sessionId: "ses-alive",
      parentSessionId: "parent-session",
      parentMessageId: "msg-alive",
      description: "task with transient session.error",
      agent: "explore",
      status: "running",
    })
    getTaskMap(manager).set(task.id, task)

    //#when
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: task.sessionId,
        error: {
          name: "UnknownError",
          message: "Out of memory",
        },
      },
    })

    await flushBackgroundNotifications()

    //#then
    expect(task.status).toBe("running")
    expect(task.error).toBeUndefined()
    expect(
      logCalls.some((call) => call.message.includes("session.error received but session still alive")),
    ).toBe(true)

    manager.shutdown()
  })

  test("terminates task when agent-not-found arrives as async session.error after promptAsync accept", async () => {
    //#given
    const manager = createBackgroundManager()
    mockVerifySessionExists(manager, true)
    const concurrencyManager = getConcurrencyManager(manager)
    const concurrencyKey = "missing-agent"
    await concurrencyManager.acquire(concurrencyKey)

    const task = createMockTask({
      id: "task-session-error-agent-not-found",
      sessionId: "ses-agent-not-found",
      parentSessionId: "parent-session",
      parentMessageId: "msg-agent-not-found",
      description: "task with missing agent",
      agent: "missing-agent",
      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: task.sessionId,
        error: {
          name: "AgentNotFoundError",
          message: "Agent not found: missing-agent",
        },
      },
    })
    await flushBackgroundNotifications()

    //#then
    expect(task.status).toBe("interrupt")
    expect(task.error).toBe("Agent \"missing-agent\" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.")
    expect(task.completedAt).toBeInstanceOf(Date)
    expect(task.concurrencyKey).toBeUndefined()
    expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
    expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined()
    expect(getCompletionTimers(manager).has(task.id)).toBe(true)

    manager.shutdown()
  })

  test("requeues dispatched parent wake when the wake prompt fails through session.error", async () => {
    //#given
    const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
    const client = {
      session: {
        status: async () => ({ data: { "parent-session-wake": { type: "idle" } } }),
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCalls.push(args)
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const managerInternals = cast<{
      queuePendingParentWake: (
        sessionID: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
      flushPendingParentWake: (sessionID: string) => Promise<void>
    }>(manager)
    managerInternals.queuePendingParentWake(
      "parent-session-wake",
      "<system-reminder>done</system-reminder>",
      { agent: "sisyphus" },
      true,
      0,
    )

    //#when
    await managerInternals.flushPendingParentWake("parent-session-wake")
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: "parent-session-wake",
        error: { name: "UnknownError", message: "wake prompt failed" },
      },
    })
    await flushBackgroundNotifications()
    await waitForParentWakeRequeue(manager, "parent-session-wake")

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(getDispatchedParentWakes(manager).has("parent-session-wake")).toBe(false)
    expect(getPendingParentWakes(manager).get("parent-session-wake")?.notifications).toEqual([
      "<system-reminder>done</system-reminder>",
    ])

    manager.shutdown()
  })

  test("pins the registered parent agent alias before dispatching a deferred parent wake", async () => {
    //#given
    resetClaudeCodeSessionState()
    registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
    const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
    const client = {
      session: {
        status: async () => ({ data: { "parent-session-alias": { type: "idle" } } }),
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCalls.push(args)
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const managerInternals = cast<{
      queuePendingParentWake: (
        sessionID: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
      flushPendingParentWake: (sessionID: string) => Promise<void>
    }>(manager)

    //#when
    managerInternals.queuePendingParentWake(
      "parent-session-alias",
      "<system-reminder>done</system-reminder>",
      { agent: "atlas" },
      true,
      0,
    )
    await managerInternals.flushPendingParentWake("parent-session-alias")

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(promptCalls[0]?.body.agent).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")

    manager.shutdown()
    resetClaudeCodeSessionState()
  })

  test("does not requeue dispatched parent wake when session.error arrives before accepted history is visible", async () => {
    //#given
    const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
    const notification = "<system-reminder>done</system-reminder>"
    let historyAccepted = false
    const client = {
      session: {
        status: async () => ({ data: { "parent-session-wake": { type: "idle" } } }),
        messages: async () =>
          historyAccepted
            ? [
                {
                  info: {
                    role: "user",
                    time: { created: Date.now() },
                  },
                  parts: [{ type: "text", text: notification }],
                },
              ]
            : [],
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCalls.push(args)
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const managerInternals = cast<{
      queuePendingParentWake: (
        sessionID: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
      flushPendingParentWake: (sessionID: string) => Promise<void>
    }>(manager)
    managerInternals.queuePendingParentWake(
      "parent-session-wake",
      notification,
      { agent: "sisyphus" },
      true,
      0,
    )
    await managerInternals.flushPendingParentWake("parent-session-wake")

    //#when
    setTimeout(() => {
      historyAccepted = true
    }, 20)
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: "parent-session-wake",
        error: { name: "UnknownError", message: "late provider failure" },
      },
    })
    await waitForParentWakeErrorSettle()

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(getDispatchedParentWakes(manager).has("parent-session-wake")).toBe(false)
    expect(getPendingParentWakes(manager).has("parent-session-wake")).toBe(false)

    manager.shutdown()
  })

  test("does not requeue dispatched parent wake when session history already contains assistant output after the wake", async () => {
    //#given
    const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
    const client = {
      session: {
        status: async () => ({ data: { "parent-session-wake": { type: "idle" } } }),
        messages: async () => [
          {
            info: {
              role: "assistant",
              finish: "end_turn",
              time: { created: 2_000 },
            },
            parts: [{ type: "text", text: "wake was already accepted" }],
          },
        ],
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCalls.push(args)
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const managerInternals = cast<{
      queuePendingParentWake: (
        sessionID: string,
        notification: string,
        promptContext: Record<string, unknown>,
        shouldReply: boolean,
        delayMs?: number,
      ) => void
      flushPendingParentWake: (sessionID: string) => Promise<void>
    }>(manager)
    managerInternals.queuePendingParentWake(
      "parent-session-wake",
      "<system-reminder>done</system-reminder>",
      { agent: "sisyphus" },
      true,
      0,
    )
    await managerInternals.flushPendingParentWake("parent-session-wake")
    const wake = getDispatchedParentWakes(manager).get("parent-session-wake")
    if (!wake) {
      throw new Error("Missing dispatched parent wake")
    }
    wake.dispatchedAt = 1_000

    //#when
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: "parent-session-wake",
        error: { name: "UnknownError", message: "late provider failure" },
      },
    })
    await flushBackgroundNotifications()
    await waitForParentWakeErrorSettle()

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(getDispatchedParentWakes(manager).has("parent-session-wake")).toBe(false)
    expect(getPendingParentWakes(manager).has("parent-session-wake")).toBe(false)

    manager.shutdown()
  })

  test("terminates task on session.error when session is gone", async () => {
    //#given
    const manager = createBackgroundManager()
    mockVerifySessionExists(manager, false)

    const task = createMockTask({
      id: "task-session-error-gone",
      sessionId: "ses-gone",
      parentSessionId: "parent-session",
      parentMessageId: "msg-gone",
      description: "task with fatal session.error",
      agent: "explore",
      status: "running",
    })
    getTaskMap(manager).set(task.id, task)

    //#when
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: task.sessionId,
        error: {
          name: "UnknownError",
          message: "Out of memory",
        },
      },
    })

    await flushBackgroundNotifications()

    //#then
    expect(task.status).toBe("error")
    expect(task.error).toBe("Out of memory")

    manager.shutdown()
  })

  test("completes task on session.idle after transient session.error", async () => {
    //#given
    const sessionID = "ses-alive-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({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)
    mockVerifySessionExists(manager, true)

    const task = createMockTask({
      id: "task-session-error-recovers",
      sessionId: sessionID,
      parentSessionId: "parent-session",
      parentMessageId: "msg-recovers",
      description: "task that recovers after transient error",
      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.error",
      properties: {
        sessionID,
        error: {
          name: "UnknownError",
          message: "Out of memory",
        },
      },
    })
    await flushBackgroundNotifications()
    manager.handleEvent({ type: "session.idle", properties: { sessionID } })
    await new Promise((resolve) => setTimeout(resolve, 10))

    //#then
    expect(task.status).toBe("completed")
    expect(task.error).toBeUndefined()

    manager.shutdown()
  })

  test("completes task when session.idle carries session id in info", async () => {
    //#given
    const sessionID = "ses-info-idle-completes-task"
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
        messages: async () => ({
          data: [
            {
              info: { role: "assistant" },
              parts: [{ type: "text", text: "done" }],
            },
          ],
        }),
        todo: async () => ({ data: [] }),
      },
    }

    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)

    const task = createMockTask({
      id: "task-info-idle-completes",
      sessionId: sessionID,
      parentSessionId: "parent-session",
      parentMessageId: "msg-info-idle",
      description: "task completed by nested idle event",
      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: { info: { id: sessionID } },
    })
    await new Promise((resolve) => setTimeout(resolve, 10))

    //#then
    expect(task.status).toBe("completed")

    manager.shutdown()
  })

  test("completes task on session.status idle after todo-continuation finishes", async () => {
    //#given
    const sessionID = "ses-status-idle-after-todo-continuation"
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
        messages: async () => ({
          data: [
            {
              info: { role: "assistant" },
              parts: [{ type: "text", text: "final verified result" }],
            },
          ],
        }),
        todo: async () => ({ data: [] }),
      },
    }

    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)
    mockVerifySessionExists(manager, true)

    const task = createMockTask({
      id: "task-status-idle-after-todo-continuation",
      sessionId: sessionID,
      parentSessionId: "parent-session",
      parentMessageId: "msg-status-idle",
      description: "task that finished after todo-continuation",
      agent: "explore",
      status: "running",
      startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
    })
    getTaskMap(manager).set(task.id, task)

    manager.handleEvent({
      type: "todo.updated",
      properties: {
        sessionID,
        todos: [{ id: "todo-1", content: "compile result", status: "completed", priority: "high" }],
      },
    })

    //#when
    manager.handleEvent({
      type: "session.status",
      properties: {
        sessionID,
        status: { type: "idle" },
      },
    })
    await flushBackgroundNotifications()

    //#then
    expect(task.status).toBe("completed")
    expect(task.completedAt).toBeDefined()

    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.7-thinking"
    await concurrencyManager.acquire(concurrencyKey)

    stubProcessKey(manager)

    const sessionID = "ses_error_retry"
    const task = createRetryTask(manager, {
      id: "task-session-error-retry",
      sessionId: sessionID,
      description: "task that should retry",
      concurrencyKey,
      fallbackChain: [
        { providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
        { providers: ["anthropic"], model: "claude-opus-4-5", variant: "max" },
      ],
    })

    //#when
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionID: sessionID,
        error: {
          name: "UnknownError",
          data: {
            message:
              "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.7-thinking\"}}",
          },
        },
      },
    })

    //#then
    expect(task.status).toBe("pending")
    expect(task.attemptCount).toBe(1)
    expect(task.model).toEqual({
      providerID: "anthropic",
      modelID: "claude-opus-4.7",
      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: sessionID,
      description: "task that should retry on status",
    })

    //#when
    manager.handleEvent({
      type: "session.status",
      properties: {
        sessionID: 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.7",
      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: sessionID,
      description: "task that should retry on message.updated",
    })

    //#when
    const messageInfo = {
      id: "msg_errored",
      sessionID: sessionID,
      role: "assistant",
      error: {
        name: "UnknownError",
        data: {
          message:
              "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.7-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.7",
      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(
      { pluginContext: createPluginInput(client), config: { 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
    ;(cast<{ startTask: (item: unknown) => Promise<void> }>(manager)).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({ pluginContext: createPluginInput(client) })
    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()
    await waitForCoalescedFlush()

    //#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 FINISHED")
    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({ pluginContext: createPluginInput(client) })
    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)
    ;(cast<{ pendingByParent: Map<string, Set<string>> }>(manager)).pendingByParent.set(
      "parent-session",
      new Set([taskA.id, taskB.id])
    )

    // when
    await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .notifyParentSession(taskA)

    // then
    const completionTimers = getCompletionTimers(manager)
    expect(completionTimers.size).toBe(1)

    // when
    await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
      .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({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)

    const task: BackgroundTask = {
      id: "task-early-idle",
      sessionId: 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({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)

    const task: BackgroundTask = {
      id: "task-late-idle",
      sessionId: 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({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)

    const remainingMs = 120
    const task: BackgroundTask = {
      id: "task-deferred-noop",
      sessionId: 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({ pluginContext: createPluginInput(client) })

    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 when legacy message.part.updated only has part session id", () => {
    //#given - a running task with stale lastUpdate
    const client = {
      session: {
        prompt: async () => ({}),
        promptAsync: async () => ({}),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })

    const oldUpdate = new Date(Date.now() - 300_000)
    const task: BackgroundTask = {
      id: "task-part-only-1",
      sessionId: "session-part-only-1",
      parentSessionId: "parent-1",
      parentMessageId: "msg-1",
      description: "Legacy part-only task",
      prompt: "Keep working",
      agent: "oracle",
      status: "running",
      startedAt: new Date(Date.now() - 600_000),
      progress: {
        toolCalls: 0,
        lastUpdate: oldUpdate,
      },
    }
    getTaskMap(manager).set(task.id, task)

    //#when - a legacy message.part.updated event arrives without top-level sessionID
    manager.handleEvent({
      type: "message.part.updated",
      properties: {
        part: {
          id: "part-1",
          messageID: "msg-1",
          sessionID: "session-part-only-1",
          type: "text",
          text: "still working",
        },
      },
    })

    //#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 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({ pluginContext: createPluginInput(client) })

    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({ pluginContext: createPluginInput(client) })

    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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    //#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({ pluginContext: createPluginInput(client), config: { 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"](undefined)

    //#then - task should still be running (delta event refreshed lastUpdate)
    expect(task.status).toBe("running")
  })

  test("should complete idle task without fetching messages after output event was observed", async () => {
    //#given - a running task with observed output from message part events
    let messagesCallCount = 0
    let todoCallCount = 0
    const sessionID = "session-output-cached-idle"
    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 () => {
          todoCallCount += 1
          return { data: [] }
        },
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)

    const task: BackgroundTask = {
      id: "task-output-cached-idle",
      sessionId: sessionID,
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "idle cached output task",
      prompt: "test",
      agent: "explore",
      status: "running",
      startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
    }
    getTaskMap(manager).set(task.id, task)

    manager.handleEvent({
      type: "message.part.updated",
      properties: { sessionID: sessionID, type: "text" },
    })

    //#when - session.idle fires after output event was already observed
    manager.handleEvent({ type: "session.idle", properties: { sessionID } })

    //#then - task completes without refetching session.messages
    await new Promise((resolve) => setTimeout(resolve, 10))
    expect(task.status).toBe("completed")
    expect(messagesCallCount).toBe(0)
    expect(todoCallCount).toBe(1)

    manager.shutdown()
  })
})

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({ pluginContext: createPluginInput(client) })

    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({ pluginContext: createPluginInput(client) })
    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 (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager)).notifyParentSession(task)

    //#then
    expect(getCompletionTimers(manager).has(task.id)).toBe(true)

    manager.shutdown()
  })

  test("should keep completed task retrievable after scheduled removal", () => {
    //#given
    const manager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-archive-regression",
      sessionId: "session-archive-regression",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "archive regression",
      prompt: "test",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
    }
    getTaskMap(manager).set(task.id, task)

    //#when
    ;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task)

    //#then
    expect(getTaskMap(manager).has(task.id)).toBe(false)
    const archivedTask = manager.getTask(task.id)
    expect(archivedTask?.sessionId).toBe(task.sessionId)
    expect(archivedTask?.prompt).toBe("[redacted]")
    expect(archivedTask?.startedAt).toEqual(task.startedAt)

    manager.shutdown()
  })

  test("should resolve a completed task registered by an earlier plugin manager instance", () => {
    //#given
    const firstManager = createBackgroundManager()
    const secondManager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-cross-manager-regression",
      sessionId: "session-cross-manager-regression",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "cross manager regression",
      prompt: "test",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
    }

    //#when
    ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)

    //#then
    const resolvedTask = secondManager.getTask(task.id)
    expect(resolvedTask?.sessionId).toBe(task.sessionId)

    firstManager.shutdown()
    secondManager.shutdown()
  })

  test("should redact active task prompts resolved from an earlier plugin manager instance", () => {
    //#given
    const firstManager = createBackgroundManager()
    const secondManager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-cross-manager-active-redaction",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "cross manager active redaction",
      prompt: "secret prompt",
      agent: "explore",
      status: "pending",
      queuedAt: new Date(),
    }

    //#when
    ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
    task.sessionId = "session-cross-manager-active-redaction"
    task.status = "running"
    task.startedAt = new Date()
    task.progress = {
      lastUpdate: new Date(),
      toolCalls: 1,
      countedToolPartIDs: new Set(["part-1"]),
    }

    //#then
    const localTask = firstManager.getTask(task.id)
    const registeredTask = secondManager.getTask(task.id)
    expect(localTask?.prompt).toBe("secret prompt")
    expect(registeredTask?.sessionId).toBe(task.sessionId)
    expect(registeredTask?.prompt).toBe("[redacted]")
    expect(registeredTask?.progress?.countedToolPartIDs).toEqual(new Set(["part-1"]))

    firstManager.shutdown()
    secondManager.shutdown()
  })

  test("should resolve archived completed task from an earlier plugin manager instance", () => {
    //#given
    const firstManager = createBackgroundManager()
    const secondManager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-cross-manager-archive-regression",
      sessionId: "session-cross-manager-archive-regression",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "cross manager archive regression",
      prompt: "sensitive prompt",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
    }
    getTaskMap(firstManager).set(task.id, task)

    //#when
    ;(cast<{ removeTask: (task: BackgroundTask) => void }>(firstManager)).removeTask(task)

    //#then
    const resolvedTask = secondManager.getTask(task.id)
    expect(resolvedTask?.sessionId).toBe(task.sessionId)
    expect(resolvedTask?.prompt).toBe("[redacted]")

    firstManager.shutdown()
    secondManager.shutdown()
  })

  test("should archive terminal registry tasks during earlier manager shutdown", async () => {
    //#given
    const firstManager = createBackgroundManager()
    const secondManager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-shutdown-archive-regression",
      sessionId: "session-shutdown-archive-regression",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "shutdown archive regression",
      prompt: "sensitive shutdown prompt",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
    }
    ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)

    //#when
    await firstManager.shutdown()

    //#then
    const resolvedTask = secondManager.getTask(task.id)
    expect(resolvedTask?.sessionId).toBe(task.sessionId)
    expect(resolvedTask?.prompt).toBe("[redacted]")

    await secondManager.shutdown()
  })

  test("should forget active registry tasks during earlier manager shutdown", async () => {
    //#given
    const firstManager = createBackgroundManager()
    const secondManager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-shutdown-active-regression",
      sessionId: "session-shutdown-active-regression",
      parentSessionId: "parent-session",
      parentMessageId: "msg-1",
      description: "shutdown active regression",
      prompt: "test",
      agent: "explore",
      status: "running",
      startedAt: new Date(),
    }
    ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)

    //#when
    await firstManager.shutdown()

    //#then
    expect(secondManager.getTask(task.id)).toBeUndefined()

    await secondManager.shutdown()
  })

  test("should cap completed task archive size at 100 entries", () => {
    //#given
    const manager = createBackgroundManager()

    //#when
    for (let index = 0; index < 120; index += 1) {
      const task: BackgroundTask = {
        id: `task-archive-${index}`,
        sessionId: `session-archive-${index}`,
        parentSessionId: "parent-session",
        parentMessageId: "msg-1",
        description: "archive cap regression",
        prompt: `sensitive-${index}`,
        agent: "explore",
        status: "completed",
        startedAt: new Date(),
        completedAt: new Date(),
      }
      ;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task)
    }

    //#then
    const archive = cast<Map<string, unknown>>(Reflect.get(manager, "completedTaskArchive"))
    expect(archive.size).toBe(100)
    expect(archive.has("task-archive-0")).toBe(false)
    expect(archive.has("task-archive-19")).toBe(false)
    expect(archive.has("task-archive-20")).toBe(true)
    expect(archive.has("task-archive-119")).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({ pluginContext: createPluginInput(client) })
    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 (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> }>(manager))
      .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("startTask keeps agent when explicit model is configured", async () => {
    //#given
    const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
    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> }) => {
          promptCalls.push(args)
          return {}
        },
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-explicit-model",
      status: "pending",
      queuedAt: new Date(),
      description: "test task",
      prompt: "test prompt",
      agent: "sisyphus-junior",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
    }
    const input: import("./types").LaunchInput = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: task.model,
    }

    //#when
    await (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> }>(manager))
      .startTask({ task, input })

    //#then
    expect(promptCalls).toHaveLength(1)
    expect(promptCalls[0].body.agent).toBe("sisyphus-junior")
    expect(promptCalls[0].body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
    expect(promptCalls[0].body.variant).toBe("medium")

    manager.shutdown()
  })

  test("startTask updates tracked session agent when launch falls back to general", async () => {
    //#given
    const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
    let promptCallCount = 0
    const client = {
      session: {
        get: async () => ({ data: { directory: "/test/dir" } }),
        create: async () => ({ data: { id: "session-manager-fallback" } }),
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCallCount++
          promptCalls.push(args)
          if (promptCallCount === 1) {
            throw new Error("Agent not found: missing-agent")
          }
          return {}
        },
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-manager-fallback",
      status: "pending",
      queuedAt: new Date(),
      description: "test task",
      prompt: "test prompt",
      agent: "missing-agent",
      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,
    }

    try {
      //#when
      await (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> }>(manager))
        .startTask({ task, input })
      await new Promise((resolve) => setTimeout(resolve, 50))

      //#then
      expect(promptCalls).toHaveLength(2)
      expect(promptCalls[0].body.agent).toBe("missing-agent")
      expect(promptCalls[1].body.agent).toBe("general")
      expect(task.agent).toBe("general")
      expect(getSessionAgent("session-manager-fallback")).toBe("general")
      expect(getDelegatedChildSessionBootstrap("session-manager-fallback")?.tools?.call_omo_agent).toBe(true)
    } finally {
      manager.shutdown()
      clearAllDelegatedChildSessionBootstrap()
    }
  })

  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({ pluginContext: createPluginInput(client) })
    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()
  })

  test("resume keeps agent when explicit model is configured", async () => {
    //#given
    let promptCall: { path: { id: string }; body: Record<string, unknown> } | undefined
    const client = {
      session: {
        promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
          promptCall = args
          return {}
        },
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-explicit-model-resume",
      sessionId: "session-3",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      description: "resume task",
      prompt: "resume prompt",
      agent: "explore",
      status: "completed",
      startedAt: new Date(),
      completedAt: new Date(),
      model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
    }
    getTaskMap(manager).set(task.id, task)

    //#when
    await manager.resume({
      sessionId: "session-3",
      prompt: "continue",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
    })

    //#then
    expect(promptCall).toBeDefined()
    expect(promptCall?.body.agent).toBe("explore")
    expect(promptCall?.body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })

    manager.shutdown()
  })
})

describe("BackgroundManager.launch - attempt state initialization", () => {
  test("newly launched task has attempt state with attemptNumber 1 and currentAttemptID pointing at it", async () => {
    //#given
    const manager = createBackgroundManager()
    ;(cast<{
      reserveSubagentSpawn: () => Promise<{
        spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
        descendantCount: number
        commit: () => number
        rollback: () => void
      }>
    }>(manager)).reserveSubagentSpawn = async () => ({
      spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
      descendantCount: 1,
      commit: () => 1,
      rollback: () => {},
    })

    //#when
    const task = await manager.launch({
      description: "attempt state test",
      prompt: "do something",
      agent: "explore",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
    })

    //#then
    const stored = getTaskMap(manager).get(task.id)

    expect(stored?.attempts).toBeDefined()
    expect(stored?.attempts).toHaveLength(1)

    const firstAttempt = stored?.attempts?.[0]
    expect(firstAttempt?.attemptNumber).toBe(1)
    expect(firstAttempt?.status).toBe("pending")
    expect(firstAttempt?.providerId).toBe("anthropic")
    expect(firstAttempt?.modelId).toBe("claude-haiku-4.5")

    expect(stored?.currentAttemptID).toBeDefined()
    expect(stored?.currentAttemptID).toBe(firstAttempt?.attemptId)

    expect(stored?.status).toBeDefined()
    expect(stored?.model).toBeDefined()
    expect(stored?.parentSessionId).toBe("parent-session")

    manager.shutdown()
  })
})

describe("BackgroundManager attempt lifecycle bindings", () => {
  test("startTask binds the created session to the queued attempt ID and mirrors task projection", async () => {
    //#given
    resetClaudeCodeSessionState()
    const client = {
      session: {
        get: async () => ({ data: { directory: "/test/dir" } }),
        create: async () => ({ data: { id: "session-attempt-2" } }),
        promptAsync: async () => ({}),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-attempt-binding",
      status: "pending",
      queuedAt: new Date(),
      description: "retry binding task",
      prompt: "continue",
      agent: "sisyphus-junior",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "anthropic", modelID: "claude-haiku-4.5", variant: "max" },
      attempts: [
        {
          attemptId: "attempt-1",
          attemptNumber: 1,
          sessionId: "session-attempt-1",
          providerId: "openai",
          modelId: "gpt-5.4-mini",
          status: "error",
          error: "first attempt failed",
          startedAt: new Date("2026-04-27T00:00:00.000Z"),
          completedAt: new Date("2026-04-27T00:00:05.000Z"),
        },
        {
          attemptId: "attempt-2",
          attemptNumber: 2,
          providerId: "anthropic",
          modelId: "claude-haiku-4.5",
          variant: "max",
          status: "pending",
        },
      ],
      currentAttemptID: "attempt-2",
      attemptCount: 1,
    }
    const input: import("./types").LaunchInput = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: task.model,
    }

    //#when
    await (cast<{
      startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise<void>
    }>(manager)).startTask({ task, input, attemptID: "attempt-2" })

    //#then
    const activeAttempt = task.attempts?.find((attempt) => attempt.attemptId === "attempt-2")
    expect(activeAttempt).toBeDefined()
    expect(activeAttempt?.sessionId).toBe("session-attempt-2")
    expect(activeAttempt?.status).toBe("running")
    expect(activeAttempt?.startedAt).toBeInstanceOf(Date)
    expect(task.currentAttemptID).toBe("attempt-2")
    expect(task.sessionId).toBe("session-attempt-2")
    expect(task.status).toBe("running")
    expect(task.attempts?.[0]).toMatchObject({
      attemptId: "attempt-1",
      sessionId: "session-attempt-1",
      status: "error",
      error: "first attempt failed",
    })
    expect(getSessionAgent("session-attempt-2")).toBe("sisyphus-junior")

    manager.shutdown()
  })

  test("startTask clears child session agent state when task is cancelled before launch binding", async () => {
    //#given
    resetClaudeCodeSessionState()
    const sessionID = "session-cancelled-prelaunch"
    const client = {
      session: {
        get: async () => ({ data: { directory: "/test/dir" } }),
        create: async () => ({ data: { id: sessionID } }),
        promptAsync: async () => ({}),
        abort: async () => ({}),
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    const task: BackgroundTask = {
      id: "task-cancel-prelaunch",
      status: "pending",
      queuedAt: new Date(),
      description: "cancel before bind",
      prompt: "continue",
      agent: "sisyphus-junior",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
      attempts: [
        {
          attemptId: "attempt-1",
          attemptNumber: 1,
          providerId: "anthropic",
          modelId: "claude-haiku-4.5",
          status: "pending",
        },
      ],
      currentAttemptID: "attempt-1",
      attemptCount: 1,
    }
    const input: import("./types").LaunchInput = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: task.model,
      onSessionCreated: async () => {
        // simulate parent flipping task to cancelled between create and bind
        task.status = "cancelled"
        const internal = cast<{ tasks: Map<string, BackgroundTask> }>(manager)
        internal.tasks.set(task.id, task)
      },
    }

    //#when
    await (cast<{
      startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise<void>
    }>(manager)).startTask({ task, input, attemptID: "attempt-1" })

    //#then
    expect(getSessionAgent(sessionID)).toBeUndefined()

    manager.shutdown()
  })

  test("historical attempt session IDs resolve to the task while stale session.error events leave the current attempt unchanged", async () => {
    //#given
    const manager = createBackgroundManager()
    const task: BackgroundTask = {
      id: "task-stale-session-event",
      status: "running",
      queuedAt: new Date("2026-04-27T00:00:00.000Z"),
      startedAt: new Date("2026-04-27T00:00:10.000Z"),
      sessionId: "session-attempt-2",
      description: "ignore stale retry events",
      prompt: "continue",
      agent: "explore",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
      attempts: [
        {
          attemptId: "attempt-1",
          attemptNumber: 1,
          sessionId: "session-attempt-1",
          providerId: "openai",
          modelId: "gpt-5.4-mini",
          status: "error",
          error: "first attempt failed",
          startedAt: new Date("2026-04-27T00:00:00.000Z"),
          completedAt: new Date("2026-04-27T00:00:05.000Z"),
        },
        {
          attemptId: "attempt-2",
          attemptNumber: 2,
          sessionId: "session-attempt-2",
          providerId: "anthropic",
          modelId: "claude-haiku-4.5",
          status: "running",
          startedAt: new Date("2026-04-27T00:00:10.000Z"),
        },
      ],
      currentAttemptID: "attempt-2",
    }
    getTaskMap(manager).set(task.id, task)

    //#when
    const resolvedTask = manager.findBySession("session-attempt-1")
    manager.handleEvent({
      type: "session.error",
      properties: {
        sessionId: "session-attempt-1",
        error: { name: "UnknownError", message: "late event from old session" },
      },
    })
    await flushBackgroundNotifications()

    //#then
    expect(resolvedTask?.id).toBe(task.id)
    expect(task.currentAttemptID).toBe("attempt-2")
    expect(task.sessionId).toBe("session-attempt-2")
    expect(task.status).toBe("running")
    expect(task.error).toBeUndefined()
    expect(task.attempts?.[0]).toMatchObject({
      attemptId: "attempt-1",
      status: "error",
      error: "first attempt failed",
    })
    expect(task.attempts?.[1]).toMatchObject({
      attemptId: "attempt-2",
      sessionId: "session-attempt-2",
      status: "running",
    })

    manager.shutdown()
  })

  test("late launch prompt errors from a historical attempt do not interrupt the current retry attempt", async () => {
    //#given
    let rejectPrompt: ((error: unknown) => void) | undefined
    const abortCalls: string[] = []
    const client = {
      session: {
        get: async () => ({ data: { directory: "/test/dir" } }),
        create: async () => ({ data: { id: "session-attempt-1" } }),
        promptAsync: async () => new Promise((_, reject) => {
          rejectPrompt = reject
        }),
        abort: async ({ path }: { path: { id: string } }) => {
          abortCalls.push(path.id)
          return {}
        },
      },
    }
    const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
    stubNotifyParentSession(manager)
    ;(cast<{
      tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
    }>(manager)).tryFallbackRetry = async () => false
    const task: BackgroundTask = {
      id: "task-stale-prompt-error",
      status: "pending",
      queuedAt: new Date("2026-04-27T00:00:00.000Z"),
      description: "ignore stale prompt errors",
      prompt: "continue",
      agent: "sisyphus-junior",
      parentSessionId: "parent-session",
      parentMessageId: "parent-message",
      model: { providerID: "openai", modelID: "gpt-5.4-mini" },
      attempts: [
        {
          attemptId: "attempt-1",
          attemptNumber: 1,
          providerId: "openai",
          modelId: "gpt-5.4-mini",
          status: "pending",
        },
      ],
      currentAttemptID: "attempt-1",
    }
    getTaskMap(manager).set(task.id, task)
    const input: import("./types").LaunchInput = {
      description: task.description,
      prompt: task.prompt,
      agent: task.agent,
      parentSessionId: task.parentSessionId,
      parentMessageId: task.parentMessageId,
      model: task.model,
    }

    await (cast<{
      startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise<void>
    }>(manager)).startTask({ task, input, attemptID: "attempt-1" })

    task.attempts = [
      {
        attemptId: "attempt-1",
        attemptNumber: 1,
        sessionId: "session-attempt-1",
        providerId: "openai",
        modelId: "gpt-5.4-mini",
        status: "error",
        error: "first attempt failed",
        startedAt: new Date("2026-04-27T00:00:00.000Z"),
        completedAt: new Date("2026-04-27T00:00:05.000Z"),
      },
      {
        attemptId: "attempt-2",
        attemptNumber: 2,
        sessionId: "session-attempt-2",
        providerId: "anthropic",
        modelId: "claude-haiku-4.5",
        status: "running",
        startedAt: new Date("2026-04-27T00:00:10.000Z"),
      },
    ]
    task.currentAttemptID = "attempt-2"
    task.sessionId = "session-attempt-2"
    task.status = "running"
    task.error = undefined

    //#when
    rejectPrompt?.({ name: "APIError", data: { message: "Forbidden: Selected provider is forbidden" } })
    await flushBackgroundNotifications()

    //#then
    expect(task.currentAttemptID).toBe("attempt-2")
    expect(task.sessionId).toBe("session-attempt-2")
    expect(task.status).toBe("running")
    expect(task.error).toBeUndefined()
    expect(task.attempts?.[0]).toMatchObject({
      attemptId: "attempt-1",
      status: "error",
      error: "first attempt failed",
    })
    expect(task.attempts?.[1]).toMatchObject({
      attemptId: "attempt-2",
      status: "running",
      sessionId: "session-attempt-2",
    })
    expect(abortCalls).toEqual([])

    manager.shutdown()
  })
})
