From 437c9e8e63ca3eeb9a82d94bbc37e458b5071674 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 09:52:21 +0000 Subject: [PATCH 1/5] feat: add Phase 4 background read-only concurrency (BackgroundTaskRunner) Implements the MVP for Phase 4 of the parallel execution roadmap: - Add BackgroundTaskRunner service that manages concurrent read-only background tasks separately from the clineStack - Add isBackgroundTask flag to Task class that suppresses webview updates and auto-approves all tool uses - Extend new_task tool with optional background parameter - Background tasks are restricted to read-only tools only - Results are delivered asynchronously to the parent task via onBackgroundComplete callback - Configurable concurrency limit (default 3) and timeout (default 5min) - Proper cleanup on task cancellation, parent cancellation, and provider disposal - 17 new tests for BackgroundTaskRunner, all existing tests pass Issue #12330 --- .../prompts/tools/native-tools/new_task.ts | 6 +- src/core/task/BackgroundTaskRunner.ts | 199 +++++++++++++++++ src/core/task/Task.ts | 50 ++++- .../__tests__/BackgroundTaskRunner.spec.ts | 201 ++++++++++++++++++ src/core/tools/AttemptCompletionTool.ts | 8 + src/core/tools/NewTaskTool.ts | 36 +++- src/core/webview/ClineProvider.ts | 128 +++++++++++ src/shared/tools.ts | 3 + 8 files changed, 618 insertions(+), 13 deletions(-) create mode 100644 src/core/task/BackgroundTaskRunner.ts create mode 100644 src/core/task/__tests__/BackgroundTaskRunner.spec.ts diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index 97a7eb1da25..18c082e3ceb 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -14,6 +14,7 @@ const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a mar const TASK_QUEUE_PARAMETER_DESCRIPTION = `Optional JSON array of additional subtasks to execute sequentially after the first subtask completes. Each element is an object with "mode" (string) and "message" (string). Example: [{"mode":"code","message":"Implement feature X"},{"mode":"debug","message":"Test feature X"}]. When provided, the system automatically transitions between subtasks without returning to the parent, collecting all results. The parent receives aggregated results when the entire queue completes.` const PERMISSIONS_PARAMETER_DESCRIPTION = `Optional JSON object defining permission boundaries for the subtask. Allows the parent to restrict the subtask's access. Supports: filePatterns (array of regex patterns for allowed file paths), commandPatterns (array of regex patterns for allowed commands), allowedTools (array of tool names the subtask may use), deniedTools (array of tool names the subtask may NOT use). Example: {"filePatterns":["src/components/.*"],"commandPatterns":["npm test.*"],"deniedTools":["execute_command"]}` +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", @@ -43,9 +44,12 @@ export default { permissions: { type: ["string", "null"], description: PERMISSIONS_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..f29b5da1286 --- /dev/null +++ b/src/core/task/BackgroundTaskRunner.ts @@ -0,0 +1,199 @@ +/** + * 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. + */ + +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 +} + +export class BackgroundTaskRunner { + private backgroundTasks: Map = new Map() + private maxConcurrentTasks: number + private taskTimeoutMs: number + + constructor( + maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS, + taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS, + ) { + this.maxConcurrentTasks = maxConcurrentTasks + this.taskTimeoutMs = taskTimeoutMs + } + + /** + * 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})`, + ) + } + + /** + * Called when a background task completes. Cleans up tracking state. + */ + onTaskCompleted(taskId: string): BackgroundTaskInfo | undefined { + const info = this.backgroundTasks.get(taskId) + + if (!info) { + return undefined + } + + clearTimeout(info.timeoutHandle) + this.backgroundTasks.delete(taskId) + + console.log( + `[BackgroundTaskRunner] Background task ${taskId} completed ` + + `(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, + ) + + 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) + + try { + await info.task.abortTask(true) + } catch (error) { + console.error( + `[BackgroundTaskRunner] Error aborting background task ${taskId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + this.backgroundTasks.delete(taskId) + + console.log( + `[BackgroundTaskRunner] Cancelled background task ${taskId} ` + + `(active: ${this.backgroundTasks.size}/${this.maxConcurrentTasks})`, + ) + } + + /** + * 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) + } + } + + /** + * Handle timeout of a background task. + */ + private async timeoutTask(taskId: string): Promise { + console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`) + await this.cancelTask(taskId) + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a4d836af8b8..94d9bd91b55 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -166,6 +166,10 @@ export interface TaskOptions extends CreateTaskOptions { * If not provided, the task falls back to the existing provider.getState() behavior. */ taskContext?: TaskContext + /** 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 { @@ -179,6 +183,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 @@ -455,6 +464,8 @@ export class Task extends EventEmitter implements TaskLike { initialStatus, taskContext, taskPermissions, + isBackgroundTask = false, + onBackgroundComplete, }: TaskOptions) { super() @@ -525,6 +536,8 @@ export class Task extends EventEmitter implements TaskLike { this.taskNumber = taskNumber this.initialStatus = initialStatus this.taskContext = taskContext + this.isBackgroundTask = isBackgroundTask + this.onBackgroundComplete = onBackgroundComplete this.assistantMessageParser = undefined @@ -1185,10 +1198,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() } @@ -1200,8 +1217,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 }) } @@ -1254,7 +1274,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) @@ -1374,6 +1396,20 @@ export class Task extends EventEmitter implements TaskLike { let timeouts: NodeJS.Timeout[] = [] + // Background tasks auto-approve all asks immediately (no user interaction). + 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..1ec2819eb86 --- /dev/null +++ b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts @@ -0,0 +1,201 @@ +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 + }) + }) + + 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) + }) + }) + + 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() + }) + }) +}) diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 7a024735f13..d30aa68a072 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -78,6 +78,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 af0bfe5b680..fc3dfaa6c3e 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,10 +1,7 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" -<<<<<<< HEAD import type { SubtaskQueueItem } from "@roo-code/types" -======= ->>>>>>> 6c51a5d52 (fix: three bugs in task permissions - parser, deniedTools exemption, pattern merging) import { type TaskPermissions, taskPermissionsSchema, toTaskPermissions } from "@roo-code/types" import { Task } from "../task/Task" @@ -22,14 +19,18 @@ interface NewTaskParams { todos?: string task_queue?: string permissions?: 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, task_queue, permissions: permissionsJson } = params + const { mode, message, todos, task_queue, permissions: permissionsJson, background } = params + const { mode, message, todos, background } = params const { askApproval, handleError, pushToolResult } = callbacks + const isBackground = background === "true" try { // Validate required parameters. @@ -67,7 +68,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 @@ -172,6 +174,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { todos: todoItems, taskQueue: queueItems.length > 0 ? queueItems : undefined, ...(parsedPermissions ? { permissions: parsedPermissions } : {}), + background: isBackground, }) const didApprove = await askApproval("tool", toolMessage) @@ -180,6 +183,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 68cdbe8ddd0..25ec4639bdb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -84,6 +84,7 @@ import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { buildTaskContext } from "../task/TaskContextBuilder" +import { BackgroundTaskRunner, BACKGROUND_TASK_ALLOWED_TOOLS } from "../task/BackgroundTaskRunner" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions, ContextHandoffSummary } from "@roo-code/types" @@ -143,6 +144,7 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false + public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner() private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private pendingOperations: Map = new Map() @@ -652,6 +654,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() @@ -3164,6 +3170,128 @@ 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) + + if (!info) { + this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`) + return + } + + 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/shared/tools.ts b/src/shared/tools.ts index c7569e00d2c..0b7eb2109c4 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -58,6 +58,7 @@ export const toolParamNames = [ "todos", "task_queue", "permissions", // new_task parameter for subtask permission boundaries + "background", // new_task parameter for background task execution "prompt", "image", // read_file parameters (native protocol) @@ -105,6 +106,7 @@ export type NativeToolArgs = { apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } new_task: { mode: string; message: string; todos?: string; task_queue?: string; permissions?: string } + new_task: { mode: string; message: string; todos?: string; background?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -244,6 +246,7 @@ export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" params: Partial, "mode" | "message" | "todos" | "task_queue">> params: Partial, "mode" | "message" | "todos" | "permissions">> + params: Partial, "mode" | "message" | "todos" | "background">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { From a599babda1af211bceb569fd82e0cb3fe4cff934 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 10:05:30 +0000 Subject: [PATCH 2/5] fix: add user notifications for background task completion, errors, and timeouts - Add BackgroundTaskRunnerCallbacks interface with onTaskTimeout and onTaskError - Wire VS Code notifications in ClineProvider: info on completion, warning on timeout/error - Document auto-approval design decision for read-only background tasks - Add 2 new tests for callback invocation (19 total, all passing) --- src/core/task/BackgroundTaskRunner.ts | 36 ++++++++++++++++--- src/core/task/Task.ts | 6 ++++ .../__tests__/BackgroundTaskRunner.spec.ts | 25 +++++++++++++ src/core/webview/ClineProvider.ts | 17 ++++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts index f29b5da1286..9f383d17c54 100644 --- a/src/core/task/BackgroundTaskRunner.ts +++ b/src/core/task/BackgroundTaskRunner.ts @@ -35,17 +35,31 @@ export interface BackgroundTaskInfo { 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 +} + export class BackgroundTaskRunner { private backgroundTasks: Map = new Map() private maxConcurrentTasks: number private taskTimeoutMs: number + private callbacks: BackgroundTaskRunnerCallbacks 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 } /** @@ -163,11 +177,13 @@ export class BackgroundTaskRunner { try { await info.task.abortTask(true) } catch (error) { - console.error( - `[BackgroundTaskRunner] Error aborting background task ${taskId}: ${ - error instanceof Error ? error.message : String(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) @@ -193,7 +209,17 @@ export class BackgroundTaskRunner { * Handle timeout of a background task. */ private async timeoutTask(taskId: string): Promise { + const info = this.backgroundTasks.get(taskId) + const parentTaskId = info?.parentTaskId ?? "unknown" + 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. + } + await this.cancelTask(taskId) } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 94d9bd91b55..0ddc1f505f9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1397,6 +1397,12 @@ 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 }) diff --git a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts index 1ec2819eb86..29d9e28cd56 100644 --- a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts +++ b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts @@ -129,6 +129,19 @@ describe("BackgroundTaskRunner", () => { 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", () => { @@ -164,6 +177,18 @@ describe("BackgroundTaskRunner", () => { 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", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 25ec4639bdb..d4922c165f8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -85,6 +85,11 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { buildTaskContext } from "../task/TaskContextBuilder" import { BackgroundTaskRunner, BACKGROUND_TASK_ALLOWED_TOOLS } from "../task/BackgroundTaskRunner" +import { + BackgroundTaskRunner, + BACKGROUND_TASK_ALLOWED_TOOLS, + BackgroundTaskRunnerCallbacks, +} from "../task/BackgroundTaskRunner" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions, ContextHandoffSummary } from "@roo-code/types" @@ -144,7 +149,14 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false - public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner() + public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner(undefined, undefined, { + onTaskTimeout: (taskId, _parentTaskId) => { + 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}`) + }, + }) private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private pendingOperations: Map = new Map() @@ -3265,6 +3277,9 @@ export class ClineProvider return } + // Notify the user that the background task finished. + vscode.window.showInformationMessage(`Background task ${taskId} completed.`) + const parentTaskId = info.parentTaskId const currentTask = this.getCurrentTask() From f42109eea860e58af8f5bac74b75037e02ec7875 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 10:35:07 +0000 Subject: [PATCH 3/5] feat: Phase 5 - Background Tasks Panel UI for parallel task visibility Adds a collapsible Background Tasks Panel to the chat sidebar that shows active and recently completed background tasks. This builds on the Phase 4 BackgroundTaskRunner to give users visibility into background work. Key changes: - BackgroundTaskStatusInfo type for exposing task status to the webview - BackgroundTaskRunner tracks completed tasks with result summaries - BackgroundTaskRunner.getTasksStatus() returns combined active + completed - BackgroundTaskRunner.onStateChanged callback for UI refresh - backgroundTasks field added to ExtensionState and getStateToPostToWebview - cancelBackgroundTask webview message handler - postBackgroundTasksToWebview() for lightweight status-only updates - BackgroundTasksPanel React component with collapsible panel, cancel buttons, active count badge, and result summaries - 31 backend tests (BackgroundTaskRunner) + 7 UI tests (panel component) --- packages/types/src/vscode-extension-host.ts | 20 +++ src/core/task/BackgroundTaskRunner.ts | 137 +++++++++++++- .../__tests__/BackgroundTaskRunner.spec.ts | 143 +++++++++++++++ src/core/webview/ClineProvider.ts | 39 +++- src/core/webview/webviewMessageHandler.ts | 5 + .../components/chat/BackgroundTasksPanel.tsx | 168 ++++++++++++++++++ webview-ui/src/components/chat/ChatView.tsx | 2 + .../__tests__/BackgroundTasksPanel.spec.tsx | 143 +++++++++++++++ 8 files changed, 646 insertions(+), 11 deletions(-) create mode 100644 webview-ui/src/components/chat/BackgroundTasksPanel.tsx create mode 100644 webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 52f07493c1a..c8c448e993f 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -352,6 +352,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 @@ -361,6 +364,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" @@ -541,6 +559,8 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "browseForWorktreePath" + // Background task messages + | "cancelBackgroundTask" // Skills messages | "requestSkills" | "createSkill" diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts index 9f383d17c54..e7ed7127c2b 100644 --- a/src/core/task/BackgroundTaskRunner.ts +++ b/src/core/task/BackgroundTaskRunner.ts @@ -10,6 +10,8 @@ * This is Phase 4 of the parallel execution roadmap: Background Read-Only Concurrency. */ +import { BackgroundTaskStatusInfo } from "@roo-code/types" + import { Task, TaskOptions } from "./Task" /** Read-only tools that background tasks are allowed to use. */ @@ -46,11 +48,27 @@ export interface BackgroundTaskRunnerCallbacks { 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, @@ -108,12 +126,14 @@ export class BackgroundTaskRunner { `[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): BackgroundTaskInfo | undefined { + onTaskCompleted(taskId: string, resultSummary?: string): BackgroundTaskInfo | undefined { const info = this.backgroundTasks.get(taskId) if (!info) { @@ -123,11 +143,22 @@ export class BackgroundTaskRunner { 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 } @@ -174,9 +205,12 @@ export class BackgroundTaskRunner { 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 { @@ -188,10 +222,20 @@ export class BackgroundTaskRunner { 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() } /** @@ -205,12 +249,79 @@ export class BackgroundTaskRunner { } } + /** + * 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`) @@ -220,6 +331,28 @@ export class BackgroundTaskRunner { // Callback errors must not break cleanup. } - await this.cancelTask(taskId) + // 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/__tests__/BackgroundTaskRunner.spec.ts b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts index 29d9e28cd56..184a1b9b3f0 100644 --- a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts +++ b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts @@ -223,4 +223,147 @@ describe("BackgroundTaskRunner", () => { 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/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d4922c165f8..1f4f2ee7c7a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -149,14 +149,21 @@ export class ClineProvider private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false - public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner(undefined, undefined, { - onTaskTimeout: (taskId, _parentTaskId) => { - 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}`) - }, - }) + 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() @@ -1962,6 +1969,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. * @@ -2280,6 +2300,7 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), + backgroundTasks: this.backgroundTaskRunner.getTasksStatus(), } } @@ -3270,7 +3291,7 @@ export class ClineProvider * task's API conversation as a system message. */ private async handleBackgroundTaskComplete(taskId: string, result: string): Promise { - const info = this.backgroundTaskRunner.onTaskCompleted(taskId) + const info = this.backgroundTaskRunner.onTaskCompleted(taskId, result) if (!info) { this.log(`[handleBackgroundTaskComplete] Task ${taskId} not found in background runner`) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 210cb258c63..434995aa5b3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1305,6 +1305,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/webview-ui/src/components/chat/BackgroundTasksPanel.tsx b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx new file mode 100644 index 00000000000..d431415165c --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx @@ -0,0 +1,168 @@ +import React, { useState, 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 isRunning = task.status === "running" + + const handleCancel = () => { + vscode.postMessage({ type: "cancelBackgroundTask", taskId: 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. + */ +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 dd9cbbe36a7..76d3dc4b5fc 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -46,6 +46,7 @@ import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import BackgroundTasksPanel from "./BackgroundTasksPanel" import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" import { Cloud } from "lucide-react" @@ -1673,6 +1674,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 send cancelBackgroundTask message when cancel is clicked", () => { + mockBackgroundTasks.push({ + taskId: "task-cancel-me", + parentTaskId: "parent-1", + status: "running", + startedAt: Date.now(), + }) + + render() + const cancelButton = screen.getByTitle("Cancel background task") + fireEvent.click(cancelButton) + + 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() + }) +}) From c5e405deb9522c217db295531f75387a214d1be7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 10:50:13 +0000 Subject: [PATCH 4/5] fix: add cancel confirmation, panel scroll constraint, and Phase 6 extension-point comments --- src/core/task/BackgroundTaskRunner.ts | 10 +++++ .../components/chat/BackgroundTasksPanel.tsx | 43 +++++++++++++++---- .../__tests__/BackgroundTasksPanel.spec.tsx | 9 +++- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts index e7ed7127c2b..638845198d3 100644 --- a/src/core/task/BackgroundTaskRunner.ts +++ b/src/core/task/BackgroundTaskRunner.ts @@ -8,6 +8,16 @@ * - 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" diff --git a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx index d431415165c..1830e118662 100644 --- a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx +++ b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react" +import React, { useState, useCallback, useMemo } from "react" import type { BackgroundTaskStatusInfo } from "@roo-code/types" @@ -69,11 +69,20 @@ function getStatusColor(status: BackgroundTaskStatusInfo["status"]): string { function BackgroundTaskItem({ task }: { task: BackgroundTaskStatusInfo }) { const [showResult, setShowResult] = useState(false) + const [confirmingCancel, setConfirmingCancel] = useState(false) const isRunning = task.status === "running" - const handleCancel = () => { + 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) @@ -102,10 +111,18 @@ function BackgroundTaskItem({ task }: { task: BackgroundTaskStatusInfo }) { )} {isRunning && ( )}
@@ -123,6 +140,16 @@ function BackgroundTaskItem({ task }: { task: BackgroundTaskStatusInfo }) { * 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() @@ -155,7 +182,7 @@ const BackgroundTasksPanel: React.FC = () => { {tasks.length} total {!isCollapsed && ( -
+
{tasks.map((task) => ( ))} diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx index 3eb52351a31..45d370ad096 100644 --- a/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx @@ -80,7 +80,7 @@ describe("BackgroundTasksPanel", () => { expect(cancelButton).toBeDefined() }) - it("should send cancelBackgroundTask message when cancel is clicked", () => { + it("should require two clicks to cancel (confirmation pattern)", () => { mockBackgroundTasks.push({ taskId: "task-cancel-me", parentTaskId: "parent-1", @@ -90,8 +90,15 @@ describe("BackgroundTasksPanel", () => { 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", From 35bed0c1b6075d106b5dfc6357f30afecc25c47c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 14:42:02 +0000 Subject: [PATCH 5/5] feat: Phase 4+5+6 - Background Concurrency, Panel UI, Task Visibility (#12330) Phase 4: Background Read-Only Concurrency Phase 5: Background Tasks Panel UI Phase 6a: Conversation Replay for background tasks Phase 6b: Tab/Panel Switching for background tasks Phase 6c: Real-time Progress Streaming --- .../phase-6-background-task-visibility.md | 327 ++++++++++++++++ packages/types/src/vscode-extension-host.ts | 41 +- packages/types/src/vscode.ts | 1 + src/activate/registerCommands.ts | 9 + .../presentAssistantMessage.ts | 27 ++ .../prompts/tools/native-tools/new_task.ts | 2 +- src/core/task/BackgroundTaskRunner.ts | 368 ----------------- src/core/task/Task.ts | 111 ++++-- .../__tests__/BackgroundTaskRunner.spec.ts | 369 ------------------ src/core/tools/AttemptCompletionTool.ts | 8 - src/core/tools/NewTaskTool.ts | 28 +- src/core/webview/ClineProvider.ts | 160 +------- ...sageHandler.backgroundTaskMessages.spec.ts | 83 ++++ ...sageHandler.backgroundTaskProgress.spec.ts | 52 +++ src/core/webview/webviewMessageHandler.ts | 29 +- src/package.json | 19 +- src/shared/tools.ts | 2 + webview-ui/src/App.tsx | 19 + webview-ui/src/__tests__/App.spec.tsx | 50 +++ .../chat/BackgroundTaskLiveView.tsx | 152 ++++++++ .../chat/BackgroundTaskReplayView.tsx | 139 +++++++ .../components/chat/BackgroundTaskView.tsx | 83 ++++ .../components/chat/BackgroundTasksList.tsx | 165 ++++++++ .../components/chat/BackgroundTasksPanel.tsx | 195 --------- webview-ui/src/components/chat/ChatView.tsx | 1 - .../__tests__/BackgroundTaskLiveView.spec.tsx | 181 +++++++++ .../BackgroundTaskReplayView.spec.tsx | 147 +++++++ .../__tests__/BackgroundTaskView.spec.tsx | 176 +++++++++ .../__tests__/BackgroundTasksList.spec.tsx | 153 ++++++++ .../__tests__/BackgroundTasksPanel.spec.tsx | 150 ------- 30 files changed, 1903 insertions(+), 1344 deletions(-) create mode 100644 docs/architecture/phase-6-background-task-visibility.md delete mode 100644 src/core/task/BackgroundTaskRunner.ts delete mode 100644 src/core/task/__tests__/BackgroundTaskRunner.spec.ts create mode 100644 src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts create mode 100644 src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts create mode 100644 webview-ui/src/components/chat/BackgroundTaskLiveView.tsx create mode 100644 webview-ui/src/components/chat/BackgroundTaskReplayView.tsx create mode 100644 webview-ui/src/components/chat/BackgroundTaskView.tsx create mode 100644 webview-ui/src/components/chat/BackgroundTasksList.tsx delete mode 100644 webview-ui/src/components/chat/BackgroundTasksPanel.tsx create mode 100644 webview-ui/src/components/chat/__tests__/BackgroundTaskLiveView.spec.tsx create mode 100644 webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx create mode 100644 webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx create mode 100644 webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx delete mode 100644 webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx diff --git a/docs/architecture/phase-6-background-task-visibility.md b/docs/architecture/phase-6-background-task-visibility.md new file mode 100644 index 00000000000..7f9ecb420b0 --- /dev/null +++ b/docs/architecture/phase-6-background-task-visibility.md @@ -0,0 +1,327 @@ +# Phase 6: Background Task Visibility and Interaction + +> Architectural design document for Issue #12330 +> Phase 6 of "Support parallel execution of specialized agents and improve context handoff between modes" + +## 1. Context + +Phase 5 (Background Tasks Panel UI) is complete. This document proposes the scope, priority, and architecture for Phase 6, which focuses on enabling better visibility and interaction with background tasks. + +## 2. Current Architecture + +### Task Lifecycle + +`ClineProvider` maintains a `clineStack: Task[]` (LIFO). Only the top-of-stack task is "current" -- all state updates, webview messages, and user interactions route through `getCurrentTask()`. + +``` +ClineProvider +├── clineStack: Task[] # LIFO stack, sequential execution +├── taskHistoryStore # Per-task file persistence +├── getCurrentTask() # Returns top of stack +├── addClineToStack(task) # Push new task +└── removeClineFromStack() # Pop completed task +``` + +### Task Persistence + +| Layer | File | Purpose | +|-------|------|---------| +| Messages | `taskMessages.ts` | Save/load `ClineMessage[]` per task | +| API History | `apiMessages.ts` | Save/load API conversation history | +| History Items | `TaskHistoryStore.ts` | Per-task metadata files with in-memory cache | +| Metadata | `taskMetadata.ts` | Task metadata helpers | + +### Webview Communication + +The extension sends typed `ExtensionMessage` objects to the webview. Key message types: + +- `state` -- Full state snapshot (includes `clineMessages`, `currentTaskId`) +- `taskHistoryUpdated` -- Full history list refresh +- `taskHistoryItemUpdated` -- Single history item update + +Currently, `postStateToWebviewWithoutTaskHistory()` sends state for only the current task. There is no mechanism to send updates for background tasks. + +### Subtask Support + +Parent-child relationships exist via `parentTaskId` and `childIds` on `HistoryItem`. The `new_task` tool creates subtasks that push onto the stack. When a subtask completes, it pops and returns control to the parent. + +## 3. Agreed Scope for Phase 6 + +**In scope (Items 1-3):** +1. Full conversation replay for completed background tasks +2. Tab switching / multi-task view +3. Real-time progress streaming for active background tasks + +**Deferred to Phase 7 (Items 4-5):** +4. Write-capable background tasks + basic file locking +5. Persistent background task history across sessions + +## 4. Feasibility Analysis + +### Item 1: Full Conversation Replay + +**Complexity: Medium | Risk: Low** + +`readTaskMessages(taskId, globalStoragePath)` already loads the full `ClineMessage[]` array from disk for any task. The existing `ChatView` component renders these messages. The main work is creating a read-only wrapper that: + +- Accepts a `taskId` prop instead of reading from global state +- Loads messages on mount via a new webview message +- Hides input controls (chat box, approval buttons) +- Renders tool calls, outputs, and assistant responses in the same format + +**Why it's low risk:** No changes to task execution, persistence, or the foreground task flow. Purely additive UI + a new message handler. + +### Item 2: Tab Switching / Multi-task View + +**Complexity: Medium-High | Risk: Medium** + +The webview already has a tab system in `App.tsx` (`tab === "history"`, `tab === "settings"`, `tab === "chat"`). Adding a background tasks view requires: + +- A new tab or panel within the chat view +- A list of active/completed background tasks with status indicators +- Navigation to open a task's replay view or live view +- State management to track which background task is currently being viewed + +**Key challenge:** The webview currently receives state for only one task. Viewing a background task must not disrupt the foreground task's state. This requires either: + - (a) A separate message channel for background task data, or + - (b) A secondary state context in the webview that can hold background task data alongside the primary task state + +Option (a) is cleaner and avoids polluting the existing state management. + +### Item 3: Real-time Progress Streaming + +**Complexity: High | Risk: Medium-High** + +Currently, `Task.ts` calls `provider.postStateToWebviewWithoutTaskHistory()` to update the UI. This method sends the full state for the current task only. For background tasks to stream progress: + +1. `Task.ts` must emit incremental updates even when it is not the "current" task +2. A new message type (`backgroundTaskProgress`) must carry task-scoped updates +3. The webview must handle concurrent update streams without degrading performance +4. Throttling/batching is needed to prevent excessive re-renders + +**Why it's harder:** Requires changes to the core task execution loop (`Task.ts`), not just additive UI. The task currently assumes it IS the visible task when posting updates. + +## 5. Recommended Priority Order + +``` +Phase 6a: Conversation Replay (Foundation -- standalone value) + │ + ▼ +Phase 6b: Tab/Panel Switching (Navigation framework, depends on 6a) + │ + ▼ +Phase 6c: Real-time Progress Streaming (Highest complexity, builds on 6b) +``` + +Each sub-phase is independently shippable and testable. + +## 6. Detailed Design + +### 6a. Conversation Replay + +#### New Message Types + +```typescript +// Webview → Extension +interface RequestBackgroundTaskMessages { + type: "requestBackgroundTaskMessages" + taskId: string +} + +// Extension → Webview +interface BackgroundTaskMessages { + type: "backgroundTaskMessages" + taskId: string + messages: ClineMessage[] +} +``` + +#### Extension Handler (webviewMessageHandler.ts) + +```typescript +case "requestBackgroundTaskMessages": { + const taskId = message.taskId + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages(taskId, globalStoragePath) + provider.postMessageToWebview({ + type: "backgroundTaskMessages", + taskId, + messages: messages ?? [], + }) + break +} +``` + +#### Webview Component + +``` +BackgroundTaskReplayView +├── Props: { taskId: string, onClose: () => void } +├── State: messages (ClineMessage[]), loading (boolean) +├── On mount: sends requestBackgroundTaskMessages +├── On message: receives backgroundTaskMessages, filters by taskId +├── Renders: read-only message list (reuses ChatRow components) +└── No input controls, no approval buttons +``` + +### 6b. Tab/Panel Switching + +#### UI Structure + +A new icon is added to the existing tab bar (alongside chat, history, settings) as the entry point. The background task view occupies the full tab area. + +``` +App.tsx +├── tab === "chat" → ChatView (foreground task) +├── tab === "history" → HistoryView +├── tab === "settings" → SettingsView +└── tab === "bgTask" → BackgroundTaskView + ├── BackgroundTasksList (task list with status badges + error badge on tab icon) + │ ├── Active tasks + │ └── Completed tasks + └── BackgroundTaskReplayView (from 6a) OR BackgroundTaskLiveView (from 6c) +``` + +#### State Management + +```typescript +// New webview state (in App.tsx or dedicated context) +interface BackgroundTaskViewState { + selectedTaskId: string | null + viewMode: "replay" | "live" +} +``` + +#### Navigation Flow + +1. User clicks background tasks icon in the tab bar +2. App switches to `tab === "bgTask"` +3. BackgroundTasksList shows available tasks +4. User clicks a task → sets `selectedTaskId` +5. If task is completed → opens BackgroundTaskReplayView +6. If task is active → opens BackgroundTaskLiveView (Phase 6c) + +### 6c. Real-time Progress Streaming (Minimal Viable Version) + +> **Design principle:** Keep Phase 6c tightly scoped to avoid expanding the phase. Ship the simplest useful version first; richer detail can be added incrementally in later phases. + +#### MVP Scope + +The minimal viable version streams only: +- **Tool name + status** (started / completed / errored) -- not full parameters or output +- **Last N updates** (rolling window of ~20 items) -- older entries are discarded client-side +- **Status changes** (running, paused, completed, errored) + +What is explicitly **out of scope** for the MVP: +- Full tool call parameters or output payloads +- Assistant text streaming +- Persistent storage of streamed updates (replay from disk covers completed tasks) + +#### New Message Types + +```typescript +// Extension → Webview (incremental updates) +interface BackgroundTaskProgress { + type: "backgroundTaskProgress" + taskId: string + update: BackgroundTaskUpdate +} + +interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} +``` + +Note: `assistant_text` is excluded from the MVP. The update interface uses typed optional fields instead of `data: any` to keep the contract narrow and safe. + +#### Task.ts Changes + +Add a method that emits progress regardless of whether the task is "current": + +```typescript +// In Task.ts +private emitBackgroundProgress(update: BackgroundTaskUpdate) { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit background updates when this task is NOT the current task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + taskId: this.taskId, + update, + }) +} +``` + +The hook points in Task.ts should be minimal -- emit at tool call start and tool call end only. Avoid adding hooks inside the LLM streaming loop for the MVP. + +#### Throttling Strategy + +- Batch updates in 500ms windows (conservative default; can be tuned down later) +- Cap at 5 updates per batch per task +- Drop older updates if buffer exceeds threshold (keep last N = 20) +- Priority ordering: status_change > error > tool_result > tool_call + +#### Webview: BackgroundTaskLiveView + +``` +BackgroundTaskLiveView +├── Props: { taskId: string } +├── State: updates (BackgroundTaskUpdate[], capped at last 20), status +├── Subscribes to backgroundTaskProgress messages filtered by taskId +├── Renders: compact list of recent tool calls with status icons +├── Auto-scrolls to latest update +└── Shows task status badge (running, paused, completed, errored) +``` + +The live view intentionally shows a compact summary, not a full chat transcript. Users who want full detail can wait for the task to complete and use the replay view (6a). + +> **Confirmed:** Streaming is scoped to the currently selected background task only. The extension should not emit `backgroundTaskProgress` messages for tasks the user is not viewing. This keeps message traffic low and the implementation simple. + +## 7. Testing Strategy + +| Area | Test Type | Key Scenarios | +|------|-----------|---------------| +| Message handler | Unit (vitest) | Request/response for task messages, missing task, corrupt data | +| BackgroundTaskReplayView | Component (vitest + RTL) | Loading state, message rendering, empty state | +| Tab switching | Component (vitest + RTL) | Tab navigation, state preservation, back to foreground | +| Progress streaming | Unit (vitest) | Throttling, batching, concurrent tasks | +| Integration | E2E (if feasible) | Full flow: start bg task → view progress → replay after completion | + +## 8. Confirmed Decisions + +The following decisions were confirmed during design review and should guide implementation. + +### UI Layout + +1. **Background task view layout: Full tab** (`tab === "bgTask"`) + + Start with a full tab for simplicity in Phase 6. A sidebar/hybrid mode may be considered later based on user feedback. + +2. **Entry point placement: New tab bar icon** + + Add a new icon in the existing tab bar (alongside chat, history, settings). This is the most discoverable location without cluttering the chat view. + +3. **Replay view implementation: Thin wrapper around ChatRow components** + + Create a dedicated `BackgroundTaskReplayView` that wraps `ChatRow` components directly rather than reusing the full `ChatView`. This avoids inheriting input controls, scroll management, and approval button logic that don't apply to read-only replay. + +### Progress Streaming (6c) + +4. **Streaming granularity: Minimal level** + + Stream tool name + status only (started/completed/errored). This provides enough signal to know what the background task is doing without performance risk. Truncated arguments (medium level) can be added in a follow-up if users need more context. + +5. **Streaming scope: Currently selected task only** + + Only stream updates for the background task the user is currently viewing. This avoids unnecessary message traffic and keeps the implementation simple. + +6. **Error surfacing: Badge on the background tasks tab icon** + + Display a badge on the tab icon when a background task encounters an error. Toast notifications can be added later if users miss errors. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c8c448e993f..d47cf2d4900 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -16,6 +16,18 @@ import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-lim import type { SkillMetadata } from "./skills.js" import type { WorktreeIncludeStatus } from "./worktree.js" +/** + * Incremental progress update for a background task (Phase 6c). + * MVP: tool name + status only. No full parameters or output payloads. + */ +export interface BackgroundTaskUpdate { + kind: "tool_call" | "tool_result" | "status_change" | "error" + timestamp: number + toolName?: string // e.g. "read_file", "execute_command" + status?: string // e.g. "started", "completed", "errored" + errorMessage?: string // Only for kind === "error" +} + /** * ExtensionMessage * Extension -> Webview | CLI @@ -94,6 +106,8 @@ export interface ExtensionMessage { | "folderSelected" | "skills" | "fileContent" + | "backgroundTaskMessages" + | "backgroundTaskProgress" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -107,6 +121,7 @@ export interface ExtensionMessage { | "settingsButtonClicked" | "historyButtonClicked" | "cloudButtonClicked" + | "backgroundTasksButtonClicked" | "didBecomeVisible" | "focusInput" | "switchTab" @@ -166,6 +181,9 @@ export interface ExtensionMessage { tools?: SerializedCustomToolDefinition[] // For customToolsResult skills?: SkillMetadata[] // For skills response modes?: { slug: string; name: string }[] // For modes response + backgroundTaskMessages?: ClineMessage[] // For backgroundTaskMessages: loaded messages for a background task replay + backgroundTaskId?: string // For backgroundTaskMessages: the task ID these messages belong to + backgroundTaskProgress?: BackgroundTaskUpdate // For backgroundTaskProgress: incremental update for a background task aggregatedCosts?: { // For taskWithAggregatedCosts response totalCost: number @@ -352,9 +370,6 @@ 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 @@ -364,21 +379,6 @@ 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" @@ -560,7 +560,9 @@ export interface WebviewMessage { | "checkoutBranch" | "browseForWorktreePath" // Background task messages - | "cancelBackgroundTask" + | "requestBackgroundTaskMessages" + | "subscribeToBackgroundTask" + | "unsubscribeFromBackgroundTask" // Skills messages | "requestSkills" | "createSkill" @@ -572,6 +574,7 @@ export interface WebviewMessage { taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay" | "bgTask" disabled?: boolean context?: string dataUri?: string diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 7ea53778888..b709e710aeb 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -46,6 +46,7 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + "backgroundTasksButtonClicked", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 44de751bcb2..6e794fb75b3 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -117,6 +117,15 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" }) }, + backgroundTasksButtonClicked: () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + visibleProvider.postMessageToWebview({ type: "action", action: "backgroundTasksButtonClicked" }) + }, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index dc0c98c7d11..d3393d09610 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -486,6 +486,14 @@ export async function presentAssistantMessage(cline: Task) { } hasToolResult = true + + // Phase 6c: Emit background progress when a tool completes + cline.emitBackgroundProgress({ + kind: "tool_result", + timestamp: Date.now(), + toolName: block.name, + status: "completed", + }) } const askApproval = async ( @@ -547,6 +555,15 @@ export async function presentAssistantMessage(cline: Task) { `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) + // Phase 6c: Emit background progress on error + cline.emitBackgroundProgress({ + kind: "error", + timestamp: Date.now(), + toolName: block.name, + status: "errored", + errorMessage: error.message, + }) + pushToolResult(formatResponse.toolError(errorString)) } @@ -649,6 +666,16 @@ export async function presentAssistantMessage(cline: Task) { } } + // Phase 6c: Emit background progress when a tool starts executing + if (!block.partial) { + cline.emitBackgroundProgress({ + kind: "tool_call", + timestamp: Date.now(), + toolName: block.name, + status: "started", + }) + } + switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index 18c082e3ceb..f846eaf73a0 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -49,7 +49,7 @@ export default { description: BACKGROUND_PARAMETER_DESCRIPTION, }, }, - required: ["mode", "message", "todos", "background"], + required: ["mode", "message", "todos"], additionalProperties: false, }, }, diff --git a/src/core/task/BackgroundTaskRunner.ts b/src/core/task/BackgroundTaskRunner.ts deleted file mode 100644 index 638845198d3..00000000000 --- a/src/core/task/BackgroundTaskRunner.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * 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 0ddc1f505f9..812d39ff41e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -30,6 +30,7 @@ import { type ClineSay, type ClineAsk, type ToolProgressStatus, + type BackgroundTaskUpdate, type HistoryItem, type CreateTaskOptions, type ModelInfo, @@ -183,11 +184,6 @@ 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 @@ -1198,14 +1194,10 @@ export class Task extends EventEmitter implements TaskLike { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) - - 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() - } - + 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() } @@ -1217,11 +1209,8 @@ export class Task extends EventEmitter implements TaskLike { } private async updateClineMessage(message: ClineMessage) { - if (!this.isBackgroundTask) { - const provider = this.providerRef.deref() - await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) - } - + const provider = this.providerRef.deref() + await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) this.emit(RooCodeEventName.Message, { action: "updated", message }) } @@ -1274,9 +1263,7 @@ export class Task extends EventEmitter implements TaskLike { // - Final state is emitted when updates stop (trailing: true) this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) - if (!this.isBackgroundTask) { - await this.providerRef.deref()?.updateTaskHistory(historyItem) - } + await this.providerRef.deref()?.updateTaskHistory(historyItem) return true } catch (error) { console.error("Failed to save Roo messages:", error) @@ -1396,26 +1383,6 @@ 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 @@ -4624,6 +4591,68 @@ export class Task extends EventEmitter implements TaskLike { } } + // --- Phase 6c: Background task progress streaming --- + + private backgroundProgressBuffer: BackgroundTaskUpdate[] = [] + private backgroundProgressTimer: ReturnType | null = null + private static readonly BACKGROUND_PROGRESS_THROTTLE_MS = 500 + private static readonly BACKGROUND_PROGRESS_MAX_BATCH = 5 + + /** + * Emit a progress update for this task if it is a background task currently + * being viewed by the user. Updates are batched in 500ms windows and capped + * at 5 per batch. + */ + public emitBackgroundProgress(update: BackgroundTaskUpdate): void { + const provider = this.providerRef.deref() + if (!provider) return + + // Only emit when this task is NOT the current (foreground) task + if (provider.getCurrentTask()?.taskId === this.taskId) return + + // Only emit when the user is actively viewing this background task + if (provider.viewedBackgroundTaskId !== this.taskId) return + + this.backgroundProgressBuffer.push(update) + + // If no flush is pending, schedule one + if (!this.backgroundProgressTimer) { + this.backgroundProgressTimer = setTimeout(() => { + this.flushBackgroundProgress() + }, Task.BACKGROUND_PROGRESS_THROTTLE_MS) + } + } + + private flushBackgroundProgress(): void { + this.backgroundProgressTimer = null + const provider = this.providerRef.deref() + if (!provider) { + this.backgroundProgressBuffer = [] + return + } + + // Take at most MAX_BATCH items, prioritizing by kind + const priorityOrder: Record = { + status_change: 0, + error: 1, + tool_result: 2, + tool_call: 3, + } + const sorted = this.backgroundProgressBuffer.sort( + (a, b) => (priorityOrder[a.kind] ?? 4) - (priorityOrder[b.kind] ?? 4), + ) + const batch = sorted.slice(0, Task.BACKGROUND_PROGRESS_MAX_BATCH) + this.backgroundProgressBuffer = [] + + for (const update of batch) { + provider.postMessageToWebview({ + type: "backgroundTaskProgress", + backgroundTaskId: this.taskId, + backgroundTaskProgress: update, + }) + } + } + // Getters public get taskStatus(): TaskStatus { diff --git a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts b/src/core/task/__tests__/BackgroundTaskRunner.spec.ts deleted file mode 100644 index 184a1b9b3f0..00000000000 --- a/src/core/task/__tests__/BackgroundTaskRunner.spec.ts +++ /dev/null @@ -1,369 +0,0 @@ -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 d30aa68a072..7a024735f13 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -78,14 +78,6 @@ 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 fc3dfaa6c3e..e681c88d386 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -29,8 +29,8 @@ export class NewTaskTool extends BaseTool<"new_task"> { async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { const { mode, message, todos, task_queue, permissions: permissionsJson, background } = params const { mode, message, todos, background } = params + const { mode, message, todos } = params const { askApproval, handleError, pushToolResult } = callbacks - const isBackground = background === "true" try { // Validate required parameters. @@ -68,8 +68,7 @@ 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. - // Background tasks don't require todos (they're read-only). - if (requireTodos && todos === undefined && !isBackground) { + if (requireTodos && todos === undefined) { task.consecutiveMistakeCount++ task.recordToolError("new_task") task.didToolFailInCurrentTurn = true @@ -183,29 +182,6 @@ 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 1f4f2ee7c7a..0d86762ad0c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -149,21 +149,6 @@ 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() @@ -181,6 +166,8 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number + /** The background task ID the webview is currently viewing (for Phase 6c progress streaming). */ + public viewedBackgroundTaskId: string | null = null public readonly latestAnnouncementId = "apr-2026-v3.53.0-community-handoff-gpt55-opus47" // v3.53.0 Community handoff, GPT-5.5, Claude Opus 4.7, checkpoint navigation public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -673,10 +660,6 @@ 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() @@ -1969,19 +1952,6 @@ 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. * @@ -2300,7 +2270,6 @@ export class ClineProvider } })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), - backgroundTasks: this.backgroundTaskRunner.getTasksStatus(), } } @@ -3203,131 +3172,6 @@ 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/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts new file mode 100644 index 00000000000..d9e68b79f68 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts @@ -0,0 +1,83 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskMessages.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +vi.mock("../../task-persistence", () => ({ + saveTaskMessages: vi.fn(), + readTaskMessages: vi.fn(), +})) + +import { readTaskMessages } from "../../task-persistence" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), +} as unknown as ClineProvider + +describe("webviewMessageHandler - requestBackgroundTaskMessages", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("loads task messages from disk and posts them to the webview", async () => { + const mockMessages = [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ] + vi.mocked(readTaskMessages).mockResolvedValue(mockMessages as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-123", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-123", + backgroundTaskMessages: mockMessages, + }) + }) + + it("returns empty array when task has no messages", async () => { + vi.mocked(readTaskMessages).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + text: "task-empty", + }) + + expect(readTaskMessages).toHaveBeenCalledWith({ + taskId: "task-empty", + globalStoragePath: "/mock/global/storage", + }) + + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "backgroundTaskMessages", + backgroundTaskId: "task-empty", + backgroundTaskMessages: [], + }) + }) + + it("does nothing when taskId is not provided", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestBackgroundTaskMessages", + // no text/taskId provided + }) + + expect(readTaskMessages).not.toHaveBeenCalled() + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts new file mode 100644 index 00000000000..a5f8138254d --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts @@ -0,0 +1,52 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.backgroundTaskProgress.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +const mockPostMessageToWebview = vi.fn() + +const mockClineProvider = { + contextProxy: { + globalStorageUri: { fsPath: "/mock/global/storage" }, + getValue: vi.fn(), + setValue: vi.fn(), + }, + postMessageToWebview: mockPostMessageToWebview, + getStateToPostToWebview: vi.fn().mockResolvedValue({}), + viewedBackgroundTaskId: null as string | null, +} as unknown as ClineProvider + +describe("webviewMessageHandler - background task progress subscription", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(mockClineProvider as any).viewedBackgroundTaskId = null + }) + + it("sets viewedBackgroundTaskId on subscribeToBackgroundTask", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + text: "task-456", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBe("task-456") + }) + + it("clears viewedBackgroundTaskId on unsubscribeFromBackgroundTask", async () => { + ;(mockClineProvider as any).viewedBackgroundTaskId = "task-456" + + await webviewMessageHandler(mockClineProvider, { + type: "unsubscribeFromBackgroundTask", + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) + + it("handles subscribeToBackgroundTask with no text gracefully", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "subscribeToBackgroundTask", + // no text + }) + + expect((mockClineProvider as any).viewedBackgroundTaskId).toBeNull() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 434995aa5b3..6783eccfe4d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -24,7 +24,7 @@ import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" +import { saveTaskMessages, readTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" @@ -814,6 +814,28 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We case "showTaskWithId": provider.showTaskWithId(message.text!) break + case "requestBackgroundTaskMessages": { + const taskId = message.text + if (taskId) { + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const messages = await readTaskMessages({ taskId, globalStoragePath }) + await provider.postMessageToWebview({ + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }) + } + break + } + case "subscribeToBackgroundTask": { + const taskId = message.text + provider.viewedBackgroundTaskId = taskId ?? null + break + } + case "unsubscribeFromBackgroundTask": { + provider.viewedBackgroundTaskId = null + break + } case "condenseTaskContextRequest": provider.condenseTaskContext(message.text!) break @@ -1305,11 +1327,6 @@ 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/package.json b/src/package.json index f578357706d..fd1e9c5d452 100644 --- a/src/package.json +++ b/src/package.json @@ -164,6 +164,11 @@ "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.backgroundTasksButtonClicked", + "title": "Background Tasks", + "icon": "$(server-process)" } ], "menus": { @@ -229,9 +234,14 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.backgroundTasksButtonClicked", "group": "overflow@2", "when": "view == roo-cline.SidebarProvider" + }, + { + "command": "roo-cline.popoutButtonClicked", + "group": "overflow@3", + "when": "view == roo-cline.SidebarProvider" } ], "editor/title": [ @@ -256,9 +266,14 @@ "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.backgroundTasksButtonClicked", "group": "overflow@2", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" + }, + { + "command": "roo-cline.popoutButtonClicked", + "group": "overflow@3", + "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" } ] }, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 0b7eb2109c4..0179e1c551b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -107,6 +107,7 @@ export type NativeToolArgs = { list_files: { path: string; recursive?: boolean } new_task: { mode: string; message: string; todos?: string; task_queue?: string; permissions?: string } new_task: { mode: string; message: string; todos?: string; background?: string } + new_task: { mode: string; message: string; todos?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -247,6 +248,7 @@ export interface NewTaskToolUse extends ToolUse<"new_task"> { params: Partial, "mode" | "message" | "todos" | "task_queue">> params: Partial, "mode" | "message" | "todos" | "permissions">> params: Partial, "mode" | "message" | "todos" | "background">> + params: Partial, "mode" | "message" | "todos">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 8b4eddfa9b7..8a1bbe6fca1 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -12,6 +12,8 @@ import ChatView, { ChatViewRef } from "./components/chat/ChatView" import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeViewProvider" +import BackgroundTaskReplayView from "./components/chat/BackgroundTaskReplayView" +import BackgroundTaskView from "./components/chat/BackgroundTaskView" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" @@ -21,6 +23,7 @@ import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" type Tab = "settings" | "history" | "chat" | "cloud" +type Tab = "settings" | "history" | "chat" | "bgTaskReplay" | "bgTask" interface DeleteMessageDialogState { isOpen: boolean @@ -45,6 +48,7 @@ const tabsByMessageAction: Partial { @@ -61,6 +65,7 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const [replayTaskId, setReplayTaskId] = useState(null) const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, @@ -99,6 +104,10 @@ const App = () => { // Handle switchTab action with tab parameter if (message.action === "switchTab" && message.tab) { const targetTab = message.tab as Tab + // If switching to bgTaskReplay, extract taskId from values + if (targetTab === "bgTaskReplay" && message.values?.taskId) { + setReplayTaskId(message.values.taskId as string) + } switchTab(targetTab) // Extract targetSection from values if provided const targetSection = message.values?.section as string | undefined @@ -185,6 +194,16 @@ const App = () => { ) : ( <> + {tab === "bgTaskReplay" && replayTaskId && ( + { + setReplayTaskId(null) + switchTab("chat") + }} + /> + )} + {tab === "bgTask" && switchTab("chat")} />} {tab === "history" && switchTab("chat")} />} {tab === "settings" && ( setTab("chat")} targetSection={currentSection} /> diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index f759d1eb0d8..76353f8a0b0 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -40,6 +40,24 @@ vi.mock("@src/components/history/HistoryView", () => ({ }, })) +vi.mock("@src/components/chat/BackgroundTaskView", () => ({ + __esModule: true, + default: function BackgroundTaskView({ onClose }: { onClose: () => void }) { + return ( +
+ Background Task View +
+ ) + }, +})) + +vi.mock("@src/components/chat/BackgroundTaskReplayView", () => ({ + __esModule: true, + default: function BackgroundTaskReplayView() { + return
Background Task Replay View
+ }, +})) + vi.mock("@src/components/mcp/McpView", () => ({ __esModule: true, default: function McpView() { @@ -212,6 +230,38 @@ describe("App", () => { expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() }) + it("switches to background tasks view when receiving backgroundTasksButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("backgroundTasksButtonClicked") + }) + + const bgTaskView = await screen.findByTestId("background-task-view") + expect(bgTaskView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("returns to chat view when clicking done in background tasks view", async () => { + render() + + act(() => { + triggerMessage("backgroundTasksButtonClicked") + }) + + const bgTaskView = await screen.findByTestId("background-task-view") + + act(() => { + bgTaskView.click() + }) + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId("background-task-view")).not.toBeInTheDocument() + }) + it.each(["history"])("returns to chat view when clicking done in %s view", async (view) => { render() diff --git a/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx new file mode 100644 index 00000000000..89fa9b15b55 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskLiveView.tsx @@ -0,0 +1,152 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Play, CheckCircle2, AlertCircle, Loader2 } from "lucide-react" + +import type { BackgroundTaskUpdate, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +const MAX_UPDATES = 20 + +export interface BackgroundTaskLiveViewProps { + taskId: string + onClose: () => void +} + +function getUpdateIcon(update: BackgroundTaskUpdate) { + if (update.kind === "error") { + return + } + if (update.status === "started") { + return + } + if (update.status === "completed") { + return + } + return +} + +function formatUpdateLabel(update: BackgroundTaskUpdate): string { + const tool = update.toolName ?? "unknown" + if (update.kind === "error") { + return `${tool} -- errored${update.errorMessage ? `: ${update.errorMessage}` : ""}` + } + if (update.kind === "tool_call") { + return `${tool} -- started` + } + if (update.kind === "tool_result") { + return `${tool} -- completed` + } + if (update.kind === "status_change") { + return `Status: ${update.status ?? "unknown"}` + } + return tool +} + +/** + * Compact live view that streams real-time progress updates for an active + * background task. Shows a rolling window of the last 20 tool-call updates + * with status icons. + */ +const BackgroundTaskLiveView = memo(({ taskId, onClose }: BackgroundTaskLiveViewProps) => { + const [updates, setUpdates] = useState([]) + const scrollRef = useRef(null) + + // Subscribe to background task progress on mount, unsubscribe on unmount + useEffect(() => { + vscode.postMessage({ type: "subscribeToBackgroundTask", text: taskId }) + return () => { + vscode.postMessage({ type: "unsubscribeFromBackgroundTask" }) + } + }, [taskId]) + + // Listen for progress updates + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if ( + message.type === "backgroundTaskProgress" && + message.backgroundTaskId === taskId && + message.backgroundTaskProgress + ) { + setUpdates((prev) => { + const next = [...prev, message.backgroundTaskProgress!] + // Keep only the last N updates (rolling window) + if (next.length > MAX_UPDATES) { + return next.slice(next.length - MAX_UPDATES) + } + return next + }) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + // Auto-scroll to bottom when new updates arrive + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [updates]) + + return ( +
+ {/* Header */} +
+ + + Live progress · {updates.length} updates + + +
+ + {/* Update list */} +
+ {updates.length === 0 ? ( +
+ +

+ Waiting for updates from background task... +

+
+ ) : ( +
+ {updates.map((update, index) => ( +
+ {getUpdateIcon(update)} + {formatUpdateLabel(update)} + + {new Date(update.timestamp).toLocaleTimeString()} + +
+ ))} +
+ )} +
+
+ ) +}) + +BackgroundTaskLiveView.displayName = "BackgroundTaskLiveView" + +export default BackgroundTaskLiveView diff --git a/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx b/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx new file mode 100644 index 00000000000..625956bf535 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskReplayView.tsx @@ -0,0 +1,139 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useEvent } from "react-use" +import { ArrowLeft, Loader2 } from "lucide-react" + +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import ChatRow from "./ChatRow" + +export interface BackgroundTaskReplayViewProps { + taskId: string + onClose: () => void +} + +/** + * A read-only view that displays the full message history of a background task. + * This is a thin wrapper around ChatRow components -- it loads messages from disk + * via the extension and renders them without any input controls or approval buttons. + */ +const BackgroundTaskReplayView = memo(({ taskId, onClose }: BackgroundTaskReplayViewProps) => { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedMessages, setExpandedMessages] = useState>(new Set()) + const scrollContainerRef = useRef(null) + + // Request messages from the extension on mount + useEffect(() => { + setLoading(true) + setError(null) + vscode.postMessage({ type: "requestBackgroundTaskMessages", text: taskId }) + }, [taskId]) + + // Listen for the response + const handleMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if (message.type === "backgroundTaskMessages" && message.backgroundTaskId === taskId) { + setMessages(message.backgroundTaskMessages ?? []) + setLoading(false) + } + }, + [taskId], + ) + + useEvent("message", handleMessage) + + const handleToggleExpand = useCallback((ts: number) => { + setExpandedMessages((prev) => { + const next = new Set(prev) + if (next.has(ts)) { + next.delete(ts) + } else { + next.add(ts) + } + return next + }) + }, []) + + if (loading) { + return ( +
+ +

Loading task messages...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + return ( +
+ {/* Header bar */} +
+ + + Task replay (read-only) · {messages.length} messages + +
+ + {/* Message list */} +
+ {messages.length === 0 ? ( +
+

+ No messages found for this task. +

+
+ ) : ( + messages.map((msg, index) => ( + {}} + /> + )) + )} +
+
+ ) +}) + +BackgroundTaskReplayView.displayName = "BackgroundTaskReplayView" + +export default BackgroundTaskReplayView diff --git a/webview-ui/src/components/chat/BackgroundTaskView.tsx b/webview-ui/src/components/chat/BackgroundTaskView.tsx new file mode 100644 index 00000000000..e4b22e3f057 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTaskView.tsx @@ -0,0 +1,83 @@ +import { memo, useCallback, useState } from "react" +import { ArrowLeft } from "lucide-react" + +import { useExtensionState } from "@src/context/ExtensionStateContext" + +import BackgroundTasksList from "./BackgroundTasksList" +import BackgroundTaskReplayView from "./BackgroundTaskReplayView" +import BackgroundTaskLiveView from "./BackgroundTaskLiveView" + +type BackgroundTaskSubView = "list" | "replay" | "live" + +export interface BackgroundTaskViewProps { + onClose: () => void +} + +/** + * Full-tab container for the background tasks feature (Phase 6b/6c). + * Manages navigation between BackgroundTasksList, BackgroundTaskReplayView, + * and BackgroundTaskLiveView. + */ +const BackgroundTaskView = memo(({ onClose }: BackgroundTaskViewProps) => { + const [subView, setSubView] = useState("list") + const [selectedTaskId, setSelectedTaskId] = useState(null) + const { taskHistory } = useExtensionState() + + const handleSelectTask = useCallback( + (taskId: string) => { + setSelectedTaskId(taskId) + // Route to live view for active tasks, replay for completed + const task = taskHistory.find((t) => t.id === taskId) + if (task?.status === "active") { + setSubView("live") + } else { + setSubView("replay") + } + }, + [taskHistory], + ) + + const handleBackToList = useCallback(() => { + setSelectedTaskId(null) + setSubView("list") + }, []) + + return ( +
+ {/* Top header bar -- only shown in list view since replay has its own header */} + {subView === "list" && ( +
+ + Background Tasks +
+ )} + + {/* Sub-view content */} +
+ {subView === "list" && } + {subView === "replay" && selectedTaskId && ( + + )} + {subView === "live" && selectedTaskId && ( + + )} +
+
+ ) +}) + +BackgroundTaskView.displayName = "BackgroundTaskView" + +export default BackgroundTaskView diff --git a/webview-ui/src/components/chat/BackgroundTasksList.tsx b/webview-ui/src/components/chat/BackgroundTasksList.tsx new file mode 100644 index 00000000000..93f05d22dc0 --- /dev/null +++ b/webview-ui/src/components/chat/BackgroundTasksList.tsx @@ -0,0 +1,165 @@ +import { memo, useMemo } from "react" +import { Clock, CheckCircle2, AlertCircle, Play } from "lucide-react" + +import type { HistoryItem } from "@roo-code/types" + +import { useExtensionState } from "@src/context/ExtensionStateContext" + +export interface BackgroundTasksListProps { + onSelectTask: (taskId: string) => void +} + +type TaskStatus = "active" | "completed" | "delegated" | "unknown" + +function getTaskStatus(item: HistoryItem): TaskStatus { + return item.status ?? "unknown" +} + +function getStatusIcon(status: TaskStatus) { + switch (status) { + case "active": + return + case "completed": + return + case "delegated": + return + default: + return + } +} + +function getStatusLabel(status: TaskStatus): string { + switch (status) { + case "active": + return "Running" + case "completed": + return "Completed" + case "delegated": + return "Delegated" + default: + return "Unknown" + } +} + +function formatTimestamp(ts: number): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + + if (diffMins < 1) { + return "just now" + } + if (diffMins < 60) { + return `${diffMins}m ago` + } + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) { + return `${diffHours}h ago` + } + const diffDays = Math.floor(diffHours / 24) + return `${diffDays}d ago` +} + +function truncateTask(task: string, maxLen: number = 80): string { + if (task.length <= maxLen) { + return task + } + return task.slice(0, maxLen) + "..." +} + +/** + * Displays a list of background tasks (subtasks / child tasks) from the task history. + * Each item shows status, task description, mode, and timestamp. + * Clicking a task navigates to its replay view. + */ +const BackgroundTasksList = memo(({ onSelectTask }: BackgroundTasksListProps) => { + const { taskHistory, currentTaskItem } = useExtensionState() + + // Filter to show tasks that have a parentTaskId (i.e., subtasks / background tasks) + // Exclude the current foreground task + const backgroundTasks = useMemo(() => { + return taskHistory + .filter((item) => item.parentTaskId && item.id !== currentTaskItem?.id) + .sort((a, b) => b.ts - a.ts) + }, [taskHistory, currentTaskItem?.id]) + + const activeTasks = useMemo(() => backgroundTasks.filter((t) => t.status === "active"), [backgroundTasks]) + + if (backgroundTasks.length === 0) { + return ( +
+

No background tasks yet.

+

+ Background tasks will appear here when subtasks are spawned via the new_task tool. +

+
+ ) + } + + return ( +
+ {/* Summary header */} +
+ {activeTasks.length > 0 && ( + + + {activeTasks.length} active + + )} + {backgroundTasks.length} total +
+ + {/* Task list */} +
+ {backgroundTasks.map((item) => { + const status = getTaskStatus(item) + return ( + + ) + })} +
+
+ ) +}) + +BackgroundTasksList.displayName = "BackgroundTasksList" + +export default BackgroundTasksList diff --git a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx b/webview-ui/src/components/chat/BackgroundTasksPanel.tsx deleted file mode 100644 index 1830e118662..00000000000 --- a/webview-ui/src/components/chat/BackgroundTasksPanel.tsx +++ /dev/null @@ -1,195 +0,0 @@ -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 76d3dc4b5fc..e14b914c4b6 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1674,7 +1674,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction
- {areButtonsVisible && (
({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +function simulateBackgroundTaskProgress(taskId: string, update: Record) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskProgress", + backgroundTaskId: taskId, + backgroundTaskProgress: update, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskLiveView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("subscribes to background task on mount and unsubscribes on unmount", () => { + const { unmount } = render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "subscribeToBackgroundTask", + text: "task-123", + }) + + unmount() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "unsubscribeFromBackgroundTask", + }) + }) + + it("shows empty state initially", () => { + render() + + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + expect(screen.getByText(/Waiting for updates/)).toBeTruthy() + }) + + it("renders progress updates when received", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(1) + }) + + expect(screen.getByText(/read_file -- started/)).toBeTruthy() + }) + + it("shows update count in header", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + simulateBackgroundTaskProgress("task-123", { + kind: "tool_result", + timestamp: Date.now(), + toolName: "read_file", + status: "completed", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/2 updates/)).toBeTruthy() + }) + }) + + it("ignores progress updates for different task IDs", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-different", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + // Should still show empty state + expect(screen.getByTestId("live-empty-state")).toBeTruthy() + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + // Send an update so the view renders fully + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now(), + toolName: "read_file", + status: "started", + }) + }) + + await waitFor(() => { + expect(screen.getByTestId("live-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("live-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("displays error updates with error message", async () => { + render() + + act(() => { + simulateBackgroundTaskProgress("task-123", { + kind: "error", + timestamp: Date.now(), + toolName: "execute_command", + status: "errored", + errorMessage: "Permission denied", + }) + }) + + await waitFor(() => { + expect(screen.getByText(/execute_command -- errored: Permission denied/)).toBeTruthy() + }) + }) + + it("caps updates at the rolling window size of 20", async () => { + render() + + act(() => { + for (let i = 0; i < 25; i++) { + simulateBackgroundTaskProgress("task-123", { + kind: "tool_call", + timestamp: Date.now() + i, + toolName: `tool_${i}`, + status: "started", + }) + } + }) + + await waitFor(() => { + const items = screen.getAllByTestId("live-update-item") + expect(items).toHaveLength(20) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx new file mode 100644 index 00000000000..0e9b2a19f48 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx @@ -0,0 +1,147 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskReplayView.spec.tsx + +import React from "react" +import { render, screen, act, waitFor } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import BackgroundTaskReplayView from "../BackgroundTaskReplayView" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ChatRow to avoid pulling in heavy dependencies +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + currentTaskItem: null, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function simulateBackgroundTaskMessages(taskId: string, messages: any[]) { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: taskId, + backgroundTaskMessages: messages, + }, + }) + window.dispatchEvent(event) +} + +describe("BackgroundTaskReplayView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("requests messages on mount", () => { + render() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestBackgroundTaskMessages", + text: "task-123", + }) + }) + + it("shows loading state initially", () => { + render() + + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("renders messages when received from extension", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Hello" }, + { ts: 2000, type: "say", say: "text", text: "World" }, + ]) + }) + + await waitFor(() => { + const rows = screen.getAllByTestId("chat-row") + expect(rows).toHaveLength(2) + }) + }) + + it("shows empty state when task has no messages", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-empty", []) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-empty-state")).toBeTruthy() + }) + }) + + it("calls onClose when back button is clicked", async () => { + const onClose = vi.fn() + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [{ ts: 1000, type: "say", say: "text", text: "Hello" }]) + }) + + await waitFor(() => { + expect(screen.getByTestId("replay-back-button")).toBeTruthy() + }) + + act(() => { + screen.getByTestId("replay-back-button").click() + }) + + expect(onClose).toHaveBeenCalled() + }) + + it("ignores messages for a different task ID", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-different", [ + { ts: 1000, type: "say", say: "text", text: "Wrong task" }, + ]) + }) + + // Should still show loading since the task ID didn't match + expect(screen.getByTestId("replay-loading")).toBeTruthy() + }) + + it("shows message count in header after loading", async () => { + render() + + act(() => { + simulateBackgroundTaskMessages("task-123", [ + { ts: 1000, type: "say", say: "text", text: "Msg 1" }, + { ts: 2000, type: "say", say: "text", text: "Msg 2" }, + { ts: 3000, type: "say", say: "text", text: "Msg 3" }, + ]) + }) + + await waitFor(() => { + expect(screen.getByText(/3 messages/)).toBeTruthy() + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx new file mode 100644 index 00000000000..fbb166d4389 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTaskView.spec.tsx @@ -0,0 +1,176 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTaskView.spec.tsx + +import React from "react" +import { render, screen, fireEvent, act } from "@/utils/test-utils" + +import BackgroundTaskView from "../BackgroundTaskView" + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn().mockReturnValue({ + taskHistory: [ + { + id: "bg-task-1", + number: 1, + ts: Date.now() - 60000, + task: "Research API docs", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.001, + parentTaskId: "parent-1", + status: "completed", + mode: "ask", + }, + { + id: "bg-task-2", + number: 2, + ts: Date.now(), + task: "Implement feature", + tokensIn: 200, + tokensOut: 100, + totalCost: 0.002, + parentTaskId: "parent-1", + status: "active", + mode: "code", + }, + ], + currentTaskItem: null, + clineMessages: [], + mcpServers: [], + mode: "code", + apiConfiguration: {}, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// Mock ChatRow for BackgroundTaskReplayView +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: { ts: number; text?: string } }) { + return
{message.text ?? "message"}
+ }, +})) + +// Mock BackgroundTaskLiveView +vi.mock("../BackgroundTaskLiveView", () => ({ + default: function MockBackgroundTaskLiveView({ taskId, onClose }: { taskId: string; onClose: () => void }) { + return ( +
+ + Live view for {taskId} +
+ ) + }, +})) + +describe("BackgroundTaskView", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the list view by default", () => { + render() + + expect(screen.getByTestId("background-task-view")).toBeTruthy() + expect(screen.getByTestId("background-task-view-header")).toBeTruthy() + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + expect(screen.getByText("Background Tasks")).toBeTruthy() + }) + + it("calls onClose when back-to-chat button is clicked", () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId("background-task-view-back")) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it("navigates to replay view when a task is clicked", () => { + render() + + // Click on a task to open replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + + // Should now show the replay view (in loading state), not the list + expect(screen.getByTestId("replay-loading")).toBeTruthy() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from replay view via back button", () => { + render() + + // Navigate to replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + expect(screen.getByTestId("replay-loading")).toBeTruthy() + + // Simulate messages arriving so replay-back-button appears + act(() => { + const event = new MessageEvent("message", { + data: { + type: "backgroundTaskMessages", + backgroundTaskId: "bg-task-1", + backgroundTaskMessages: [{ ts: 1000, type: "say", say: "text", text: "Hello" }], + }, + }) + window.dispatchEvent(event) + }) + + // Click back button in replay view + fireEvent.click(screen.getByTestId("replay-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) + + it("hides the top header when in replay view (replay has its own header)", () => { + render() + + // Header should be visible in list view + expect(screen.getByTestId("background-task-view-header")).toBeTruthy() + + // Navigate to replay + fireEvent.click(screen.getByTestId("background-task-item-bg-task-1")) + + // Top header should be hidden -- replay has its own back button + expect(screen.queryByTestId("background-task-view-header")).toBeNull() + }) + + it("navigates to live view when an active task is clicked", () => { + render() + + // Click on the active task (bg-task-2 has status "active") + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + + // Should show the live view, not the replay view + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + expect(screen.queryByTestId("replay-loading")).toBeNull() + expect(screen.queryByTestId("background-tasks-list")).toBeNull() + }) + + it("navigates back to list from live view via back button", () => { + render() + + // Navigate to live view for active task + fireEvent.click(screen.getByTestId("background-task-item-bg-task-2")) + expect(screen.getByTestId("background-task-live-view")).toBeTruthy() + + // Click back button in live view + fireEvent.click(screen.getByTestId("live-back-button")) + + // Should return to list view + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx new file mode 100644 index 00000000000..2778a8d6d77 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BackgroundTasksList.spec.tsx @@ -0,0 +1,153 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/BackgroundTasksList.spec.tsx + +import React from "react" +import { render, screen, fireEvent } from "@/utils/test-utils" + +import BackgroundTasksList from "../BackgroundTasksList" + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +const mockUseExtensionState = vi.fn() + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: (...args: any[]) => mockUseExtensionState(...args), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +function createHistoryItem(overrides: Record = {}) { + return { + id: "task-1", + number: 1, + ts: Date.now(), + task: "Test background task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.001, + parentTaskId: "parent-1", + status: "completed" as const, + mode: "code", + ...overrides, + } +} + +describe("BackgroundTasksList", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ + taskHistory: [], + currentTaskItem: null, + }) + }) + + it("shows empty state when no background tasks exist", () => { + render() + + expect(screen.getByTestId("background-tasks-empty")).toBeTruthy() + expect(screen.getByText(/No background tasks yet/)).toBeTruthy() + }) + + it("shows tasks that have a parentTaskId", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", task: "Background task one", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-2", task: "Foreground task (no parent)", parentTaskId: undefined }), + createHistoryItem({ id: "task-3", task: "Background task two", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByTestId("background-tasks-list")).toBeTruthy() + expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy() + expect(screen.getByTestId("background-task-item-task-3")).toBeTruthy() + // Foreground task without parentTaskId should NOT appear + expect(screen.queryByTestId("background-task-item-task-2")).toBeNull() + }) + + it("excludes the current foreground task from the list", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", task: "Background subtask", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-current", task: "Current task", parentTaskId: "parent-1" }), + ], + currentTaskItem: { id: "task-current" }, + }) + + render() + + expect(screen.getByTestId("background-task-item-task-1")).toBeTruthy() + expect(screen.queryByTestId("background-task-item-task-current")).toBeNull() + }) + + it("calls onSelectTask when a task item is clicked", () => { + const onSelectTask = vi.fn() + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", task: "Click me", parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + fireEvent.click(screen.getByTestId("background-task-item-task-1")) + expect(onSelectTask).toHaveBeenCalledWith("task-1") + }) + + it("shows task status badges", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-active", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-done", status: "completed", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("Running")).toBeTruthy() + expect(screen.getByText("Completed")).toBeTruthy() + }) + + it("shows active count in summary header", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [ + createHistoryItem({ id: "task-1", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-2", status: "active", parentTaskId: "parent-1" }), + createHistoryItem({ id: "task-3", status: "completed", parentTaskId: "parent-1" }), + ], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("2 active")).toBeTruthy() + expect(screen.getByText("3 total")).toBeTruthy() + }) + + it("shows mode badge when task has a mode", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", mode: "architect", parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + expect(screen.getByText("architect")).toBeTruthy() + }) + + it("truncates long task descriptions", () => { + const longTask = "A".repeat(100) + mockUseExtensionState.mockReturnValue({ + taskHistory: [createHistoryItem({ id: "task-1", task: longTask, parentTaskId: "parent-1" })], + currentTaskItem: null, + }) + + render() + + // Should be truncated at 80 chars + "..." + expect(screen.getByText("A".repeat(80) + "...")).toBeTruthy() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx b/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx deleted file mode 100644 index 45d370ad096..00000000000 --- a/webview-ui/src/components/chat/__tests__/BackgroundTasksPanel.spec.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react" - -import { vscode } from "@src/utils/vscode" - -import type { BackgroundTaskStatusInfo } from "@roo-code/types" - -// Mock vscode -vi.mock("@src/utils/vscode", () => ({ - 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() - }) -})