diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a4ef802efbc..c9ba68717f1 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -337,6 +337,9 @@ export type ExtensionState = Pick< openAiCodexIsAuthenticated?: boolean debug?: boolean + /** Background tasks status for the UI panel */ + backgroundTasks?: BackgroundTaskStatusInfo[] + /** * Monotonically increasing sequence number for clineMessages state pushes. * When present, the frontend should only apply clineMessages from a state push @@ -346,6 +349,21 @@ export type ExtensionState = Pick< clineMessagesSeq?: number } +/** + * Status of a background task as exposed to the webview UI. + */ +export interface BackgroundTaskStatusInfo { + taskId: string + parentTaskId: string + status: "running" | "completed" | "cancelled" | "timed_out" | "error" + startedAt: number + completedAt?: number + /** Short summary of the result (from attempt_completion) */ + resultSummary?: string + /** The mode slug the background task was running in */ + mode?: string +} + export interface Command { name: string source: "global" | "project" | "built-in" @@ -514,6 +532,8 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "browseForWorktreePath" + // Background task messages + | "cancelBackgroundTask" // Skills messages | "requestSkills" | "createSkill" diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f8e29e549d9..24c7bf0169b 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos` +const BACKGROUND_PARAMETER_DESCRIPTION = `When set to "true", the task runs in the background concurrently with the current task. Background tasks are restricted to read-only tools only (read_file, list_files, search_files, codebase_search). Results are delivered asynchronously when the background task completes. Use for research, analysis, or documentation lookup while continuing other work.` + export default { type: "function", function: { @@ -31,8 +33,12 @@ export default { type: ["string", "null"], description: TODOS_PARAMETER_DESCRIPTION, }, + background: { + type: ["string", "null"], + description: BACKGROUND_PARAMETER_DESCRIPTION, + }, }, - required: ["mode", "message", "todos"], + required: ["mode", "message", "todos", "background"], additionalProperties: false, }, }, diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts new file mode 100644 index 00000000000..638845198d3 --- /dev/null +++ b/src/core/task/BackgroundTaskRunner.ts @@ -0,0 +1,368 @@ +/** + * BackgroundTaskRunner manages read-only background tasks that run concurrently + * alongside the user's active foreground task. Background tasks: + * - Are completely webview-silent (no UI updates) + * - Auto-approve all tool uses (no user interaction) + * - Are restricted to read-only tools only + * - Have a configurable timeout to prevent runaway execution + * - Are not added to the clineStack + * + * This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency. + * + * Phase 6+ extension points: + * - To support write-capable background tasks, extend BACKGROUND_TASK_ALLOWED_TOOLS + * and add a file-locking mechanism to prevent conflicts with foreground edits. + * - For real-time progress streaming, add an `onProgressUpdate` callback to + * BackgroundTaskInfo and emit partial tool-call summaries from Task. + * - For persistent history across sessions, serialize completedTasks to global + * state via the TaskHistoryStore and restore on provider initialization. + * - For tab-based switching, expose the background task's clineMessages via + * getTasksStatus() so the webview can render a full conversation view. + */ + +import { BackgroundTaskStatusInfo } from "@roo-code/types" + +import { Task, TaskOptions } from "./Task" + +/** Read-only tools that background tasks are allowed to use. */ +export const BACKGROUND_TASK_ALLOWED_TOOLS = [ + "read_file", + "list_files", + "search_files", + "codebase_search", + "ask_followup_question", + "attempt_completion", +] as const + +/** Default maximum number of concurrent background tasks. */ +export const DEFAULT_MAX_BACKGROUND_TASKS = 3 + +/** Default timeout for background tasks in milliseconds (5 minutes). */ +export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MS = 5 * 60 * 1000 + +export interface BackgroundTaskInfo { + task: Task + parentTaskId: string + startedAt: number + timeoutHandle: ReturnType +} + +/** + * Optional callbacks that allow the owner (e.g. ClineProvider) to react to + * background task lifecycle events such as completion, timeout, or errors. + */ +export interface BackgroundTaskRunnerCallbacks { + /** Called when a background task times out. */ + onTaskTimeout?: (taskId: string, parentTaskId: string) => void + /** Called when aborting a background task throws an error. */ + onTaskError?: (taskId: string, parentTaskId: string, error: Error) => void +} + +/** Maximum number of recently completed tasks to keep for UI display. */ +const MAX_COMPLETED_TASKS = 10 + +export interface CompletedBackgroundTaskInfo { + taskId: string + parentTaskId: string + status: "completed" | "cancelled" | "timed_out" | "error" + startedAt: number + completedAt: number + resultSummary?: string + mode?: string +} + +export class BackgroundTaskRunner { + private backgroundTasks: Map = new Map() + private completedTasks: CompletedBackgroundTaskInfo[] = [] + private maxConcurrentTasks: number + private taskTimeoutMs: number + private callbacks: BackgroundTaskRunnerCallbacks + /** Called whenever the set of active/completed tasks changes, so the UI can be refreshed. */ + public onStateChanged?: () => void + + constructor( + maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS, + taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS, + callbacks: BackgroundTaskRunnerCallbacks = {}, + ) { + this.maxConcurrentTasks = maxConcurrentTasks + this.taskTimeoutMs = taskTimeoutMs + this.callbacks = callbacks + } + + /** + * Returns the number of currently running background tasks. + */ + get activeCount(): number { + return this.backgroundTasks.size + } + + /** + * Returns whether the runner can accept more background tasks. + */ + get canAcceptTask(): boolean { + return this.backgroundTasks.size < this.maxConcurrentTasks + } + + /** + * Register a background task after it has been created. + * The task should already have isBackgroundTask=true and be started. + */ + registerTask(task: Task, parentTaskId: string): void { + if (this.backgroundTasks.has(task.taskId)) { + console.warn(`[BackgroundTaskRunner] Task ${task.taskId} already registered`) + return + } + + if (!this.canAcceptTask) { + throw new Error( + `[BackgroundTaskRunner] Cannot accept more background tasks. ` + + `Current: ${this.backgroundTasks.size}, Max: ${this.maxConcurrentTasks}`, + ) + } + + const timeoutHandle = setTimeout(() => { + this.timeoutTask(task.taskId) + }, this.taskTimeoutMs) + + this.backgroundTasks.set(task.taskId, { + task, + parentTaskId, + startedAt: Date.now(), + timeoutHandle, + }) + + console.log( + `[BackgroundTaskRunner] Registered background task ${task.taskId} ` + + `(parent: ${parentTaskId}, active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, + ) + + this.notifyStateChanged() + } + + /** + * Called when a background task completes. Cleans up tracking state. + */ + onTaskCompleted(taskId: string, resultSummary?: string): BackgroundTaskInfo | undefined { + const info = this.backgroundTasks.get(taskId) + + if (!info) { + return undefined + } + + clearTimeout(info.timeoutHandle) + this.backgroundTasks.delete(taskId) + + this.addCompletedTask({ + taskId, + parentTaskId: info.parentTaskId, + status: "completed", + startedAt: info.startedAt, + completedAt: Date.now(), + resultSummary, + }) + + console.log( + `[BackgroundTaskRunner] Background task ${taskId} completed ` + + `(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, + ) + + this.notifyStateChanged() + + return info + } + + /** + * Get info about a specific background task. + */ + getTaskInfo(taskId: string): BackgroundTaskInfo | undefined { + return this.backgroundTasks.get(taskId) + } + + /** + * Check if a task is a registered background task. + */ + isBackgroundTask(taskId: string): boolean { + return this.backgroundTasks.has(taskId) + } + + /** + * Cancel all background tasks spawned by a specific parent task. + */ + async cancelTasksByParent(parentTaskId: string): Promise { + const tasksToCancel: BackgroundTaskInfo[] = [] + + for (const [, info] of this.backgroundTasks) { + if (info.parentTaskId === parentTaskId) { + tasksToCancel.push(info) + } + } + + for (const info of tasksToCancel) { + await this.cancelTask(info.task.taskId) + } + } + + /** + * Cancel a specific background task. + */ + async cancelTask(taskId: string): Promise { + const info = this.backgroundTasks.get(taskId) + + if (!info) { + return + } + + clearTimeout(info.timeoutHandle) + + let status: CompletedBackgroundTaskInfo["status"] = "cancelled" + + try { + await info.task.abortTask(true) + } catch (error) { + status = "error" + const err = error instanceof Error ? error : new Error(String(error)) + console.error(`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${err.message}`) + try { + this.callbacks.onTaskError?.(taskId, info.parentTaskId, err) + } catch { + // Callback errors must not break cleanup. + } + } + + this.backgroundTasks.delete(taskId) + + this.addCompletedTask({ + taskId, + parentTaskId: info.parentTaskId, + status, + startedAt: info.startedAt, + completedAt: Date.now(), + }) + + console.log( + `[BackgroundTaskRunner] Cancelled background task ${taskId} ` + + `(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, + ) + + this.notifyStateChanged() + } + + /** + * Cancel all background tasks. Called during provider disposal. + */ + async dispose(): Promise { + const taskIds = Array.from(this.backgroundTasks.keys()) + + for (const taskId of taskIds) { + await this.cancelTask(taskId) + } + } + + /** + * Returns the combined status of all active and recently completed background tasks + * for display in the webview UI. + */ + getTasksStatus(): BackgroundTaskStatusInfo[] { + const activeTasks: BackgroundTaskStatusInfo[] = [] + + for (const [taskId, info] of this.backgroundTasks) { + activeTasks.push({ + taskId, + parentTaskId: info.parentTaskId, + status: "running", + startedAt: info.startedAt, + }) + } + + const completedStatuses: BackgroundTaskStatusInfo[] = this.completedTasks.map((ct) => ({ + taskId: ct.taskId, + parentTaskId: ct.parentTaskId, + status: ct.status, + startedAt: ct.startedAt, + completedAt: ct.completedAt, + resultSummary: ct.resultSummary, + mode: ct.mode, + })) + + return [...activeTasks, ...completedStatuses] + } + + /** + * Returns the list of recently completed tasks (for testing and direct access). + */ + getCompletedTasks(): readonly CompletedBackgroundTaskInfo[] { + return this.completedTasks + } + + /** + * Clears completed tasks from the buffer. + */ + clearCompletedTasks(): void { + this.completedTasks = [] + this.notifyStateChanged() + } + + /** + * Add a completed task to the buffer, evicting the oldest if at capacity. + */ + private addCompletedTask(info: CompletedBackgroundTaskInfo): void { + this.completedTasks.push(info) + + if (this.completedTasks.length > MAX_COMPLETED_TASKS) { + this.completedTasks = this.completedTasks.slice(-MAX_COMPLETED_TASKS) + } + } + + /** + * Notify the owner that background task state has changed. + */ + private notifyStateChanged(): void { + try { + this.onStateChanged?.() + } catch { + // Callback errors must not break internal logic. + } + } + + /** + * Handle timeout of a background task. + */ + private async timeoutTask(taskId: string): Promise { + const info = this.backgroundTasks.get(taskId) + const parentTaskId = info?.parentTaskId ?? "unknown" + const startedAt = info?.startedAt ?? Date.now() + + console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`) + + try { + this.callbacks.onTaskTimeout?.(taskId, parentTaskId) + } catch { + // Callback errors must not break cleanup. + } + + // Record as timed_out before cancelling (cancelTask will record as cancelled otherwise) + clearTimeout(info?.timeoutHandle) + if (info) { + try { + await info.task.abortTask(true) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + console.error(`[BackgroundTaskRunner] Error aborting timed-out task ${taskId}: ${err.message}`) + } + this.backgroundTasks.delete(taskId) + + this.addCompletedTask({ + taskId, + parentTaskId, + status: "timed_out", + startedAt, + completedAt: Date.now(), + }) + + this.notifyStateChanged() + } else { + await this.cancelTask(taskId) + } + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 97f07fcc7aa..b82588df377 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -153,6 +153,10 @@ export interface TaskOptions extends CreateTaskOptions { workspacePath?: string /** Initial status for the task's history item (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" + /** When true, the task runs in the background: webview updates are suppressed and all tool uses are auto-approved. */ + isBackgroundTask?: boolean + /** Callback invoked when a background task completes (via attempt_completion). */ + onBackgroundComplete?: (taskId: string, result: string) => void } export class Task extends EventEmitter implements TaskLike { @@ -165,6 +169,11 @@ export class Task extends EventEmitter implements TaskLike { readonly instanceId: string readonly metadata: TaskMetadata + /** When true, this task runs in the background with webview silencing and auto-approval. */ + readonly isBackgroundTask: boolean + /** Callback for background task completion result delivery. */ + readonly onBackgroundComplete?: (taskId: string, result: string) => void + todoList?: TodoItem[] readonly rootTask: Task | undefined = undefined @@ -430,6 +439,8 @@ export class Task extends EventEmitter implements TaskLike { initialTodos, workspacePath, initialStatus, + isBackgroundTask = false, + onBackgroundComplete, }: TaskOptions) { super() @@ -491,6 +502,8 @@ export class Task extends EventEmitter implements TaskLike { this.parentTask = parentTask this.taskNumber = taskNumber this.initialStatus = initialStatus + this.isBackgroundTask = isBackgroundTask + this.onBackgroundComplete = onBackgroundComplete this.assistantMessageParser = undefined @@ -1143,10 +1156,14 @@ export class Task extends EventEmitter implements TaskLike { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) - const provider = this.providerRef.deref() - // Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update. - // taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated. - await provider?.postStateToWebviewWithoutTaskHistory() + + if (!this.isBackgroundTask) { + const provider = this.providerRef.deref() + // Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update. + // taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated. + await provider?.postStateToWebviewWithoutTaskHistory() + } + this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() } @@ -1158,8 +1175,11 @@ export class Task extends EventEmitter implements TaskLike { } private async updateClineMessage(message: ClineMessage) { - const provider = this.providerRef.deref() - await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) + if (!this.isBackgroundTask) { + const provider = this.providerRef.deref() + await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) + } + this.emit(RooCodeEventName.Message, { action: "updated", message }) } @@ -1195,7 +1215,9 @@ export class Task extends EventEmitter implements TaskLike { // - Final state is emitted when updates stop (trailing: true) this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) - await this.providerRef.deref()?.updateTaskHistory(historyItem) + if (!this.isBackgroundTask) { + await this.providerRef.deref()?.updateTaskHistory(historyItem) + } return true } catch (error) { console.error("Failed to save Roo messages:", error) @@ -1315,6 +1337,26 @@ export class Task extends EventEmitter implements TaskLike { let timeouts: NodeJS.Timeout[] = [] + // Background tasks auto-approve all asks immediately (no user interaction). + // Design decision: Full auto-approval is safe here because background tasks + // are restricted to read-only tools only (read_file, list_files, search_files, + // codebase_search). They cannot modify files, execute commands, or perform any + // destructive operations. If a future phase introduces write-capable background + // tasks, this auto-approval should be revisited to allow selective user input + // for dangerous operations. + if (this.isBackgroundTask) { + this.approveAsk() + await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + if (this.lastMessageTs !== askTs) { + throw new AskIgnoredError("superseded") + } + const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + return result + } + // Automatically approve if the ask according to the user's settings. const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined diff --git a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts new file mode 100644 index 00000000000..184a1b9b3f0 --- /dev/null +++ b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts @@ -0,0 +1,369 @@ +import { + BackgroundTaskRunner, + DEFAULT_MAX_BACKGROUND_TASKS, + DEFAULT_BACKGROUND_TASK_TIMEOUT_MS, +} from "../BackgroundTaskRunner" + +// Minimal mock for Task +function createMockTask(taskId: string): any { + return { + taskId, + instanceId: "test-instance", + isBackgroundTask: true, + abortTask: vi.fn().mockResolvedValue(undefined), + } +} + +describe("BackgroundTaskRunner", () => { + let runner: BackgroundTaskRunner + + beforeEach(() => { + vi.useFakeTimers() + runner = new BackgroundTaskRunner() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("constructor", () => { + it("should initialize with default values", () => { + expect(runner.activeCount).toBe(0) + expect(runner.canAcceptTask).toBe(true) + }) + + it("should accept custom concurrency and timeout", () => { + const customRunner = new BackgroundTaskRunner(5, 60000) + expect(customRunner.canAcceptTask).toBe(true) + }) + }) + + describe("registerTask", () => { + it("should register a background task", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + expect(runner.activeCount).toBe(1) + expect(runner.isBackgroundTask("task-1")).toBe(true) + }) + + it("should track parent task ID", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const info = runner.getTaskInfo("task-1") + expect(info).toBeDefined() + expect(info!.parentTaskId).toBe("parent-1") + }) + + it("should not register duplicate tasks", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + runner.registerTask(task, "parent-1") // duplicate + + expect(runner.activeCount).toBe(1) + }) + + it("should throw when concurrency limit is reached", () => { + const customRunner = new BackgroundTaskRunner(2) + + customRunner.registerTask(createMockTask("task-1"), "parent-1") + customRunner.registerTask(createMockTask("task-2"), "parent-1") + + expect(() => { + customRunner.registerTask(createMockTask("task-3"), "parent-1") + }).toThrow("Cannot accept more background tasks") + }) + + it("should report canAcceptTask correctly", () => { + const customRunner = new BackgroundTaskRunner(2) + + expect(customRunner.canAcceptTask).toBe(true) + customRunner.registerTask(createMockTask("task-1"), "parent-1") + expect(customRunner.canAcceptTask).toBe(true) + customRunner.registerTask(createMockTask("task-2"), "parent-1") + expect(customRunner.canAcceptTask).toBe(false) + }) + }) + + describe("onTaskCompleted", () => { + it("should remove completed task and return info", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const info = runner.onTaskCompleted("task-1") + + expect(info).toBeDefined() + expect(info!.parentTaskId).toBe("parent-1") + expect(runner.activeCount).toBe(0) + expect(runner.isBackgroundTask("task-1")).toBe(false) + }) + + it("should return undefined for unknown task", () => { + const info = runner.onTaskCompleted("unknown") + expect(info).toBeUndefined() + }) + + it("should clear the timeout on completion", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + runner.onTaskCompleted("task-1") + + // Advance time past the timeout - should not trigger abort + vi.advanceTimersByTime(DEFAULT_BACKGROUND_TASK_TIMEOUT_MS + 1000) + expect(task.abortTask).not.toHaveBeenCalled() + }) + }) + + describe("cancelTask", () => { + it("should abort and remove a task", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + await runner.cancelTask("task-1") + + expect(task.abortTask).toHaveBeenCalledWith(true) + expect(runner.activeCount).toBe(0) + }) + + it("should handle canceling unknown task gracefully", async () => { + await runner.cancelTask("unknown") // should not throw + }) + + it("should invoke onTaskError callback when abort throws", async () => { + const onTaskError = vi.fn() + const customRunner = new BackgroundTaskRunner(3, undefined, { onTaskError }) + const task = createMockTask("task-1") + task.abortTask.mockRejectedValue(new Error("abort failed")) + customRunner.registerTask(task, "parent-1") + + await customRunner.cancelTask("task-1") + + expect(onTaskError).toHaveBeenCalledWith("task-1", "parent-1", expect.any(Error)) + expect(customRunner.activeCount).toBe(0) + }) + }) + + describe("cancelTasksByParent", () => { + it("should cancel all tasks for a given parent", async () => { + const task1 = createMockTask("task-1") + const task2 = createMockTask("task-2") + const task3 = createMockTask("task-3") + + runner.registerTask(task1, "parent-1") + runner.registerTask(task2, "parent-1") + runner.registerTask(task3, "parent-2") + + await runner.cancelTasksByParent("parent-1") + + expect(task1.abortTask).toHaveBeenCalled() + expect(task2.abortTask).toHaveBeenCalled() + expect(task3.abortTask).not.toHaveBeenCalled() + expect(runner.activeCount).toBe(1) + }) + }) + + describe("timeout", () => { + it("should abort task after timeout", async () => { + const task = createMockTask("task-1") + const customRunner = new BackgroundTaskRunner(3, 5000) + customRunner.registerTask(task, "parent-1") + + vi.advanceTimersByTime(5000) + + // Allow any pending microtasks to flush + await vi.runAllTimersAsync() + + expect(task.abortTask).toHaveBeenCalledWith(true) + expect(customRunner.activeCount).toBe(0) + }) + + it("should invoke onTaskTimeout callback when task times out", async () => { + const onTaskTimeout = vi.fn() + const customRunner = new BackgroundTaskRunner(3, 5000, { onTaskTimeout }) + const task = createMockTask("task-1") + customRunner.registerTask(task, "parent-1") + + vi.advanceTimersByTime(5000) + await vi.runAllTimersAsync() + + expect(onTaskTimeout).toHaveBeenCalledWith("task-1", "parent-1") + }) + }) + + describe("dispose", () => { + it("should cancel all tasks", async () => { + const task1 = createMockTask("task-1") + const task2 = createMockTask("task-2") + + runner.registerTask(task1, "parent-1") + runner.registerTask(task2, "parent-2") + + await runner.dispose() + + expect(task1.abortTask).toHaveBeenCalled() + expect(task2.abortTask).toHaveBeenCalled() + expect(runner.activeCount).toBe(0) + }) + }) + + describe("getTaskInfo", () => { + it("should return task info for registered task", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const info = runner.getTaskInfo("task-1") + expect(info).toBeDefined() + expect(info!.task).toBe(task) + expect(info!.parentTaskId).toBe("parent-1") + expect(info!.startedAt).toBeGreaterThan(0) + }) + + it("should return undefined for unregistered task", () => { + expect(runner.getTaskInfo("unknown")).toBeUndefined() + }) + }) + + describe("getTasksStatus", () => { + it("should return empty array when no tasks", () => { + expect(runner.getTasksStatus()).toEqual([]) + }) + + it("should return running tasks with correct status", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(1) + expect(statuses[0].taskId).toBe("task-1") + expect(statuses[0].parentTaskId).toBe("parent-1") + expect(statuses[0].status).toBe("running") + expect(statuses[0].startedAt).toBeGreaterThan(0) + expect(statuses[0].completedAt).toBeUndefined() + }) + + it("should include completed tasks after onTaskCompleted", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + runner.onTaskCompleted("task-1", "Done!") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(1) + expect(statuses[0].taskId).toBe("task-1") + expect(statuses[0].status).toBe("completed") + expect(statuses[0].resultSummary).toBe("Done!") + expect(statuses[0].completedAt).toBeGreaterThan(0) + }) + + it("should include cancelled tasks after cancelTask", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + await runner.cancelTask("task-1") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(1) + expect(statuses[0].status).toBe("cancelled") + }) + + it("should show both active and completed tasks", () => { + const task1 = createMockTask("task-1") + const task2 = createMockTask("task-2") + runner.registerTask(task1, "parent-1") + runner.registerTask(task2, "parent-1") + runner.onTaskCompleted("task-1", "Result 1") + + const statuses = runner.getTasksStatus() + expect(statuses).toHaveLength(2) + // Active task + const active = statuses.find((s) => s.taskId === "task-2") + expect(active?.status).toBe("running") + // Completed task + const completed = statuses.find((s) => s.taskId === "task-1") + expect(completed?.status).toBe("completed") + }) + }) + + describe("completed tasks buffer", () => { + it("should limit completed tasks to MAX_COMPLETED_TASKS (10)", () => { + // Register and complete 12 tasks + for (let i = 0; i < 12; i++) { + const task = createMockTask(`task-${i}`) + runner.registerTask(task, "parent-1") + runner.onTaskCompleted(`task-${i}`, `Result ${i}`) + } + + const completed = runner.getCompletedTasks() + expect(completed).toHaveLength(10) + // Should keep the most recent 10 + expect(completed[0].taskId).toBe("task-2") + expect(completed[9].taskId).toBe("task-11") + }) + + it("should clear completed tasks", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + runner.onTaskCompleted("task-1", "Done") + + expect(runner.getCompletedTasks()).toHaveLength(1) + runner.clearCompletedTasks() + expect(runner.getCompletedTasks()).toHaveLength(0) + }) + }) + + describe("onStateChanged callback", () => { + it("should be called when a task is registered", () => { + const callback = vi.fn() + runner.onStateChanged = callback + + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("should be called when a task is completed", () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const callback = vi.fn() + runner.onStateChanged = callback + runner.onTaskCompleted("task-1", "Done") + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("should be called when a task is cancelled", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + const callback = vi.fn() + runner.onStateChanged = callback + await runner.cancelTask("task-1") + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it("should not throw if onStateChanged is not set", () => { + const task = createMockTask("task-1") + runner.onStateChanged = undefined + expect(() => runner.registerTask(task, "parent-1")).not.toThrow() + }) + }) + + describe("timeout tracking", () => { + it("should record timed_out status when task times out", async () => { + const task = createMockTask("task-1") + runner.registerTask(task, "parent-1") + + // Advance past timeout + vi.advanceTimersByTime(DEFAULT_BACKGROUND_TASK_TIMEOUT_MS + 1000) + + // Wait for async timeoutTask + await vi.runAllTimersAsync() + + const statuses = runner.getTasksStatus() + const timedOut = statuses.find((s) => s.taskId === "task-1") + expect(timedOut?.status).toBe("timed_out") + }) + }) +}) diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 16e0428120c..a2eb29077e8 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -77,6 +77,14 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { task.consecutiveMistakeCount = 0 + // Background task completion: deliver result via callback, no UI interaction + if (task.isBackgroundTask && task.onBackgroundComplete) { + task.onBackgroundComplete(task.taskId, result) + this.emitTaskCompleted(task) + pushToolResult("") + return + } + await task.say("completion_result", result, undefined, false) // Check for subtask using parentTaskId (metadata-driven delegation) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e379..e4f72d6a3ee 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -15,14 +15,17 @@ interface NewTaskParams { mode: string message: string todos?: string + /** When true, the task runs in the background concurrently with the parent. Read-only tools only. */ + background?: string } export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos } = params + const { mode, message, todos, background } = params const { askApproval, handleError, pushToolResult } = callbacks + const isBackground = background === "true" try { // Validate required parameters. @@ -60,7 +63,8 @@ export class NewTaskTool extends BaseTool<"new_task"> { // Check if todos are required based on VSCode setting. // Note: `undefined` means not provided, empty string is valid. - if (requireTodos && todos === undefined) { + // Background tasks don't require todos (they're read-only). + if (requireTodos && todos === undefined && !isBackground) { task.consecutiveMistakeCount++ task.recordToolError("new_task") task.didToolFailInCurrentTurn = true @@ -101,6 +105,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { mode: targetMode.name, content: message, todos: todoItems, + background: isBackground, }) const didApprove = await askApproval("tool", toolMessage) @@ -109,6 +114,29 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + if (isBackground) { + // Spawn as a background task - parent continues executing + try { + const bgTask = await (provider as any).spawnBackgroundTask({ + parentTaskId: task.taskId, + message: unescapedMessage, + mode, + }) + pushToolResult( + `Background task ${bgTask.taskId} spawned in ${targetMode.name} mode. ` + + `It will run concurrently with read-only tools. ` + + `Results will be delivered when it completes.`, + ) + } catch (error) { + pushToolResult( + formatResponse.toolError( + `Failed to spawn background task: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + } + return + } + // Delegate parent and open child as sole active task const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index aecdb17f316..f02bc7313e0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -80,6 +80,11 @@ import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" +import { + BackgroundTaskRunner, + BACKGROUND_TASK_ALLOWED_TOOLS, + BackgroundTaskRunnerCallbacks, +} from "../task/BackgroundTaskRunner" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" @@ -136,6 +141,21 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false + public readonly backgroundTaskRunner: BackgroundTaskRunner = (() => { + const runner = new BackgroundTaskRunner(undefined, undefined, { + onTaskTimeout: (taskId: string, _parentTaskId: string) => { + vscode.window.showWarningMessage(`Background task ${taskId} timed out and was cancelled.`) + }, + onTaskError: (taskId, _parentTaskId, error) => { + vscode.window.showWarningMessage(`Background task ${taskId} encountered an error: ${error.message}`) + }, + }) + runner.onStateChanged = () => { + // Push updated background task status to the webview whenever tasks change + this.postBackgroundTasksToWebview() + } + return runner + })() private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private pendingOperations: Map = new Map() @@ -546,6 +566,10 @@ export class ClineProvider this._disposed = true this.log("Disposing ClineProvider...") + // Cancel all background tasks first. + await this.backgroundTaskRunner.dispose() + this.log("Disposed background task runner") + // Clear all tasks from the stack. while (this.clineStack.length > 0) { await this.removeClineFromStack() @@ -1832,6 +1856,19 @@ export class ClineProvider this.postMessageToWebview({ type: "state", state }) } + /** + * Push only the background tasks status to the webview. + * This is a lightweight update triggered by BackgroundTaskRunner.onStateChanged + * so the UI can refresh the panel without a full state push. + */ + postBackgroundTasksToWebview(): void { + const backgroundTasks = this.backgroundTaskRunner.getTasksStatus() + this.postMessageToWebview({ + type: "state", + state: { backgroundTasks } as any, + }) + } + /** * Like postStateToWebview but intentionally omits taskHistory. * @@ -2114,6 +2151,7 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), + backgroundTasks: this.backgroundTaskRunner.getTasksStatus(), } } @@ -2912,6 +2950,131 @@ export class ClineProvider return child } + /** + * Spawn a background task that runs concurrently alongside the foreground task. + * Background tasks are: + * - Completely webview-silent (no UI updates) + * - Auto-approved for all tool uses (no user interaction) + * - Restricted to read-only tools only + * - Tracked by the BackgroundTaskRunner with timeout enforcement + * + * The parent task continues executing while the background task runs. + * Results are delivered asynchronously via the onBackgroundComplete callback. + */ + public async spawnBackgroundTask(params: { parentTaskId: string; message: string; mode: string }): Promise { + const { parentTaskId, message, mode } = params + + if (!this.backgroundTaskRunner.canAcceptTask) { + throw new Error( + `[spawnBackgroundTask] Cannot spawn background task: concurrency limit reached ` + + `(${this.backgroundTaskRunner.activeCount} active)`, + ) + } + + // Get parent task for lineage + const parent = this.getCurrentTask() + if (!parent || parent.taskId !== parentTaskId) { + throw new Error(`[spawnBackgroundTask] Parent task mismatch or not found: ${parentTaskId}`) + } + + const { apiConfiguration, experiments } = await this.getState() + + // Switch mode for the background task's context + const savedMode = (await this.getState()).mode + + try { + await this.handleModeSwitch(mode as any) + } catch (e) { + this.log( + `[spawnBackgroundTask] handleModeSwitch failed for mode '${mode}': ${ + (e as Error)?.message ?? String(e) + }`, + ) + } + + // Create the background task - NOT added to clineStack + const backgroundTask = new Task({ + provider: this, + apiConfiguration, + task: message, + experiments, + rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, + parentTask: parent, + taskNumber: -1, // Background tasks don't get a sequential number + isBackgroundTask: true, + enableCheckpoints: false, // Read-only tasks have nothing to checkpoint + startTask: false, + initialStatus: "active", + onBackgroundComplete: (taskId: string, result: string) => { + this.handleBackgroundTaskComplete(taskId, result) + }, + }) + + // Restore the original mode for the foreground task + try { + await this.handleModeSwitch(savedMode as any) + } catch (e) { + this.log( + `[spawnBackgroundTask] Failed to restore mode '${savedMode}': ${(e as Error)?.message ?? String(e)}`, + ) + } + + // Register with the background task runner (handles timeout, tracking) + this.backgroundTaskRunner.registerTask(backgroundTask, parentTaskId) + + // Start the task (it will auto-approve all tools and skip webview updates) + backgroundTask.start() + + this.log( + `[spawnBackgroundTask] Background task ${backgroundTask.taskId} spawned ` + + `(parent: ${parentTaskId}, mode: ${mode})`, + ) + + return backgroundTask + } + + /** + * Handle completion of a background task. Injects the result into the parent + * task's API conversation as a system message. + */ + private async handleBackgroundTaskComplete(taskId: string, result: string): Promise { + const info = this.backgroundTaskRunner.onTaskCompleted(taskId, result) + + if (!info) { + this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`) + return + } + + // Notify the user that the background task finished. + vscode.window.showInformationMessage(`Background task ${taskId} completed.`) + + const parentTaskId = info.parentTaskId + const currentTask = this.getCurrentTask() + + // If the parent is currently the foreground task, inject the result directly + if (currentTask && currentTask.taskId === parentTaskId) { + const resultMessage = [`Background task ${taskId} completed.`, ``, `Result:`, result].join("\n") + + // Inject as a system-level message into the parent's conversation + try { + await currentTask.say("subtask_result", resultMessage) + } catch (error) { + this.log( + `[handleBackgroundTaskComplete] Failed to inject result into parent ${parentTaskId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } else { + // Parent is not the current foreground task (e.g., it was delegated). + // Store the result for later retrieval when the parent resumes. + this.log( + `[handleBackgroundTaskComplete] Parent ${parentTaskId} is not foreground. ` + + `Background task ${taskId} result will not be injected automatically.`, + ) + } + } + /** * Reopen parent task from delegation with write-back and events. */ diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fac7ed10d57..419372db064 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1189,6 +1189,11 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "cancelTask": await provider.cancelTask() break + case "cancelBackgroundTask": + if (message.taskId) { + await provider.backgroundTaskRunner.cancelTask(message.taskId) + } + break case "cancelAutoApproval": // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 4cac8335ea7..aff8c5b1792 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -56,6 +56,7 @@ export const toolParamNames = [ "start_line", "end_line", "todos", + "background", // new_task parameter for background task execution "prompt", "image", // read_file parameters (native protocol) @@ -102,7 +103,7 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string } + new_task: { mode: string; message: string; todos?: string; background?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -240,7 +241,7 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" - params: Partial, "mode" | "message" | "todos">> + params: Partial, "mode" | "message" | "todos" | "background">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx new file mode 100644 index 00000000000..1830e118662 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx @@ -0,0 +1,195 @@ +import React, { useState, useCallback, useMemo } from "react" + +import type { BackgroundTaskStatusInfo } from "@roo-code/types" + +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +/** + * Format elapsed time in a human-readable way. + */ +function formatElapsed(startedAt: number, completedAt?: number): string { + const end = completedAt ?? Date.now() + const ms = end - startedAt + + if (ms < 1000) { + return "<1s" + } + + const seconds = Math.floor(ms / 1000) + + if (seconds < 60) { + return `${seconds}s` + } + + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` +} + +/** + * Get a status icon codicon class based on task status. + */ +function getStatusIcon(status: BackgroundTaskStatusInfo["status"]): string { + switch (status) { + case "running": + return "codicon-loading codicon-modifier-spin" + case "completed": + return "codicon-check" + case "cancelled": + return "codicon-circle-slash" + case "timed_out": + return "codicon-clock" + case "error": + return "codicon-error" + default: + return "codicon-question" + } +} + +/** + * Get a color class for the status indicator. + */ +function getStatusColor(status: BackgroundTaskStatusInfo["status"]): string { + switch (status) { + case "running": + return "text-vscode-charts-blue" + case "completed": + return "text-vscode-charts-green" + case "cancelled": + return "text-vscode-charts-yellow" + case "timed_out": + return "text-vscode-charts-orange" + case "error": + return "text-vscode-errorForeground" + default: + return "text-vscode-descriptionForeground" + } +} + +function BackgroundTaskItem({ task }: { task: BackgroundTaskStatusInfo }) { + const [showResult, setShowResult] = useState(false) + const [confirmingCancel, setConfirmingCancel] = useState(false) + const isRunning = task.status === "running" + + const handleCancelClick = useCallback(() => { + if (!confirmingCancel) { + setConfirmingCancel(true) + // Auto-reset after 3 seconds if user doesn't confirm + setTimeout(() => setConfirmingCancel(false), 3000) + return + } + // Second click confirms cancellation + setConfirmingCancel(false) + vscode.postMessage({ type: "cancelBackgroundTask", taskId: task.taskId }) + }, [confirmingCancel, task.taskId]) + + const shortId = task.taskId.slice(0, 8) + + return ( +
+
+
+ + + {shortId} + + + {formatElapsed(task.startedAt, task.completedAt)} + +
+
+ {task.resultSummary && !isRunning && ( + + )} + {isRunning && ( + + )} +
+
+ {showResult && task.resultSummary && ( +
+ {task.resultSummary.length > 500 ? task.resultSummary.slice(0, 500) + "..." : task.resultSummary} +
+ )} +
+ ) +} + +/** + * BackgroundTasksPanel shows active and recently completed background tasks + * as a collapsible section in the chat sidebar. Only renders when there are + * background tasks to display. + * + * Phase 6+ evolution notes: + * - This panel can be promoted to a tab-based view alongside the main chat + * by extracting the task list into a shared component and rendering it in + * both the sidebar panel and a dedicated "Background Tasks" tab. + * - For real-time progress streaming, each BackgroundTaskItem could accept + * a `progressMessages` prop with the last N tool-call summaries. + * - For conversation replay, clicking a completed task could open its full + * message history in a read-only chat view (reuse ChatView with a + * `readOnly` flag and the task's clineMessages). + */ +const BackgroundTasksPanel: React.FC = () => { + const { backgroundTasks } = useExtensionState() + const [isCollapsed, setIsCollapsed] = useState(false) + + const tasks = useMemo(() => backgroundTasks ?? [], [backgroundTasks]) + + const activeCount = useMemo(() => tasks.filter((t) => t.status === "running").length, [tasks]) + + if (tasks.length === 0) { + return null + } + + return ( +
+ + {!isCollapsed && ( +
+ {tasks.map((task) => ( + + ))} +
+ )} +
+ ) +} + +export default BackgroundTasksPanel diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 04f8e5b6b6d..d2ffe759d7a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -41,6 +41,7 @@ import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" +import BackgroundTasksPanel from "./BackgroundTasksPanel" import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" export interface ChatViewProps { @@ -1641,6 +1642,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction + {areButtonsVisible && (
({ + vscode: { postMessage: vi.fn() }, +})) + +// Mock useExtensionState +const mockBackgroundTasks: BackgroundTaskStatusInfo[] = [] +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + backgroundTasks: mockBackgroundTasks, + }), +})) + +import BackgroundTasksPanel from "../BackgroundTasksPanel" + +describe("BackgroundTasksPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + mockBackgroundTasks.length = 0 + }) + + it("should not render when there are no background tasks", () => { + const { container } = render() + expect(container.innerHTML).toBe("") + }) + + it("should render when there are background tasks", () => { + mockBackgroundTasks.push({ + taskId: "task-abc12345", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now() - 30000, + }) + + render() + expect(screen.getByText("Background Tasks")).toBeDefined() + expect(screen.getByText("task-abc")).toBeDefined() // short ID + }) + + it("should show active count badge", () => { + mockBackgroundTasks.push( + { + taskId: "task-1111", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }, + { + taskId: "task-2222", + parentTaskId: "parent-1", + status: "completed", + startedAt: Date.now() - 60000, + completedAt: Date.now(), + resultSummary: "Done", + }, + ) + + render() + // Badge should show "1" for 1 running task + expect(screen.getByText("1")).toBeDefined() + expect(screen.getByText("2 total")).toBeDefined() + }) + + it("should show cancel button for running tasks", () => { + mockBackgroundTasks.push({ + taskId: "task-run1", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const cancelButton = screen.getByTitle("Cancel background task") + expect(cancelButton).toBeDefined() + }) + + it("should require two clicks to cancel (confirmation pattern)", () => { + mockBackgroundTasks.push({ + taskId: "task-cancel-me", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const cancelButton = screen.getByTitle("Cancel background task") + + // First click shows confirmation text, does NOT send message + fireEvent.click(cancelButton) + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(screen.getByText("Cancel?")).toBeDefined() + + // Second click confirms and sends the cancel message + const confirmButton = screen.getByTitle("Click again to confirm cancellation") + fireEvent.click(confirmButton) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "cancelBackgroundTask", + taskId: "task-cancel-me", + }) + }) + + it("should show Result button for completed tasks with result summary", () => { + mockBackgroundTasks.push({ + taskId: "task-done1", + parentTaskId: "parent-1", + status: "completed", + startedAt: Date.now() - 60000, + completedAt: Date.now(), + resultSummary: "Analysis complete: found 3 issues.", + }) + + render() + const resultButton = screen.getByText("Result") + expect(resultButton).toBeDefined() + + // Click to expand + fireEvent.click(resultButton) + expect(screen.getByText("Analysis complete: found 3 issues.")).toBeDefined() + + // Click to collapse + fireEvent.click(screen.getByText("Hide")) + expect(screen.queryByText("Analysis complete: found 3 issues.")).toBeNull() + }) + + it("should collapse and expand the panel", () => { + mockBackgroundTasks.push({ + taskId: "task-1234", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const header = screen.getByText("Background Tasks") + + // Click to collapse + fireEvent.click(header) + expect(screen.queryByText("task-1234".slice(0, 8))).toBeNull() + + // Click to expand + fireEvent.click(header) + expect(screen.getByText("task-1234".slice(0, 8))).toBeDefined() + }) +})