From 09a5570b587bf35774addfea64571795e0a51100 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 02:46:26 +0000 Subject: [PATCH 01/24] feat: enrich subtask handoff with structured context summaries Phase 1 of #12330 - improves context handoff visibility between parent and child tasks during delegation. Changes: - Add SubtaskSummary type to @roo-code/types for structured handoff data - Create buildSubtaskSummary utility that extracts files modified/read, commands executed, tool usage, and todo stats from task history - Modify AttemptCompletionTool to build structured summary on completion - Update reopenParentFromDelegation to format enriched API history text so the parent LLM gets better context about what the subtask did - Update ChatRow UI to render structured summaries with mode badge, file lists, command lists, and todo progress - Add i18n translation keys for new UI elements - Add 19 tests for buildSubtaskSummary and formatSubtaskSummaryForApi - Backward compatible: plain-text summaries still work as before --- packages/types/src/history.ts | 31 ++ .../__tests__/buildSubtaskSummary.spec.ts | 308 ++++++++++++++++++ src/core/task/buildSubtaskSummary.ts | 189 +++++++++++ src/core/tools/AttemptCompletionTool.ts | 24 +- src/core/webview/ClineProvider.ts | 25 +- webview-ui/src/components/chat/ChatRow.tsx | 84 ++++- webview-ui/src/i18n/locales/en/chat.json | 6 +- 7 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 src/core/task/__tests__/buildSubtaskSummary.spec.ts create mode 100644 src/core/task/buildSubtaskSummary.ts diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index a60d1a75b65..302fbe3454c 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -29,3 +29,34 @@ export const historyItemSchema = z.object({ }) export type HistoryItem = z.infer + +/** + * SubtaskSummary + * + * Structured metadata produced when a subtask completes via attempt_completion + * and hands off context back to its parent task. This enriches the handoff + * with visibility into what the subtask actually did. + */ +export const subtaskSummarySchema = z.object({ + /** The completion result text from attempt_completion */ + result: z.string(), + /** Mode slug the subtask ran in (e.g. "code", "architect") */ + mode: z.string().optional(), + /** Files that were created or modified (write_to_file, apply_diff, insert_content) */ + filesModified: z.array(z.string()).optional(), + /** Files that were read during the subtask */ + filesRead: z.array(z.string()).optional(), + /** Shell commands that were executed */ + commandsExecuted: z.array(z.string()).optional(), + /** Summary of tool usage counts: tool name -> number of attempts */ + toolUsageSummary: z.record(z.string(), z.number()).optional(), + /** Todo list status at completion: [completed, total] */ + todoStats: z + .object({ + completed: z.number(), + total: z.number(), + }) + .optional(), +}) + +export type SubtaskSummary = z.infer diff --git a/src/core/task/__tests__/buildSubtaskSummary.spec.ts b/src/core/task/__tests__/buildSubtaskSummary.spec.ts new file mode 100644 index 00000000000..055c8f556b5 --- /dev/null +++ b/src/core/task/__tests__/buildSubtaskSummary.spec.ts @@ -0,0 +1,308 @@ +import { buildSubtaskSummary, formatSubtaskSummaryForApi, type SubtaskContext } from "../buildSubtaskSummary" + +function createContext(overrides: Partial = {}): SubtaskContext { + return { + apiConversationHistory: [], + toolUsage: {}, + todoList: undefined, + taskMode: "code", + ...overrides, + } +} + +describe("buildSubtaskSummary", () => { + it("should return a minimal summary with just result and mode", () => { + const context = createContext() + const summary = buildSubtaskSummary(context, "Task completed successfully") + + expect(summary.result).toBe("Task completed successfully") + expect(summary.mode).toBe("code") + expect(summary.filesModified).toBeUndefined() + expect(summary.filesRead).toBeUndefined() + expect(summary.commandsExecuted).toBeUndefined() + expect(summary.toolUsageSummary).toBeUndefined() + expect(summary.todoStats).toBeUndefined() + }) + + it("should extract files modified from write_to_file tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "write_to_file", + input: { path: "src/index.ts", content: "hello" }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toEqual(["src/index.ts"]) + }) + + it("should extract files modified from apply_diff tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_2", + name: "apply_diff", + input: { path: "src/utils.ts", diff: "--- a\n+++ b" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toEqual(["src/utils.ts"]) + }) + + it("should extract files read from read_file tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_3", + name: "read_file", + input: { path: "package.json" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesRead).toEqual(["package.json"]) + }) + + it("should extract commands from execute_command tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_4", + name: "execute_command", + input: { command: "npm test" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.commandsExecuted).toEqual(["npm test"]) + }) + + it("should truncate very long commands", () => { + const longCmd = "a".repeat(200) + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_5", + name: "execute_command", + input: { command: longCmd }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.commandsExecuted![0].length).toBeLessThanOrEqual(120) + expect(summary.commandsExecuted![0].endsWith("...")).toBe(true) + }) + + it("should deduplicate modified files", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_6", + name: "write_to_file", + input: { path: "src/index.ts", content: "v1" }, + }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_6", content: "ok" }] }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_7", + name: "apply_diff", + input: { path: "src/index.ts", diff: "diff" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toEqual(["src/index.ts"]) + }) + + it("should include tool usage summary from toolUsage", () => { + const context = createContext({ + toolUsage: { + write_to_file: { attempts: 3, failures: 0 }, + read_file: { attempts: 5, failures: 1 }, + } as any, + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.toolUsageSummary).toEqual({ + write_to_file: 3, + read_file: 5, + }) + }) + + it("should include todo stats when todoList is present", () => { + const context = createContext({ + todoList: [ + { id: "1", task: "Do A", status: "completed" }, + { id: "2", task: "Do B", status: "completed" }, + { id: "3", task: "Do C", status: "pending" }, + ] as any, + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.todoStats).toEqual({ completed: 2, total: 3 }) + }) + + it("should skip user messages when scanning for tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "user", + content: [ + { + type: "tool_result" as any, + tool_use_id: "toolu_x", + content: "ok", + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toBeUndefined() + expect(summary.commandsExecuted).toBeUndefined() + }) + + it("should handle empty conversation history", () => { + const context = createContext({ apiConversationHistory: [] }) + const summary = buildSubtaskSummary(context, "Nothing happened") + + expect(summary.result).toBe("Nothing happened") + expect(summary.mode).toBe("code") + }) + + it("should handle messages with non-array content (string content)", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: "Just text response", + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toBeUndefined() + }) +}) + +describe("formatSubtaskSummaryForApi", () => { + it("should format a minimal summary", () => { + const text = formatSubtaskSummaryForApi({ result: "All done" }) + expect(text).toContain("## Result\nAll done") + }) + + it("should include mode section", () => { + const text = formatSubtaskSummaryForApi({ result: "Done", mode: "architect" }) + expect(text).toContain("## Mode\narchitect") + }) + + it("should include files modified section", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + filesModified: ["src/a.ts", "src/b.ts"], + }) + expect(text).toContain("## Files Modified") + expect(text).toContain("- src/a.ts") + expect(text).toContain("- src/b.ts") + }) + + it("should include files read section", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + filesRead: ["package.json"], + }) + expect(text).toContain("## Files Read") + expect(text).toContain("- package.json") + }) + + it("should include commands section", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + commandsExecuted: ["npm test", "npm build"], + }) + expect(text).toContain("## Commands Executed") + expect(text).toContain("- `npm test`") + expect(text).toContain("- `npm build`") + }) + + it("should include todo stats", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + todoStats: { completed: 3, total: 5 }, + }) + expect(text).toContain("## Todos\n3/5 completed") + }) + + it("should format a comprehensive summary with all sections", () => { + const text = formatSubtaskSummaryForApi({ + result: "Implemented the feature", + mode: "code", + filesModified: ["src/feature.ts"], + filesRead: ["src/config.ts"], + commandsExecuted: ["npm test"], + todoStats: { completed: 2, total: 2 }, + }) + + expect(text).toContain("## Result") + expect(text).toContain("## Mode") + expect(text).toContain("## Files Modified") + expect(text).toContain("## Files Read") + expect(text).toContain("## Commands Executed") + expect(text).toContain("## Todos") + }) +}) diff --git a/src/core/task/buildSubtaskSummary.ts b/src/core/task/buildSubtaskSummary.ts new file mode 100644 index 00000000000..00ecc38cf8d --- /dev/null +++ b/src/core/task/buildSubtaskSummary.ts @@ -0,0 +1,189 @@ +import type { SubtaskSummary } from "@roo-code/types" +import type { ToolUsage } from "@roo-code/types" +import type { TodoItem } from "@roo-code/types" +import type Anthropic from "@anthropic-ai/sdk" + +/** + * File-modifying tool names. When these appear as tool_use blocks in the + * API conversation history, the first positional argument (typically `path`) + * is extracted as a modified file. + */ +const FILE_WRITE_TOOLS = new Set(["write_to_file", "apply_diff", "insert_content"]) + +/** + * File-reading tool names. + */ +const FILE_READ_TOOLS = new Set(["read_file", "search_files", "list_files", "list_code_definition_names"]) + +/** + * Extract a file path from a tool_use input object. + * Native tool calls store params as structured objects with a `path` field. + */ +function extractPath(input: Record): string | undefined { + if (typeof input.path === "string" && input.path.length > 0) { + return input.path + } + return undefined +} + +/** + * Extract a command string from a tool_use input for execute_command. + */ +function extractCommand(input: Record): string | undefined { + if (typeof input.command === "string" && input.command.length > 0) { + const cmd = input.command + return cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd + } + return undefined +} + +/** + * Minimal interface representing the data we need from a Task instance. + * Using an interface avoids importing the full Task class (circular deps). + */ +export interface SubtaskContext { + apiConversationHistory: Anthropic.MessageParam[] + toolUsage: ToolUsage + todoList?: TodoItem[] + taskMode: string +} + +/** + * Builds a structured SubtaskSummary from task context. + * + * This scans the task's API conversation history to extract: + * - Files modified (write_to_file, apply_diff, insert_content) + * - Files read (read_file, search_files, etc.) + * - Commands executed (execute_command) + * - Tool usage summary (from toolUsage) + * - Todo completion stats (from todoList) + * + * The result text comes from attempt_completion and is passed in separately. + */ +export function buildSubtaskSummary(context: SubtaskContext, completionResult: string): SubtaskSummary { + const filesModified = new Set() + const filesRead = new Set() + const commandsExecuted: string[] = [] + + // Scan API conversation history for tool_use blocks + for (const message of context.apiConversationHistory) { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + continue + } + + for (const block of message.content as Anthropic.ContentBlockParam[]) { + if (block.type !== "tool_use") { + continue + } + + const toolBlock = block as Anthropic.ToolUseBlockParam + const toolName = toolBlock.name + const input = (toolBlock.input ?? {}) as Record + + if (FILE_WRITE_TOOLS.has(toolName)) { + const path = extractPath(input) + if (path) { + filesModified.add(path) + } + } else if (FILE_READ_TOOLS.has(toolName)) { + const path = extractPath(input) + if (path) { + filesRead.add(path) + } + } else if (toolName === "execute_command") { + const cmd = extractCommand(input) + if (cmd) { + commandsExecuted.push(cmd) + } + } + } + } + + // Build tool usage summary from toolUsage + const toolUsageSummary: Record = {} + if (context.toolUsage) { + for (const [toolName, usage] of Object.entries(context.toolUsage)) { + const u = usage as { attempts: number; failures: number } | undefined + if (u && u.attempts > 0) { + toolUsageSummary[toolName] = u.attempts + } + } + } + + // Build todo stats + let todoStats: SubtaskSummary["todoStats"] + if (context.todoList && context.todoList.length > 0) { + const completed = context.todoList.filter((t: TodoItem) => t.status === "completed").length + todoStats = { completed, total: context.todoList.length } + } + + const summary: SubtaskSummary = { + result: completionResult, + mode: context.taskMode, + } + + if (filesModified.size > 0) { + summary.filesModified = Array.from(filesModified) + } + + if (filesRead.size > 0) { + summary.filesRead = Array.from(filesRead) + } + + if (commandsExecuted.length > 0) { + summary.commandsExecuted = commandsExecuted + } + + if (Object.keys(toolUsageSummary).length > 0) { + summary.toolUsageSummary = toolUsageSummary + } + + if (todoStats) { + summary.todoStats = todoStats + } + + return summary +} + +/** + * Formats a SubtaskSummary into a human-readable string suitable for + * injection into the parent's API history (tool_result content). + * This enriched format gives the parent LLM much better context about + * what the subtask accomplished. + */ +export function formatSubtaskSummaryForApi(summary: SubtaskSummary): string { + const sections: string[] = [] + + // Result section (always present) + sections.push(`## Result\n${summary.result}`) + + // Mode + if (summary.mode) { + sections.push(`## Mode\n${summary.mode}`) + } + + // Files modified + if (summary.filesModified && summary.filesModified.length > 0) { + const fileList = summary.filesModified.map((f: string) => `- ${f}`).join("\n") + sections.push(`## Files Modified\n${fileList}`) + } + + // Files read + if (summary.filesRead && summary.filesRead.length > 0) { + const fileList = summary.filesRead.map((f: string) => `- ${f}`).join("\n") + sections.push(`## Files Read\n${fileList}`) + } + + // Commands executed + if (summary.commandsExecuted && summary.commandsExecuted.length > 0) { + const cmdList = summary.commandsExecuted.map((c: string) => `- \`${c}\``).join("\n") + sections.push(`## Commands Executed\n${cmdList}`) + } + + // Todo stats + if (summary.todoStats) { + sections.push(`## Todos\n${summary.todoStats.completed}/${summary.todoStats.total} completed`) + } + + return sections.join("\n\n") +} diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 16e0428120c..7a024735f13 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -7,6 +7,7 @@ import { formatResponse } from "../prompts/responses" import { Package } from "../../shared/package" import type { ToolUse } from "../../shared/tools" import { t } from "../../i18n" +import { buildSubtaskSummary } from "../task/buildSubtaskSummary" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -168,10 +169,31 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { pushToolResult("") + // Build a structured summary of what this subtask accomplished. + // This enriches the handoff with files changed, tools used, etc. + // Wrapped in try/catch: if summary building fails (e.g. mode not initialized), + // we fall back to the plain result string for backward compatibility. + let completionResultSummary: string + try { + const summary = buildSubtaskSummary( + { + apiConversationHistory: task.apiConversationHistory, + toolUsage: task.toolUsage, + todoList: task.todoList ?? undefined, + taskMode: task.taskMode, + }, + result, + ) + completionResultSummary = JSON.stringify(summary) + } catch { + // Fallback: use plain result text if structured summary cannot be built + completionResultSummary = result + } + await provider.reopenParentFromDelegation({ parentTaskId: task.parentTaskId!, childTaskId: task.taskId, - completionResultSummary: result, + completionResultSummary, }) return "delegated" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5430f959d05..c1b086a6a1a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -92,6 +92,8 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" +import { formatSubtaskSummaryForApi } from "../task/buildSubtaskSummary" +import type { SubtaskSummary } from "@roo-code/types" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -3183,6 +3185,21 @@ export class ClineProvider if (!Array.isArray(parentClineMessages)) parentClineMessages = [] if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + // Try to parse completionResultSummary as a structured SubtaskSummary (JSON). + // If it's not valid JSON, treat it as a plain-text result for backward compatibility. + let parsedSummary: SubtaskSummary | undefined + let apiResultText: string + try { + parsedSummary = JSON.parse(completionResultSummary) as SubtaskSummary + // Use the enriched format for API history so the parent LLM gets structured context + apiResultText = `Subtask ${childTaskId} completed.\n\n${formatSubtaskSummaryForApi(parsedSummary)}` + } catch { + // Not JSON - plain text result (backward compatible path) + apiResultText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + } + + // For the UI message, pass the raw completionResultSummary (JSON or plain text). + // The webview ChatRow component will detect JSON and render structured data. const subtaskUiMessage: ClineMessage = { type: "say", say: "subtask_result", @@ -3218,8 +3235,8 @@ export class ClineProvider if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { for (const block of lastMsg.content) { if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + // Update the existing tool_result content with enriched summary + block.content = apiResultText alreadyHasToolResult = true break } @@ -3234,7 +3251,7 @@ export class ClineProvider { type: "tool_result" as const, tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + content: apiResultText, }, ], ts, @@ -3257,7 +3274,7 @@ export class ClineProvider content: [ { type: "text" as const, - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + text: apiResultText, }, ], ts, diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 33c9acb2df2..ec02fda04cd 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1020,16 +1020,95 @@ export const ChatRowContent = ({ showCopyButton={true} /> ) - case "subtask_result": + case "subtask_result": { // Get the child task ID that produced this result const completedChildTaskId = currentTaskItem?.completedByChildId + + // Try to parse structured summary (JSON). Falls back to plain text. + let structuredSummary: { + result?: string + mode?: string + filesModified?: string[] + filesRead?: string[] + commandsExecuted?: string[] + toolUsageSummary?: Record + todoStats?: { completed: number; total: number } + } | null = null + try { + if (message.text?.startsWith("{")) { + structuredSummary = JSON.parse(message.text) + } + } catch { + // Not JSON, use plain text rendering + } + return (
{t("chat:subtasks.resultContent")}
- + + {structuredSummary ? ( +
+ {structuredSummary.mode && ( +
+ + {structuredSummary.mode} + +
+ )} + + {structuredSummary.result && } + + {structuredSummary.filesModified && structuredSummary.filesModified.length > 0 && ( +
+
+ {t("chat:subtasks.filesModified")} +
+
    + {structuredSummary.filesModified.map((f: string, i: number) => ( +
  • + {f} +
  • + ))} +
+
+ )} + + {structuredSummary.commandsExecuted && + structuredSummary.commandsExecuted.length > 0 && ( +
+
+ {t("chat:subtasks.commandsExecuted")} +
+
    + {structuredSummary.commandsExecuted.map((c: string, i: number) => ( +
  • + {c} +
  • + ))} +
+
+ )} + + {structuredSummary.todoStats && ( +
+ {t("chat:subtasks.todoStats", { + completed: structuredSummary.todoStats.completed, + total: structuredSummary.todoStats.total, + })} +
+ )} +
+ ) : ( + + )} + {completedChildTaskId && (
+ {tool.permissions && ( +
+
{t("chat:subtasks.permissionBoundaries")}
+ {tool.permissions.filePatterns && ( +
+ {t("chat:subtasks.permissionFilePatterns", { + patterns: tool.permissions.filePatterns.join(", "), + })} +
+ )} + {tool.permissions.commandPatterns && ( +
+ {t("chat:subtasks.permissionCommandPatterns", { + patterns: tool.permissions.commandPatterns.join(", "), + })} +
+ )} + {tool.permissions.allowedTools && ( +
+ {t("chat:subtasks.permissionAllowedTools", { + tools: tool.permissions.allowedTools.join(", "), + })} +
+ )} + {tool.permissions.deniedTools && ( +
+ {t("chat:subtasks.permissionDeniedTools", { + tools: tool.permissions.deniedTools.join(", "), + })} +
+ )} +
+ )}
{childTaskId && !isFollowedBySubtaskResult && ( + )} + {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 17/24] 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 18/24] 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() - }) -}) From 9ab2c19d4b6489b6375df204cc0b5c371873aeb6 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 15:21:03 +0000 Subject: [PATCH 19/24] feat: implement Phase 7a File Lock Manager for concurrent task write coordination Advisory file-level locking so concurrent tasks coordinate write access safely. - FileLockManager class with acquire/release/releaseAll operations - Per-file locks with automatic expiration (configurable timeout, default 2min) - Re-entrant lock support (same task can re-acquire its own lock) - Lock conflict detection with detailed conflict info - Event system for lock lifecycle (acquired/released/expired) - Reverse index for efficient per-task lock lookup - Path normalization for consistent lock keys - 39 comprehensive unit tests covering all operations Part of Phase 7 (Controlled Write Parallelism) for Issue #12330 --- src/services/file-lock/FileLockManager.ts | 348 ++++++++++++++++ .../__tests__/FileLockManager.spec.ts | 389 ++++++++++++++++++ src/services/file-lock/index.ts | 8 + 3 files changed, 745 insertions(+) create mode 100644 src/services/file-lock/FileLockManager.ts create mode 100644 src/services/file-lock/__tests__/FileLockManager.spec.ts create mode 100644 src/services/file-lock/index.ts diff --git a/src/services/file-lock/FileLockManager.ts b/src/services/file-lock/FileLockManager.ts new file mode 100644 index 00000000000..22ea20bbdfc --- /dev/null +++ b/src/services/file-lock/FileLockManager.ts @@ -0,0 +1,348 @@ +import path from "path" + +/** + * Information about a file lock held by a task. + */ +export interface FileLockInfo { + /** Absolute normalized path of the locked file */ + filePath: string + /** ID of the task holding the lock */ + taskId: string + /** Timestamp (ms) when the lock was acquired */ + acquiredAt: number +} + +/** + * Result returned when a lock acquisition attempt fails. + */ +export interface LockConflict { + /** The file that is already locked */ + filePath: string + /** The task that currently holds the lock */ + holdingTaskId: string + /** How long (ms) the lock has been held */ + heldForMs: number +} + +/** + * Events emitted by the FileLockManager. + */ +export type FileLockEvent = + | { type: "lock-acquired"; filePath: string; taskId: string } + | { type: "lock-released"; filePath: string; taskId: string } + | { type: "lock-expired"; filePath: string; taskId: string } + | { type: "all-locks-released"; taskId: string; count: number } + +export type FileLockEventListener = (event: FileLockEvent) => void + +export interface FileLockManagerOptions { + /** + * Maximum duration (ms) a lock can be held before it is forcibly released. + * Default: 120_000 (2 minutes). + */ + lockTimeoutMs?: number +} + +const DEFAULT_LOCK_TIMEOUT_MS = 120_000 + +/** + * Advisory file-level lock manager for coordinating writes across concurrent tasks. + * + * Locks are "advisory" -- they do not use OS-level file locks. Instead, the + * tool execution layer checks the lock manager before allowing write operations. + * This keeps the system portable and testable. + * + * All file paths are normalized to absolute paths using `path.resolve` before + * being used as map keys, ensuring consistent lookup regardless of how the + * path is specified (relative, absolute, trailing slashes, etc.). + */ +export class FileLockManager { + /** + * Map from normalized absolute file path to lock info. + */ + private locks = new Map() + + /** + * Reverse index: taskId -> set of normalized file paths locked by that task. + */ + private taskLocks = new Map>() + + /** + * Event listeners. + */ + private listeners: FileLockEventListener[] = [] + + /** + * Maximum lock hold duration in milliseconds. + */ + private readonly lockTimeoutMs: number + + constructor(options?: FileLockManagerOptions) { + this.lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS + } + + /** + * Attempt to acquire a write lock on a file for a specific task. + * + * If the file is already locked by the same task, refreshes the timestamp + * and returns true (re-entrant). If locked by a different task, checks + * for expiration first -- if the existing lock has expired it is forcibly + * released before granting the new lock. + * + * @returns `true` if the lock was acquired, `false` if another task holds it. + */ + acquireLock(filePath: string, taskId: string): boolean { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (existing) { + // Re-entrant: same task already holds the lock -- refresh timestamp + if (existing.taskId === taskId) { + existing.acquiredAt = Date.now() + return true + } + + // Check if the existing lock has expired + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + } else { + return false + } + } + + // Acquire the lock + const lockInfo: FileLockInfo = { + filePath: normalized, + taskId, + acquiredAt: Date.now(), + } + + this.locks.set(normalized, lockInfo) + + let taskSet = this.taskLocks.get(taskId) + if (!taskSet) { + taskSet = new Set() + this.taskLocks.set(taskId, taskSet) + } + taskSet.add(normalized) + + this.emit({ type: "lock-acquired", filePath: normalized, taskId }) + return true + } + + /** + * Release a lock held by a specific task. + * No-op if the task does not hold the lock. + */ + releaseLock(filePath: string, taskId: string): void { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing || existing.taskId !== taskId) { + return + } + + this.locks.delete(normalized) + + const taskSet = this.taskLocks.get(taskId) + if (taskSet) { + taskSet.delete(normalized) + if (taskSet.size === 0) { + this.taskLocks.delete(taskId) + } + } + + this.emit({ type: "lock-released", filePath: normalized, taskId }) + } + + /** + * Release all locks held by a specific task. + * Called when a task completes, is cancelled, or errors out. + */ + releaseAllLocks(taskId: string): void { + const taskSet = this.taskLocks.get(taskId) + if (!taskSet || taskSet.size === 0) { + this.taskLocks.delete(taskId) + return + } + + const count = taskSet.size + + for (const normalized of taskSet) { + this.locks.delete(normalized) + } + + this.taskLocks.delete(taskId) + + this.emit({ type: "all-locks-released", taskId, count }) + } + + /** + * Check which task (if any) holds the lock on a file. + * Checks for expiration -- if the lock is expired, it is released and + * `undefined` is returned. + * + * @returns The taskId of the lock holder, or `undefined` if unlocked. + */ + getLockHolder(filePath: string): string | undefined { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing) { + return undefined + } + + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + return undefined + } + + return existing.taskId + } + + /** + * Get detailed lock info for a file, or undefined if not locked. + * Checks for expiration. + */ + getLockInfo(filePath: string): FileLockInfo | undefined { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing) { + return undefined + } + + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + return undefined + } + + return { ...existing } + } + + /** + * Get the conflict details when a lock acquisition would fail. + * Returns undefined if the file is not locked by another task. + */ + getLockConflict(filePath: string, taskId: string): LockConflict | undefined { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing || existing.taskId === taskId) { + return undefined + } + + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + return undefined + } + + return { + filePath: normalized, + holdingTaskId: existing.taskId, + heldForMs: Date.now() - existing.acquiredAt, + } + } + + /** + * List all files currently locked by a specific task. + */ + getLockedFiles(taskId: string): string[] { + const taskSet = this.taskLocks.get(taskId) + if (!taskSet) { + return [] + } + return Array.from(taskSet) + } + + /** + * Get all currently held locks. Primarily for debugging/UI display. + * Expired locks are cleaned up during this call. + */ + getAllLocks(): FileLockInfo[] { + const result: FileLockInfo[] = [] + const expired: Array<{ normalized: string; taskId: string }> = [] + + for (const [normalized, info] of this.locks) { + if (this.isLockExpired(info)) { + expired.push({ normalized, taskId: info.taskId }) + } else { + result.push({ ...info }) + } + } + + // Clean up expired locks + for (const { normalized, taskId } of expired) { + this.forceReleaseLock(normalized, taskId) + } + + return result + } + + /** + * Get the total number of active locks. + */ + get lockCount(): number { + return this.locks.size + } + + /** + * Register an event listener. + */ + onEvent(listener: FileLockEventListener): void { + this.listeners.push(listener) + } + + /** + * Remove an event listener. + */ + offEvent(listener: FileLockEventListener): void { + const idx = this.listeners.indexOf(listener) + if (idx !== -1) { + this.listeners.splice(idx, 1) + } + } + + /** + * Clear all locks and listeners. Primarily for testing. + */ + dispose(): void { + this.locks.clear() + this.taskLocks.clear() + this.listeners = [] + } + + // --- Private helpers --- + + private normalizePath(filePath: string): string { + return path.resolve(filePath) + } + + private isLockExpired(info: FileLockInfo): boolean { + return Date.now() - info.acquiredAt > this.lockTimeoutMs + } + + private forceReleaseLock(normalized: string, taskId: string): void { + this.locks.delete(normalized) + + const taskSet = this.taskLocks.get(taskId) + if (taskSet) { + taskSet.delete(normalized) + if (taskSet.size === 0) { + this.taskLocks.delete(taskId) + } + } + + this.emit({ type: "lock-expired", filePath: normalized, taskId }) + } + + private emit(event: FileLockEvent): void { + for (const listener of this.listeners) { + try { + listener(event) + } catch { + // Swallow listener errors to avoid breaking lock operations + } + } + } +} diff --git a/src/services/file-lock/__tests__/FileLockManager.spec.ts b/src/services/file-lock/__tests__/FileLockManager.spec.ts new file mode 100644 index 00000000000..d00d66e5d74 --- /dev/null +++ b/src/services/file-lock/__tests__/FileLockManager.spec.ts @@ -0,0 +1,389 @@ +import path from "path" +import { FileLockManager, type FileLockEvent } from "../FileLockManager" + +describe("FileLockManager", () => { + let manager: FileLockManager + + beforeEach(() => { + manager = new FileLockManager() + }) + + afterEach(() => { + manager.dispose() + }) + + describe("acquireLock", () => { + it("should acquire a lock on an unlocked file", () => { + const result = manager.acquireLock("/project/foo.ts", "task-1") + expect(result).toBe(true) + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should allow the same task to re-acquire a lock (re-entrant)", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const result = manager.acquireLock("/project/foo.ts", "task-1") + expect(result).toBe(true) + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should deny a lock when another task holds it", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const result = manager.acquireLock("/project/foo.ts", "task-2") + expect(result).toBe(false) + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should normalize paths so equivalent paths resolve to the same lock", () => { + manager.acquireLock("/project/src/../foo.ts", "task-1") + const result = manager.acquireLock("/project/foo.ts", "task-2") + expect(result).toBe(false) + }) + + it("should allow locking multiple different files by the same task", () => { + expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true) + expect(manager.acquireLock("/project/b.ts", "task-1")).toBe(true) + expect(manager.getLockedFiles("task-1")).toHaveLength(2) + }) + + it("should allow different tasks to lock different files", () => { + expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true) + expect(manager.acquireLock("/project/b.ts", "task-2")).toBe(true) + expect(manager.getLockHolder("/project/a.ts")).toBe("task-1") + expect(manager.getLockHolder("/project/b.ts")).toBe("task-2") + }) + }) + + describe("releaseLock", () => { + it("should release a lock held by the specified task", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-1") + expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined() + }) + + it("should be a no-op when the task does not hold the lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-2") + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should be a no-op when the file is not locked", () => { + // Should not throw + manager.releaseLock("/project/foo.ts", "task-1") + }) + + it("should allow another task to acquire the lock after release", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-1") + expect(manager.acquireLock("/project/foo.ts", "task-2")).toBe(true) + }) + + it("should clean up taskLocks when the last lock for a task is released", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-1") + expect(manager.getLockedFiles("task-1")).toHaveLength(0) + }) + }) + + describe("releaseAllLocks", () => { + it("should release all locks for a task", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-1") + manager.acquireLock("/project/c.ts", "task-1") + manager.releaseAllLocks("task-1") + + expect(manager.getLockedFiles("task-1")).toHaveLength(0) + expect(manager.getLockHolder("/project/a.ts")).toBeUndefined() + expect(manager.getLockHolder("/project/b.ts")).toBeUndefined() + expect(manager.getLockHolder("/project/c.ts")).toBeUndefined() + }) + + it("should not affect locks held by other tasks", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-2") + manager.releaseAllLocks("task-1") + + expect(manager.getLockHolder("/project/b.ts")).toBe("task-2") + }) + + it("should be a no-op when the task has no locks", () => { + // Should not throw + manager.releaseAllLocks("task-nonexistent") + }) + }) + + describe("getLockHolder", () => { + it("should return undefined for unlocked files", () => { + expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined() + }) + + it("should return the task ID for locked files", () => { + manager.acquireLock("/project/foo.ts", "task-1") + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + }) + + describe("getLockInfo", () => { + it("should return undefined for unlocked files", () => { + expect(manager.getLockInfo("/project/foo.ts")).toBeUndefined() + }) + + it("should return a copy of the lock info", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const info = manager.getLockInfo("/project/foo.ts") + expect(info).toBeDefined() + expect(info!.taskId).toBe("task-1") + expect(info!.filePath).toBe(path.resolve("/project/foo.ts")) + expect(info!.acquiredAt).toBeGreaterThan(0) + }) + }) + + describe("getLockConflict", () => { + it("should return undefined when file is not locked", () => { + expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined() + }) + + it("should return undefined when the requesting task holds the lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined() + }) + + it("should return conflict details when another task holds the lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const conflict = manager.getLockConflict("/project/foo.ts", "task-2") + expect(conflict).toBeDefined() + expect(conflict!.holdingTaskId).toBe("task-1") + expect(conflict!.filePath).toBe(path.resolve("/project/foo.ts")) + expect(conflict!.heldForMs).toBeGreaterThanOrEqual(0) + }) + }) + + describe("getLockedFiles", () => { + it("should return empty array for a task with no locks", () => { + expect(manager.getLockedFiles("task-1")).toEqual([]) + }) + + it("should return all files locked by a task", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-1") + const files = manager.getLockedFiles("task-1") + expect(files).toHaveLength(2) + expect(files).toContain(path.resolve("/project/a.ts")) + expect(files).toContain(path.resolve("/project/b.ts")) + }) + }) + + describe("getAllLocks", () => { + it("should return empty array when no locks exist", () => { + expect(manager.getAllLocks()).toEqual([]) + }) + + it("should return all active locks", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-2") + const allLocks = manager.getAllLocks() + expect(allLocks).toHaveLength(2) + }) + }) + + describe("lockCount", () => { + it("should return 0 initially", () => { + expect(manager.lockCount).toBe(0) + }) + + it("should track the number of active locks", () => { + manager.acquireLock("/project/a.ts", "task-1") + expect(manager.lockCount).toBe(1) + manager.acquireLock("/project/b.ts", "task-1") + expect(manager.lockCount).toBe(2) + manager.releaseLock("/project/a.ts", "task-1") + expect(manager.lockCount).toBe(1) + }) + }) + + describe("lock expiration", () => { + it("should auto-expire locks after timeout and allow reacquisition", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-1") + + // Simulate time passing by directly manipulating the lock's acquiredAt + const locks = (shortTimeoutManager as any).locks as Map + const normalized = path.resolve("/project/foo.ts") + const lockInfo = locks.get(normalized) + lockInfo.acquiredAt = Date.now() - 100 // 100ms ago, > 50ms timeout + + // The lock should now be considered expired + expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBeUndefined() + expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true) + + shortTimeoutManager.dispose() + }) + + it("should auto-expire locks during acquireLock by another task", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + + // Simulate expiry + const locks = (shortTimeoutManager as any).locks as Map + const normalized = path.resolve("/project/foo.ts") + locks.get(normalized).acquiredAt = Date.now() - 100 + + // Another task should be able to acquire the expired lock + expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true) + expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-2") + + shortTimeoutManager.dispose() + }) + + it("should clean up expired locks during getAllLocks", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/a.ts", "task-1") + shortTimeoutManager.acquireLock("/project/b.ts", "task-2") + + // Expire only task-1's lock + const locks = (shortTimeoutManager as any).locks as Map + locks.get(path.resolve("/project/a.ts")).acquiredAt = Date.now() - 100 + + const allLocks = shortTimeoutManager.getAllLocks() + expect(allLocks).toHaveLength(1) + expect(allLocks[0].taskId).toBe("task-2") + + shortTimeoutManager.dispose() + }) + + it("should return undefined from getLockConflict for expired locks", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + + // Expire the lock + const locks = (shortTimeoutManager as any).locks as Map + locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100 + + expect(shortTimeoutManager.getLockConflict("/project/foo.ts", "task-2")).toBeUndefined() + + shortTimeoutManager.dispose() + }) + }) + + describe("events", () => { + it("should emit lock-acquired events", () => { + const events: FileLockEvent[] = [] + manager.onEvent((e) => events.push(e)) + + manager.acquireLock("/project/foo.ts", "task-1") + + expect(events).toHaveLength(1) + expect(events[0].type).toBe("lock-acquired") + expect(events[0].taskId).toBe("task-1") + }) + + it("should emit lock-released events", () => { + const events: FileLockEvent[] = [] + manager.acquireLock("/project/foo.ts", "task-1") + + manager.onEvent((e) => events.push(e)) + manager.releaseLock("/project/foo.ts", "task-1") + + expect(events).toHaveLength(1) + expect(events[0].type).toBe("lock-released") + }) + + it("should emit all-locks-released events", () => { + const events: FileLockEvent[] = [] + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-1") + + manager.onEvent((e) => events.push(e)) + manager.releaseAllLocks("task-1") + + expect(events).toHaveLength(1) + expect(events[0].type).toBe("all-locks-released") + if (events[0].type === "all-locks-released") { + expect(events[0].count).toBe(2) + } + }) + + it("should emit lock-expired events when a lock times out", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + const events: FileLockEvent[] = [] + shortTimeoutManager.onEvent((e) => events.push(e)) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + + // Expire the lock + const locks = (shortTimeoutManager as any).locks as Map + locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100 + + // Trigger expiration check via getLockHolder + shortTimeoutManager.getLockHolder("/project/foo.ts") + + const expiredEvents = events.filter((e) => e.type === "lock-expired") + expect(expiredEvents).toHaveLength(1) + + shortTimeoutManager.dispose() + }) + + it("should allow removing event listeners", () => { + const events: FileLockEvent[] = [] + const listener = (e: FileLockEvent) => events.push(e) + + manager.onEvent(listener) + manager.acquireLock("/project/foo.ts", "task-1") + expect(events).toHaveLength(1) + + manager.offEvent(listener) + manager.acquireLock("/project/bar.ts", "task-1") + expect(events).toHaveLength(1) // No new events + }) + + it("should not throw if a listener throws", () => { + manager.onEvent(() => { + throw new Error("listener error") + }) + + // Should not throw + expect(() => manager.acquireLock("/project/foo.ts", "task-1")).not.toThrow() + }) + }) + + describe("dispose", () => { + it("should clear all locks and listeners", () => { + const events: FileLockEvent[] = [] + manager.onEvent((e) => events.push(e)) + + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-2") + manager.dispose() + + expect(manager.lockCount).toBe(0) + expect(manager.getAllLocks()).toEqual([]) + + // Listener should have been removed + manager.acquireLock("/project/c.ts", "task-3") + expect(events).toHaveLength(2) // Only the pre-dispose events + }) + }) + + describe("re-entrant lock refresh", () => { + it("should refresh the acquiredAt timestamp on re-entrant lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const info1 = manager.getLockInfo("/project/foo.ts") + + // Small delay to ensure timestamp differs + const originalTime = info1!.acquiredAt + + // Manipulate time to verify refresh + const locks = (manager as any).locks as Map + const normalized = path.resolve("/project/foo.ts") + locks.get(normalized).acquiredAt = originalTime - 1000 + + manager.acquireLock("/project/foo.ts", "task-1") + const info2 = manager.getLockInfo("/project/foo.ts") + expect(info2!.acquiredAt).toBeGreaterThan(originalTime - 1000) + }) + }) +}) diff --git a/src/services/file-lock/index.ts b/src/services/file-lock/index.ts new file mode 100644 index 00000000000..bd61c47d857 --- /dev/null +++ b/src/services/file-lock/index.ts @@ -0,0 +1,8 @@ +export { + FileLockManager, + type FileLockManagerOptions, + type FileLockInfo, + type LockConflict, + type FileLockEvent, + type FileLockEventListener, +} from "./FileLockManager" From 77fe7cf6f71d07bb508faf16717a9f7e12446a14 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 15:39:31 +0000 Subject: [PATCH 20/24] feat: Phase 7b - LockGuardedToolExecutor for write tool lock integration (#12330) --- .../file-lock/LockGuardedToolExecutor.ts | 229 +++++++++++++++ .../__tests__/LockGuardedToolExecutor.spec.ts | 277 ++++++++++++++++++ src/services/file-lock/index.ts | 7 + 3 files changed, 513 insertions(+) create mode 100644 src/services/file-lock/LockGuardedToolExecutor.ts create mode 100644 src/services/file-lock/__tests__/LockGuardedToolExecutor.spec.ts diff --git a/src/services/file-lock/LockGuardedToolExecutor.ts b/src/services/file-lock/LockGuardedToolExecutor.ts new file mode 100644 index 00000000000..8a3549560a6 --- /dev/null +++ b/src/services/file-lock/LockGuardedToolExecutor.ts @@ -0,0 +1,229 @@ +import path from "path" + +import type { ToolName } from "@roo-code/types" + +import { FileLockManager, type LockConflict } from "./FileLockManager" + +/** + * Set of tool names that perform file write operations and require lock guards. + */ +export const WRITE_TOOL_NAMES: ReadonlySet = new Set([ + "write_to_file", + "apply_diff", + "apply_patch", + "edit_file", + "search_replace", + "search_and_replace", +]) + +/** + * Patch file header markers used by apply_patch to specify file operations. + */ +const PATCH_FILE_MARKERS = ["*** Add File: ", "*** Delete File: ", "*** Update File: "] as const + +/** + * Extract target file paths from tool parameters based on tool name. + * + * Each write tool encodes the target file path differently: + * - write_to_file, apply_diff: `params.path` + * - edit_file, search_replace, search_and_replace: `params.file_path` + * - apply_patch: multiple paths embedded in the patch content + * + * @returns Array of relative file paths the tool intends to write to. + */ +export function extractWriteTargetPaths(toolName: ToolName, params: Record): string[] { + switch (toolName) { + case "write_to_file": + case "apply_diff": { + const p = params.path + if (typeof p === "string" && p.length > 0) { + return [p] + } + return [] + } + + case "edit_file": + case "search_replace": + case "search_and_replace": { + const p = params.file_path + if (typeof p === "string" && p.length > 0) { + return [p] + } + return [] + } + + case "apply_patch": { + return extractFilePathsFromPatch(params.patch) + } + + default: + return [] + } +} + +/** + * Extract file paths from apply_patch content. + * The patch format uses markers like "*** Add File: path", "*** Delete File: path", etc. + */ +function extractFilePathsFromPatch(patchContent: unknown): string[] { + if (typeof patchContent !== "string" || patchContent.length === 0) { + return [] + } + + const filePaths: string[] = [] + const lines = patchContent.split("\n") + + for (const line of lines) { + for (const marker of PATCH_FILE_MARKERS) { + if (line.startsWith(marker)) { + const filePath = line.substring(marker.length).trim() + if (filePath) { + filePaths.push(filePath) + } + break + } + } + } + + return filePaths +} + +/** + * Result of attempting to acquire locks for a tool execution. + */ +export type LockAcquisitionResult = + | { success: true; lockedPaths: string[] } + | { success: false; conflicts: LockConflict[]; lockedPaths: string[] } + +/** + * Orchestrates file lock acquisition and release around write tool executions. + * + * This executor is designed to be called by the tool execution layer before + * invoking a write tool. It: + * + * 1. Extracts target file paths from the tool's parameters + * 2. Attempts to acquire locks on all target files for the given task + * 3. If any lock fails, releases all locks acquired in this batch and returns conflicts + * 4. On success, the caller executes the tool, then calls `releaseLocks()` + * + * Usage: + * ```typescript + * const executor = new LockGuardedToolExecutor(fileLockManager) + * const result = executor.tryAcquireLocks("write_to_file", params, taskId, cwd) + * + * if (!result.success) { + * // Report conflicts to the LLM + * return formatLockConflictError(result.conflicts) + * } + * + * try { + * await tool.execute(params, task, callbacks) + * } finally { + * executor.releaseLocks(result.lockedPaths, taskId) + * } + * ``` + */ +export class LockGuardedToolExecutor { + constructor(private readonly lockManager: FileLockManager) {} + + /** + * Check if the given tool name is a write tool that requires lock guards. + */ + isWriteTool(toolName: ToolName): boolean { + return WRITE_TOOL_NAMES.has(toolName) + } + + /** + * Attempt to acquire file locks for all files a write tool targets. + * + * If the tool is not a write tool or has no extractable paths, returns + * success with an empty lockedPaths array (no locks needed). + * + * Uses all-or-nothing semantics: if any file can't be locked, all locks + * acquired in this batch are released and the conflicts are returned. + * + * @param toolName - The tool being executed + * @param params - The tool's parameters + * @param taskId - The ID of the task executing the tool + * @param cwd - The working directory for resolving relative paths + * @returns Lock acquisition result + */ + tryAcquireLocks( + toolName: ToolName, + params: Record, + taskId: string, + cwd: string, + ): LockAcquisitionResult { + if (!this.isWriteTool(toolName)) { + return { success: true, lockedPaths: [] } + } + + const relativePaths = extractWriteTargetPaths(toolName, params) + + if (relativePaths.length === 0) { + return { success: true, lockedPaths: [] } + } + + // Resolve to absolute paths for consistent locking + const absolutePaths = relativePaths.map((p) => path.resolve(cwd, p)) + + // Sort paths to prevent deadlocks when multiple tools lock multiple files + const sortedPaths = [...absolutePaths].sort() + + const lockedPaths: string[] = [] + const conflicts: LockConflict[] = [] + + for (const absPath of sortedPaths) { + const acquired = this.lockManager.acquireLock(absPath, taskId) + + if (acquired) { + lockedPaths.push(absPath) + } else { + // Collect conflict info + const conflict = this.lockManager.getLockConflict(absPath, taskId) + if (conflict) { + conflicts.push(conflict) + } + } + } + + // All-or-nothing: if any conflict, release everything we acquired + if (conflicts.length > 0) { + for (const locked of lockedPaths) { + this.lockManager.releaseLock(locked, taskId) + } + return { success: false, conflicts, lockedPaths: [] } + } + + return { success: true, lockedPaths } + } + + /** + * Release locks on the specified paths for a task. + * Should be called in a `finally` block after tool execution. + */ + releaseLocks(lockedPaths: string[], taskId: string): void { + for (const absPath of lockedPaths) { + this.lockManager.releaseLock(absPath, taskId) + } + } + + /** + * Format a human-readable error message for lock conflicts. + * This message is intended to be returned to the LLM so it can + * understand why the write was blocked and take corrective action. + */ + static formatLockConflictError(conflicts: LockConflict[]): string { + const lines = ["Cannot write to the following file(s) because they are locked by another task:", ""] + + for (const conflict of conflicts) { + const heldSec = Math.round(conflict.heldForMs / 1000) + lines.push(` - ${conflict.filePath} (locked by task ${conflict.holdingTaskId} for ${heldSec}s)`) + } + + lines.push("") + lines.push("Wait for the other task to finish writing, or work on a different file.") + + return lines.join("\n") + } +} diff --git a/src/services/file-lock/__tests__/LockGuardedToolExecutor.spec.ts b/src/services/file-lock/__tests__/LockGuardedToolExecutor.spec.ts new file mode 100644 index 00000000000..71f74a3895a --- /dev/null +++ b/src/services/file-lock/__tests__/LockGuardedToolExecutor.spec.ts @@ -0,0 +1,277 @@ +import path from "path" + +import { FileLockManager } from "../FileLockManager" +import { LockGuardedToolExecutor, extractWriteTargetPaths, WRITE_TOOL_NAMES } from "../LockGuardedToolExecutor" + +describe("extractWriteTargetPaths", () => { + it("extracts path from write_to_file params", () => { + expect(extractWriteTargetPaths("write_to_file", { path: "src/foo.ts", content: "hello" })).toEqual([ + "src/foo.ts", + ]) + }) + + it("extracts path from apply_diff params", () => { + expect(extractWriteTargetPaths("apply_diff", { path: "lib/bar.ts", diff: "---" })).toEqual(["lib/bar.ts"]) + }) + + it("extracts file_path from edit_file params", () => { + expect( + extractWriteTargetPaths("edit_file", { + file_path: "a/b.ts", + old_string: "x", + new_string: "y", + }), + ).toEqual(["a/b.ts"]) + }) + + it("extracts file_path from search_replace params", () => { + expect( + extractWriteTargetPaths("search_replace", { + file_path: "c/d.ts", + old_string: "x", + new_string: "y", + }), + ).toEqual(["c/d.ts"]) + }) + + it("extracts file_path from search_and_replace params", () => { + expect( + extractWriteTargetPaths("search_and_replace", { + file_path: "e/f.ts", + old_string: "x", + new_string: "y", + }), + ).toEqual(["e/f.ts"]) + }) + + it("extracts multiple paths from apply_patch params", () => { + const patch = [ + "*** Update File: src/a.ts", + "some diff content", + "*** Add File: src/b.ts", + "file content", + "*** Delete File: src/c.ts", + ].join("\n") + + expect(extractWriteTargetPaths("apply_patch", { patch })).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]) + }) + + it("returns empty array for apply_patch with no recognizable paths", () => { + expect(extractWriteTargetPaths("apply_patch", { patch: "random content" })).toEqual([]) + }) + + it("returns empty array for apply_patch with empty/missing patch", () => { + expect(extractWriteTargetPaths("apply_patch", { patch: "" })).toEqual([]) + expect(extractWriteTargetPaths("apply_patch", {})).toEqual([]) + }) + + it("returns empty array for missing path param", () => { + expect(extractWriteTargetPaths("write_to_file", {})).toEqual([]) + expect(extractWriteTargetPaths("write_to_file", { path: "" })).toEqual([]) + }) + + it("returns empty array for non-write tools", () => { + expect(extractWriteTargetPaths("read_file", { path: "foo.ts" })).toEqual([]) + expect(extractWriteTargetPaths("list_files", { path: "." })).toEqual([]) + }) +}) + +describe("WRITE_TOOL_NAMES", () => { + it("contains all expected write tools", () => { + expect(WRITE_TOOL_NAMES.has("write_to_file")).toBe(true) + expect(WRITE_TOOL_NAMES.has("apply_diff")).toBe(true) + expect(WRITE_TOOL_NAMES.has("apply_patch")).toBe(true) + expect(WRITE_TOOL_NAMES.has("edit_file")).toBe(true) + expect(WRITE_TOOL_NAMES.has("search_replace")).toBe(true) + expect(WRITE_TOOL_NAMES.has("search_and_replace")).toBe(true) + }) + + it("does not contain read tools", () => { + expect(WRITE_TOOL_NAMES.has("read_file")).toBe(false) + expect(WRITE_TOOL_NAMES.has("list_files")).toBe(false) + }) +}) + +describe("LockGuardedToolExecutor", () => { + let lockManager: FileLockManager + let executor: LockGuardedToolExecutor + const cwd = "/workspace" + + beforeEach(() => { + lockManager = new FileLockManager() + executor = new LockGuardedToolExecutor(lockManager) + }) + + afterEach(() => { + lockManager.dispose() + }) + + describe("isWriteTool", () => { + it("returns true for write tools", () => { + expect(executor.isWriteTool("write_to_file")).toBe(true) + expect(executor.isWriteTool("apply_diff")).toBe(true) + expect(executor.isWriteTool("apply_patch")).toBe(true) + expect(executor.isWriteTool("edit_file")).toBe(true) + expect(executor.isWriteTool("search_replace")).toBe(true) + }) + + it("returns false for non-write tools", () => { + expect(executor.isWriteTool("read_file")).toBe(false) + expect(executor.isWriteTool("list_files")).toBe(false) + expect(executor.isWriteTool("execute_command")).toBe(false) + }) + }) + + describe("tryAcquireLocks", () => { + it("returns success with empty lockedPaths for non-write tools", () => { + const result = executor.tryAcquireLocks("read_file", { path: "foo.ts" }, "task-1", cwd) + expect(result).toEqual({ success: true, lockedPaths: [] }) + }) + + it("returns success with empty lockedPaths when no paths extracted", () => { + const result = executor.tryAcquireLocks("write_to_file", {}, "task-1", cwd) + expect(result).toEqual({ success: true, lockedPaths: [] }) + }) + + it("acquires lock for a single file write", () => { + const result = executor.tryAcquireLocks( + "write_to_file", + { path: "src/foo.ts", content: "hello" }, + "task-1", + cwd, + ) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.lockedPaths).toHaveLength(1) + expect(result.lockedPaths[0]).toBe(path.resolve(cwd, "src/foo.ts")) + } + + // Verify the lock is held in the manager + expect(lockManager.getLockHolder(path.resolve(cwd, "src/foo.ts"))).toBe("task-1") + }) + + it("acquires locks for multiple files in apply_patch", () => { + const patch = ["*** Update File: src/a.ts", "diff", "*** Add File: src/b.ts", "content"].join("\n") + + const result = executor.tryAcquireLocks("apply_patch", { patch }, "task-1", cwd) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.lockedPaths).toHaveLength(2) + } + }) + + it("allows re-entrant lock by same task", () => { + // First acquire + const result1 = executor.tryAcquireLocks( + "write_to_file", + { path: "src/foo.ts", content: "a" }, + "task-1", + cwd, + ) + expect(result1.success).toBe(true) + + // Same task, same file -- should succeed (re-entrant) + const result2 = executor.tryAcquireLocks( + "write_to_file", + { path: "src/foo.ts", content: "b" }, + "task-1", + cwd, + ) + expect(result2.success).toBe(true) + }) + + it("fails when another task holds the lock", () => { + // Task 1 locks the file + lockManager.acquireLock(path.resolve(cwd, "src/foo.ts"), "task-1") + + // Task 2 tries to write + const result = executor.tryAcquireLocks( + "write_to_file", + { path: "src/foo.ts", content: "conflict" }, + "task-2", + cwd, + ) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.conflicts).toHaveLength(1) + expect(result.conflicts[0].holdingTaskId).toBe("task-1") + expect(result.lockedPaths).toEqual([]) + } + }) + + it("uses all-or-nothing semantics for multi-file patches", () => { + // Task 1 locks one of the files + lockManager.acquireLock(path.resolve(cwd, "src/b.ts"), "task-1") + + const patch = ["*** Update File: src/a.ts", "diff a", "*** Update File: src/b.ts", "diff b"].join("\n") + + const result = executor.tryAcquireLocks("apply_patch", { patch }, "task-2", cwd) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.conflicts).toHaveLength(1) + // a.ts should have been rolled back + expect(result.lockedPaths).toEqual([]) + } + + // Verify a.ts was NOT left locked by task-2 + expect(lockManager.getLockHolder(path.resolve(cwd, "src/a.ts"))).toBeUndefined() + }) + }) + + describe("releaseLocks", () => { + it("releases all specified locks", () => { + const absPath = path.resolve(cwd, "src/foo.ts") + lockManager.acquireLock(absPath, "task-1") + + executor.releaseLocks([absPath], "task-1") + + expect(lockManager.getLockHolder(absPath)).toBeUndefined() + }) + + it("is safe to call with empty array", () => { + expect(() => executor.releaseLocks([], "task-1")).not.toThrow() + }) + + it("is safe to call for locks not held", () => { + expect(() => executor.releaseLocks([path.resolve(cwd, "nonexistent.ts")], "task-1")).not.toThrow() + }) + }) + + describe("formatLockConflictError", () => { + it("formats a readable error message", () => { + const conflicts = [ + { + filePath: "/workspace/src/foo.ts", + holdingTaskId: "task-abc", + heldForMs: 5000, + }, + ] + + const msg = LockGuardedToolExecutor.formatLockConflictError(conflicts) + + expect(msg).toContain("Cannot write") + expect(msg).toContain("/workspace/src/foo.ts") + expect(msg).toContain("task-abc") + expect(msg).toContain("5s") + expect(msg).toContain("Wait for the other task") + }) + + it("formats multiple conflicts", () => { + const conflicts = [ + { filePath: "/workspace/a.ts", holdingTaskId: "t1", heldForMs: 2000 }, + { filePath: "/workspace/b.ts", holdingTaskId: "t2", heldForMs: 10000 }, + ] + + const msg = LockGuardedToolExecutor.formatLockConflictError(conflicts) + + expect(msg).toContain("a.ts") + expect(msg).toContain("b.ts") + expect(msg).toContain("t1") + expect(msg).toContain("t2") + }) + }) +}) diff --git a/src/services/file-lock/index.ts b/src/services/file-lock/index.ts index bd61c47d857..ca893cd869b 100644 --- a/src/services/file-lock/index.ts +++ b/src/services/file-lock/index.ts @@ -6,3 +6,10 @@ export { type FileLockEvent, type FileLockEventListener, } from "./FileLockManager" + +export { + LockGuardedToolExecutor, + extractWriteTargetPaths, + WRITE_TOOL_NAMES, + type LockAcquisitionResult, +} from "./LockGuardedToolExecutor" From 363436c44214970bdcbe20ab25c57b60609e46fe Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 15:50:04 +0000 Subject: [PATCH 21/24] feat: wire LockGuardedToolExecutor into tool runner and Task.dispose() - Integrate file lock guards into presentAssistantMessage.ts tool dispatch - Acquire locks before write tool execution, release in finally block - Block concurrent writes with LLM-friendly conflict error messages - Wire releaseAllLocks into Task.dispose() for stale lock cleanup - Add singleton getters (getFileLockManager, getLockGuardedToolExecutor) - Add 14 integration tests covering lifecycle, conflicts, and cleanup Phase 7b tool runner integration for parallel agent file coordination. --- .../presentAssistantMessage.ts | 489 ++++++++++-------- src/core/task/Task.ts | 9 + .../__tests__/tool-runner-integration.spec.ts | 220 ++++++++ src/services/file-lock/index.ts | 43 ++ 4 files changed, 534 insertions(+), 227 deletions(-) create mode 100644 src/services/file-lock/__tests__/tool-runner-integration.spec.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index d3393d09610..0f2149e1b9e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -38,6 +38,11 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +import { + getLockGuardedToolExecutor, + LockGuardedToolExecutor, + type LockAcquisitionResult, +} from "../../services/file-lock" /** * Processes and presents assistant message content to the user interface. @@ -676,245 +681,275 @@ export async function presentAssistantMessage(cline: Task) { }) } - switch (block.name) { - case "write_to_file": - await checkpointSaveAndMark(cline) - await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "update_todo_list": - await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "apply_diff": - await checkpointSaveAndMark(cline) - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "edit": - case "search_and_replace": - await checkpointSaveAndMark(cline) - await editTool.handle(cline, block as ToolUse<"edit">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "search_replace": - await checkpointSaveAndMark(cline) - await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "edit_file": - await checkpointSaveAndMark(cline) - await editFileTool.handle(cline, block as ToolUse<"edit_file">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "apply_patch": - await checkpointSaveAndMark(cline) - await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "read_file": - // Type assertion is safe here because we're in the "read_file" case - await readFileTool.handle(cline, block as ToolUse<"read_file">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "list_files": - await listFilesTool.handle(cline, block as ToolUse<"list_files">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "codebase_search": - await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "search_files": - await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "execute_command": - await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "read_command_output": - await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "use_mcp_tool": - await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "access_mcp_resource": - await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "ask_followup_question": - await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "switch_mode": - await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "new_task": - await checkpointSaveAndMark(cline) - await newTaskTool.handle(cline, block as ToolUse<"new_task">, { - askApproval, - handleError, - pushToolResult, - toolCallId: block.id, - }) - break - case "attempt_completion": { - const completionCallbacks: AttemptCompletionCallbacks = { - askApproval, - handleError, - pushToolResult, - askFinishSubTaskApproval, - toolDescription, - } - await attemptCompletionTool.handle( - cline, - block as ToolUse<"attempt_completion">, - completionCallbacks, - ) + // Phase 7b: Acquire file locks for write tools before execution. + // This prevents concurrent tasks from writing to the same file simultaneously. + // Lock acquisition is only attempted for complete (non-partial) blocks with valid args. + let lockResult: LockAcquisitionResult | undefined + if (!block.partial && block.nativeArgs) { + const executor = getLockGuardedToolExecutor() + lockResult = executor.tryAcquireLocks( + block.name as ToolName, + block.nativeArgs as Record, + cline.taskId, + cline.cwd, + ) + if (!lockResult.success) { + const errorMsg = LockGuardedToolExecutor.formatLockConflictError(lockResult.conflicts) + pushToolResult(formatResponse.toolError(errorMsg)) break } - case "run_slash_command": - await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "skill": - await skillTool.handle(cline, block as ToolUse<"skill">, { - askApproval, - handleError, - pushToolResult, - }) - break - case "generate_image": - await checkpointSaveAndMark(cline) - await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { - askApproval, - handleError, - pushToolResult, - }) - break - default: { - // Handle unknown/invalid tool names OR custom tools - // This is critical for native tool calling where every tool_use MUST have a tool_result - - // CRITICAL: Don't process partial blocks for unknown tools - just let them stream in. - // If we try to show errors for partial blocks, we'd show the error on every streaming chunk, - // creating a loop that appears to freeze the extension. Only handle complete blocks. - if (block.partial) { + } + + try { + switch (block.name) { + case "write_to_file": + await checkpointSaveAndMark(cline) + await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "update_todo_list": + await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "apply_diff": + await checkpointSaveAndMark(cline) + await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "edit": + case "search_and_replace": + await checkpointSaveAndMark(cline) + await editTool.handle(cline, block as ToolUse<"edit">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "search_replace": + await checkpointSaveAndMark(cline) + await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "edit_file": + await checkpointSaveAndMark(cline) + await editFileTool.handle(cline, block as ToolUse<"edit_file">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "apply_patch": + await checkpointSaveAndMark(cline) + await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "read_file": + // Type assertion is safe here because we're in the "read_file" case + await readFileTool.handle(cline, block as ToolUse<"read_file">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "list_files": + await listFilesTool.handle(cline, block as ToolUse<"list_files">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "codebase_search": + await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "search_files": + await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "execute_command": + await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "read_command_output": + await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "use_mcp_tool": + await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "access_mcp_resource": + await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "ask_followup_question": + await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "switch_mode": + await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "new_task": + await checkpointSaveAndMark(cline) + await newTaskTool.handle(cline, block as ToolUse<"new_task">, { + askApproval, + handleError, + pushToolResult, + toolCallId: block.id, + }) + break + case "attempt_completion": { + const completionCallbacks: AttemptCompletionCallbacks = { + askApproval, + handleError, + pushToolResult, + askFinishSubTaskApproval, + toolDescription, + } + await attemptCompletionTool.handle( + cline, + block as ToolUse<"attempt_completion">, + completionCallbacks, + ) break } + case "run_slash_command": + await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "skill": + await skillTool.handle(cline, block as ToolUse<"skill">, { + askApproval, + handleError, + pushToolResult, + }) + break + case "generate_image": + await checkpointSaveAndMark(cline) + await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { + askApproval, + handleError, + pushToolResult, + }) + break + default: { + // Handle unknown/invalid tool names OR custom tools + // This is critical for native tool calling where every tool_use MUST have a tool_result + + // CRITICAL: Don't process partial blocks for unknown tools - just let them stream in. + // If we try to show errors for partial blocks, we'd show the error on every streaming chunk, + // creating a loop that appears to freeze the extension. Only handle complete blocks. + if (block.partial) { + break + } - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined - - if (customTool) { - try { - let customToolArgs - - if (customTool.parameters) { - try { - customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {}) - } catch (parseParamsError) { - const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` - console.error(message) - cline.consecutiveMistakeCount++ - await cline.say("error", message) - pushToolResult(formatResponse.toolError(message)) - break + const customTool = stateExperiments?.customTools + ? customToolRegistry.get(block.name) + : undefined + + if (customTool) { + try { + let customToolArgs + + if (customTool.parameters) { + try { + customToolArgs = customTool.parameters.parse( + block.nativeArgs || block.params || {}, + ) + } catch (parseParamsError) { + const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` + console.error(message) + cline.consecutiveMistakeCount++ + await cline.say("error", message) + pushToolResult(formatResponse.toolError(message)) + break + } } + + const result = await customTool.execute(customToolArgs, { + mode: mode ?? defaultModeSlug, + task: cline, + }) + + console.log( + `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, + ) + + pushToolResult(result) + cline.consecutiveMistakeCount = 0 + } catch (executionError: any) { + cline.consecutiveMistakeCount++ + // Record custom tool error with static name + cline.recordToolError("custom_tool", executionError.message) + await handleError(`executing custom tool "${block.name}"`, executionError) } - const result = await customTool.execute(customToolArgs, { - mode: mode ?? defaultModeSlug, - task: cline, - }) - - console.log( - `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, - ) - - pushToolResult(result) - cline.consecutiveMistakeCount = 0 - } catch (executionError: any) { - cline.consecutiveMistakeCount++ - // Record custom tool error with static name - cline.recordToolError("custom_tool", executionError.message) - await handleError(`executing custom tool "${block.name}"`, executionError) + break } + // Not a custom tool - handle as unknown tool error + const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` + cline.consecutiveMistakeCount++ + cline.recordToolError(block.name as ToolName, errorMessage) + await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) + // Push tool_result directly WITHOUT setting didAlreadyUseTool + // This prevents the stream from being interrupted with "Response interrupted by tool use result" + cline.pushToolResultToUserContent({ + type: "tool_result", + tool_use_id: sanitizeToolUseId(toolCallId), + content: formatResponse.toolError(errorMessage), + is_error: true, + }) break } - - // Not a custom tool - handle as unknown tool error - const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` - cline.consecutiveMistakeCount++ - cline.recordToolError(block.name as ToolName, errorMessage) - await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) - // Push tool_result directly WITHOUT setting didAlreadyUseTool - // This prevents the stream from being interrupted with "Response interrupted by tool use result" - cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: formatResponse.toolError(errorMessage), - is_error: true, - }) - break + } + } finally { + // Phase 7b: Release file locks acquired before tool execution. + if (lockResult?.success && lockResult.lockedPaths.length > 0) { + getLockGuardedToolExecutor().releaseLocks(lockResult.lockedPaths, cline.taskId) } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 812d39ff41e..7b8d5f74fe6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -103,6 +103,7 @@ import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" +import { getFileLockManager } from "../../services/file-lock" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" import { manageContext, willManageContext } from "../context-management" @@ -2361,6 +2362,14 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error releasing terminals:", error) } + // Phase 7b: Release any file locks held by this task to prevent stale locks + // from blocking other tasks after this task is disposed/aborted. + try { + getFileLockManager().releaseAllLocks(this.taskId) + } catch (error) { + console.error("Error releasing file locks:", error) + } + // Cleanup command output artifacts getTaskDirectoryPath(this.globalStoragePath, this.taskId) .then((taskDir) => { diff --git a/src/services/file-lock/__tests__/tool-runner-integration.spec.ts b/src/services/file-lock/__tests__/tool-runner-integration.spec.ts new file mode 100644 index 00000000000..df7ed3ca71d --- /dev/null +++ b/src/services/file-lock/__tests__/tool-runner-integration.spec.ts @@ -0,0 +1,220 @@ +import path from "path" + +import { FileLockManager } from "../FileLockManager" +import { LockGuardedToolExecutor } from "../LockGuardedToolExecutor" +import { getFileLockManager, getLockGuardedToolExecutor, resetFileLockSingletons } from "../index" + +describe("tool-runner-integration", () => { + describe("singleton getters", () => { + afterEach(() => { + resetFileLockSingletons() + }) + + it("getFileLockManager returns a FileLockManager instance", () => { + const manager = getFileLockManager() + expect(manager).toBeInstanceOf(FileLockManager) + }) + + it("getFileLockManager returns the same instance on repeated calls", () => { + const a = getFileLockManager() + const b = getFileLockManager() + expect(a).toBe(b) + }) + + it("getLockGuardedToolExecutor returns a LockGuardedToolExecutor instance", () => { + const executor = getLockGuardedToolExecutor() + expect(executor).toBeInstanceOf(LockGuardedToolExecutor) + }) + + it("getLockGuardedToolExecutor returns the same instance on repeated calls", () => { + const a = getLockGuardedToolExecutor() + const b = getLockGuardedToolExecutor() + expect(a).toBe(b) + }) + + it("resetFileLockSingletons creates fresh instances", () => { + const before = getFileLockManager() + resetFileLockSingletons() + const after = getFileLockManager() + expect(before).not.toBe(after) + }) + + it("getLockGuardedToolExecutor uses getFileLockManager singleton", () => { + const executor = getLockGuardedToolExecutor() + const manager = getFileLockManager() + + // Acquire a lock through the manager, verify executor sees it + const absPath = path.resolve("/workspace", "test.ts") + manager.acquireLock(absPath, "task-1") + + const result = executor.tryAcquireLocks( + "write_to_file", + { path: "test.ts", content: "hello" }, + "task-2", + "/workspace", + ) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.conflicts[0].holdingTaskId).toBe("task-1") + } + }) + }) + + describe("lock guard lifecycle in tool execution flow", () => { + let lockManager: FileLockManager + let executor: LockGuardedToolExecutor + + beforeEach(() => { + lockManager = new FileLockManager() + executor = new LockGuardedToolExecutor(lockManager) + }) + + it("acquires and releases locks around a successful write_to_file", () => { + const cwd = "/workspace" + const params = { path: "src/app.ts", content: "code" } + const taskId = "task-1" + + // Acquire + const result = executor.tryAcquireLocks("write_to_file", params, taskId, cwd) + expect(result.success).toBe(true) + if (!result.success) return + + // Lock is held + const absPath = path.resolve(cwd, "src/app.ts") + expect(lockManager.getLockHolder(absPath)).toBe(taskId) + + // Simulate tool execution... + + // Release + executor.releaseLocks(result.lockedPaths, taskId) + + // Lock is released + expect(lockManager.getLockHolder(absPath)).toBeUndefined() + }) + + it("acquires and releases locks around a successful apply_patch with multiple files", () => { + const cwd = "/workspace" + const patch = ["*** Update File: src/a.ts", "some diff", "*** Add File: src/b.ts", "some content"].join( + "\n", + ) + const params = { patch } + const taskId = "task-1" + + const result = executor.tryAcquireLocks("apply_patch", params, taskId, cwd) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.lockedPaths).toHaveLength(2) + + // Both locks held + expect(lockManager.getLockHolder(path.resolve(cwd, "src/a.ts"))).toBe(taskId) + expect(lockManager.getLockHolder(path.resolve(cwd, "src/b.ts"))).toBe(taskId) + + // Release + executor.releaseLocks(result.lockedPaths, taskId) + + // Both released + expect(lockManager.getLockHolder(path.resolve(cwd, "src/a.ts"))).toBeUndefined() + expect(lockManager.getLockHolder(path.resolve(cwd, "src/b.ts"))).toBeUndefined() + }) + + it("blocks a second task from writing to a locked file", () => { + const cwd = "/workspace" + const params = { path: "src/shared.ts", content: "x" } + + // Task 1 acquires + const result1 = executor.tryAcquireLocks("write_to_file", params, "task-1", cwd) + expect(result1.success).toBe(true) + + // Task 2 tries to write same file + const result2 = executor.tryAcquireLocks("write_to_file", params, "task-2", cwd) + expect(result2.success).toBe(false) + if (!result2.success) { + expect(result2.conflicts).toHaveLength(1) + expect(result2.conflicts[0].holdingTaskId).toBe("task-1") + } + + // Task 1 releases + if (result1.success) { + executor.releaseLocks(result1.lockedPaths, "task-1") + } + + // Task 2 can now acquire + const result3 = executor.tryAcquireLocks("write_to_file", params, "task-2", cwd) + expect(result3.success).toBe(true) + }) + + it("releaseAllLocks on dispose frees all locks for a task", () => { + const cwd = "/workspace" + + // Task 1 acquires locks on two files + executor.tryAcquireLocks("write_to_file", { path: "a.ts", content: "a" }, "task-1", cwd) + executor.tryAcquireLocks("write_to_file", { path: "b.ts", content: "b" }, "task-1", cwd) + + expect(lockManager.getLockHolder(path.resolve(cwd, "a.ts"))).toBe("task-1") + expect(lockManager.getLockHolder(path.resolve(cwd, "b.ts"))).toBe("task-1") + + // Simulate Task.dispose() calling releaseAllLocks + lockManager.releaseAllLocks("task-1") + + expect(lockManager.getLockHolder(path.resolve(cwd, "a.ts"))).toBeUndefined() + expect(lockManager.getLockHolder(path.resolve(cwd, "b.ts"))).toBeUndefined() + }) + + it("does not acquire locks for read-only tools", () => { + const cwd = "/workspace" + const result = executor.tryAcquireLocks("read_file", { path: "a.ts" }, "task-1", cwd) + expect(result.success).toBe(true) + if (result.success) { + expect(result.lockedPaths).toEqual([]) + } + }) + + it("formats lock conflict errors with useful information", () => { + const conflicts = [ + { + filePath: "/workspace/src/shared.ts", + holdingTaskId: "task-abc-123", + heldForMs: 8000, + }, + ] + const msg = LockGuardedToolExecutor.formatLockConflictError(conflicts) + + expect(msg).toContain("Cannot write") + expect(msg).toContain("/workspace/src/shared.ts") + expect(msg).toContain("task-abc-123") + expect(msg).toContain("8s") + expect(msg).toContain("Wait for the other task") + }) + + it("handles edit_file tool path extraction", () => { + const cwd = "/workspace" + const result = executor.tryAcquireLocks( + "edit_file", + { file_path: "src/utils.ts", old_string: "a", new_string: "b" }, + "task-1", + cwd, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.lockedPaths).toHaveLength(1) + expect(result.lockedPaths[0]).toBe(path.resolve(cwd, "src/utils.ts")) + } + }) + + it("handles search_replace tool path extraction", () => { + const cwd = "/workspace" + const result = executor.tryAcquireLocks( + "search_replace", + { file_path: "src/config.ts", old_string: "x", new_string: "y" }, + "task-1", + cwd, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.lockedPaths).toHaveLength(1) + expect(result.lockedPaths[0]).toBe(path.resolve(cwd, "src/config.ts")) + } + }) + }) +}) diff --git a/src/services/file-lock/index.ts b/src/services/file-lock/index.ts index ca893cd869b..8d6896b83a8 100644 --- a/src/services/file-lock/index.ts +++ b/src/services/file-lock/index.ts @@ -13,3 +13,46 @@ export { WRITE_TOOL_NAMES, type LockAcquisitionResult, } from "./LockGuardedToolExecutor" + +import { FileLockManager } from "./FileLockManager" +import { LockGuardedToolExecutor } from "./LockGuardedToolExecutor" + +/** + * Module-level singleton instances for the file lock subsystem. + * Lazily initialized on first access. + */ +let _fileLockManager: FileLockManager | undefined +let _lockGuardedToolExecutor: LockGuardedToolExecutor | undefined + +/** + * Get the singleton FileLockManager instance. + * Creates it on first call. + */ +export function getFileLockManager(): FileLockManager { + if (!_fileLockManager) { + _fileLockManager = new FileLockManager() + } + return _fileLockManager +} + +/** + * Get the singleton LockGuardedToolExecutor instance. + * Creates it (and the underlying FileLockManager) on first call. + */ +export function getLockGuardedToolExecutor(): LockGuardedToolExecutor { + if (!_lockGuardedToolExecutor) { + _lockGuardedToolExecutor = new LockGuardedToolExecutor(getFileLockManager()) + } + return _lockGuardedToolExecutor +} + +/** + * Reset singleton instances. Intended for testing only. + */ +export function resetFileLockSingletons(): void { + if (_fileLockManager) { + _fileLockManager.dispose() + } + _fileLockManager = undefined + _lockGuardedToolExecutor = undefined +} From c5303196e8574aab6dfeada3ed907fe4c15c966c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 16:13:40 +0000 Subject: [PATCH 22/24] feat: add persistent background task history (Phase 7c) - Add background field to HistoryItem schema - Add interrupted status for background tasks stopped mid-execution - Add background option to TaskMetadataOptions - Detect and mark interrupted background tasks on TaskHistoryStore init - Add showBackgroundTasks filter to useTaskSearch hook - Add background task filter toggle to HistoryView UI - Add background/interrupted visual indicators in TaskItemFooter - Add i18n translation keys for background task labels - Add unit tests for all changes --- .../autocomplete/triggers/HistoryTrigger.tsx | 4 +- apps/cli/src/ui/types.ts | 3 +- packages/types/src/history.ts | 3 +- packages/types/src/task.ts | 2 +- src/core/task-persistence/TaskHistoryStore.ts | 21 +++++++ .../__tests__/TaskHistoryStore.spec.ts | 54 ++++++++++++++++ src/core/task-persistence/taskMetadata.ts | 6 +- src/core/task/Task.ts | 4 +- .../src/components/history/HistoryView.tsx | 26 ++++++++ .../src/components/history/TaskItemFooter.tsx | 16 ++++- .../history/__tests__/TaskItemFooter.spec.tsx | 29 +++++++++ .../history/__tests__/useTaskSearch.spec.tsx | 61 +++++++++++++++++++ .../src/components/history/useTaskSearch.ts | 8 ++- webview-ui/src/i18n/locales/en/history.json | 11 +++- 14 files changed, 237 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx index 443fdfa9797..86e2fad9b4b 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx @@ -21,7 +21,7 @@ export interface HistoryResult extends AutocompleteItem { /** Mode the task was run in */ mode?: string /** Task status */ - status?: "active" | "completed" | "delegated" + status?: "active" | "completed" | "delegated" | "interrupted" } /** @@ -178,7 +178,7 @@ export function toHistoryResult(item: { totalCost?: number workspace?: string mode?: string - status?: "active" | "completed" | "delegated" + status?: "active" | "completed" | "delegated" | "interrupted" }): HistoryResult { return { key: item.id, // Use task ID as the unique key diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index 3c45377c675..48d59d643dd 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -109,7 +109,8 @@ export interface TaskHistoryItem { totalCost?: number workspace?: string mode?: string - status?: "active" | "completed" | "delegated" + status?: "active" | "completed" | "delegated" | "interrupted" + background?: boolean tokensIn?: number tokensOut?: number } diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 5a7bfdfa243..0045b82a29e 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -45,7 +45,8 @@ export const historyItemSchema = z.object({ workspace: z.string().optional(), mode: z.string().optional(), apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature - status: z.enum(["active", "completed", "delegated"]).optional(), + background: z.boolean().optional(), // true if this was a background task + status: z.enum(["active", "completed", "delegated", "interrupted"]).optional(), delegatedToId: z.string().optional(), // Last child this parent delegated to childIds: z.array(z.string()).optional(), // All children spawned by this task awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index d0fa680f8fc..1e4586aab1a 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -92,7 +92,7 @@ export interface CreateTaskOptions { experiments?: Record initialTodos?: TodoItem[] /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + initialStatus?: "active" | "delegated" | "completed" | "interrupted" /** Whether to start the task loop immediately (default: true). * When false, the caller must invoke `task.start()` manually. */ startTask?: boolean diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 4157d8b9fbb..b8f70841279 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -88,6 +88,9 @@ export class TaskHistoryStore { // 2. Reconcile cache against actual task directories on disk await this.reconcile() + // 2b. Mark interrupted background tasks (were active when VS Code closed) + this.markInterruptedBackgroundTasks() + // 3. Start fs.watch for cross-instance reactivity this.startWatcher() @@ -233,6 +236,24 @@ export class TaskHistoryStore { }) } + // ────────────────────────────── Background Task Recovery ────────────────────────────── + + /** + * Mark background tasks that were still active when VS Code closed as "interrupted". + * This runs after cache is loaded and reconciled during initialization. + */ + private markInterruptedBackgroundTasks(): void { + for (const [id, item] of this.cache) { + if (item.background && item.status === "active") { + this.cache.set(id, { ...item, status: "interrupted" }) + // Best-effort write of updated status to disk (fire-and-forget during init) + this.writeTaskFile({ ...item, status: "interrupted" }).catch((err) => { + console.error(`[TaskHistoryStore] Failed to mark background task ${id} as interrupted:`, err) + }) + } + } + } + // ────────────────────────────── Reconciliation ────────────────────────────── /** diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 8adc486160a..c42a7d88ffd 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -439,4 +439,58 @@ describe("TaskHistoryStore", () => { expect(store.get("gone-task")).toBeUndefined() }) }) + + describe("markInterruptedBackgroundTasks()", () => { + it("marks active background tasks as interrupted on initialize", async () => { + // Create a background task with active status before initializing + const taskDir = path.join(tmpDir, "tasks", "bg-active-task") + await fs.mkdir(taskDir, { recursive: true }) + const bgItem = makeHistoryItem({ + id: "bg-active-task", + background: true, + status: "active", + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem)) + + await store.initialize() + + const result = store.get("bg-active-task") + expect(result).toBeDefined() + expect(result!.status).toBe("interrupted") + expect(result!.background).toBe(true) + }) + + it("does not mark completed background tasks as interrupted", async () => { + const taskDir = path.join(tmpDir, "tasks", "bg-completed-task") + await fs.mkdir(taskDir, { recursive: true }) + const bgItem = makeHistoryItem({ + id: "bg-completed-task", + background: true, + status: "completed", + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(bgItem)) + + await store.initialize() + + const result = store.get("bg-completed-task") + expect(result).toBeDefined() + expect(result!.status).toBe("completed") + }) + + it("does not mark non-background active tasks as interrupted", async () => { + const taskDir = path.join(tmpDir, "tasks", "fg-active-task") + await fs.mkdir(taskDir, { recursive: true }) + const fgItem = makeHistoryItem({ + id: "fg-active-task", + status: "active", + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(fgItem)) + + await store.initialize() + + const result = store.get("fg-active-task") + expect(result).toBeDefined() + expect(result!.status).toBe("active") + }) + }) }) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 83dee5fb478..220b68d860c 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -24,9 +24,11 @@ export type TaskMetadataOptions = { /** Provider profile name for the task (sticky profile feature) */ apiConfigName?: string /** Initial status for the task (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + initialStatus?: "active" | "delegated" | "completed" | "interrupted" /** Permission boundaries for the task, set by the parent via new_task tool */ taskPermissions?: TaskPermissionsInput + /** Whether this is a background task */ + background?: boolean } export async function taskMetadata({ @@ -41,6 +43,7 @@ export async function taskMetadata({ apiConfigName, initialStatus, taskPermissions, + background, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -116,6 +119,7 @@ export async function taskMetadata({ ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), ...(taskPermissions && { taskPermissions }), + ...(background && { background: true }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7b8d5f74fe6..e86d20ce8c0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -158,7 +158,7 @@ export interface TaskOptions extends CreateTaskOptions { initialTodos?: TodoItem[] workspacePath?: string /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" + initialStatus?: "active" | "delegated" | "completed" | "interrupted" /** * Optional isolated task context containing mode, API config, and permissions. * When provided, the task uses this context instead of reading from the provider. @@ -435,7 +435,7 @@ export class Task extends EventEmitter implements TaskLike { // Cloud Sync Tracking // Initial status for the task's history item (set at creation time to avoid race conditions) - private readonly initialStatus?: "active" | "delegated" | "completed" + private readonly initialStatus?: "active" | "delegated" | "completed" | "interrupted" // MessageManager for high-level message operations (lazy initialized) private _messageManager?: MessageManager diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 1d6de93e64d..3672a4dacb6 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -41,6 +41,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + showBackgroundTasks, + setShowBackgroundTasks, } = useTaskSearch() const { t } = useAppTranslation() @@ -223,6 +225,30 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { +
{/* Select all control in selection mode */} diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index d0dc367e646..a1d440781e8 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -6,7 +6,7 @@ import { ExportButton } from "./ExportButton" import { DeleteButton } from "./DeleteButton" import { StandardTooltip } from "../ui/standard-tooltip" import { useAppTranslation } from "@/i18n/TranslationContext" -import { Split } from "lucide-react" +import { Split, Layers, AlertTriangle } from "lucide-react" export interface TaskItemFooterProps { item: HistoryItem @@ -28,6 +28,20 @@ const TaskItemFooter: React.FC = ({ return (
+ {/* Background task tag */} + {item.background && ( + <> + {item.status === "interrupted" ? ( + + ) : ( + + )} + + {item.status === "interrupted" ? t("history:interruptedTag") : t("history:backgroundTag")} + + · + + )} {/* Subtask tag */} {isSubtask && ( <> diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index aa334d94c26..337ec2aecd9 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -94,4 +94,33 @@ describe("TaskItemFooter", () => { expect(screen.queryByText("history:subtaskTag")).not.toBeInTheDocument() }) + + it("shows background tag when item.background is true", () => { + const backgroundItem = { ...mockItem, background: true } + render() + + expect(screen.getByText("history:backgroundTag")).toBeInTheDocument() + }) + + it("does not show background tag when item.background is falsy", () => { + render() + + expect(screen.queryByText("history:backgroundTag")).not.toBeInTheDocument() + }) + + it("shows interrupted tag when item is a background task with interrupted status", () => { + const interruptedItem = { ...mockItem, background: true, status: "interrupted" as const } + render() + + expect(screen.getByText("history:interruptedTag")).toBeInTheDocument() + expect(screen.queryByText("history:backgroundTag")).not.toBeInTheDocument() + }) + + it("shows background tag instead of interrupted for active background tasks", () => { + const activeBackgroundItem = { ...mockItem, background: true, status: "active" as const } + render() + + expect(screen.getByText("history:backgroundTag")).toBeInTheDocument() + expect(screen.queryByText("history:interruptedTag")).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index bea79814fa1..b99c9ca598d 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -284,4 +284,65 @@ describe("useTaskSearch", () => { // When not searching, it should fall back to newest expect(result.current.sortOption).toBe("mostRelevant") }) + + it("shows background tasks by default", () => { + const taskHistoryWithBackground: HistoryItem[] = [ + ...mockTaskHistory, + { + id: "task-bg", + number: 4, + task: "Background task", + ts: new Date("2022-02-18T12:00:00").getTime(), + tokensIn: 50, + tokensOut: 25, + totalCost: 0.005, + workspace: "/workspace/project1", + background: true, + }, + ] + + mockUseExtensionState.mockReturnValue({ + taskHistory: taskHistoryWithBackground, + cwd: "/workspace/project1", + } as any) + + const { result } = renderHook(() => useTaskSearch()) + + // Background tasks should be included by default + expect(result.current.showBackgroundTasks).toBe(true) + expect(result.current.tasks.some((task) => task.id === "task-bg")).toBe(true) + }) + + it("hides background tasks when showBackgroundTasks is false", () => { + const taskHistoryWithBackground: HistoryItem[] = [ + ...mockTaskHistory, + { + id: "task-bg", + number: 4, + task: "Background task", + ts: new Date("2022-02-18T12:00:00").getTime(), + tokensIn: 50, + tokensOut: 25, + totalCost: 0.005, + workspace: "/workspace/project1", + background: true, + }, + ] + + mockUseExtensionState.mockReturnValue({ + taskHistory: taskHistoryWithBackground, + cwd: "/workspace/project1", + } as any) + + const { result } = renderHook(() => useTaskSearch()) + + act(() => { + result.current.setShowBackgroundTasks(false) + }) + + // Background tasks should be hidden + expect(result.current.tasks.some((task) => task.id === "task-bg")).toBe(false) + // Non-background tasks should still be visible + expect(result.current.tasks.some((task) => task.id === "task-1")).toBe(true) + }) }) diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3969985b98a..e4cd6a98ca7 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -12,6 +12,7 @@ export const useTaskSearch = () => { const [sortOption, setSortOption] = useState("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") const [showAllWorkspaces, setShowAllWorkspaces] = useState(false) + const [showBackgroundTasks, setShowBackgroundTasks] = useState(true) useEffect(() => { if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { @@ -28,8 +29,11 @@ export const useTaskSearch = () => { if (!showAllWorkspaces) { tasks = tasks.filter((item) => item.workspace === cwd) } + if (!showBackgroundTasks) { + tasks = tasks.filter((item) => !item.background) + } return tasks - }, [taskHistory, showAllWorkspaces, cwd]) + }, [taskHistory, showAllWorkspaces, showBackgroundTasks, cwd]) const fzf = useMemo(() => { return new Fzf(presentableTasks, { @@ -88,5 +92,7 @@ export const useTaskSearch = () => { setLastNonRelevantSort, showAllWorkspaces, setShowAllWorkspaces, + showBackgroundTasks, + setShowBackgroundTasks, } } diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 85174890e14..6d1d54606f1 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "This will also delete {{count}} subtask(s). Are you sure?", "expandSubtasks": "Expand subtasks", - "collapseSubtasks": "Collapse subtasks" + "collapseSubtasks": "Collapse subtasks", + "backgroundTag": "Background", + "interruptedTag": "Interrupted", + "showBackgroundTasks": "Show background tasks", + "hideBackgroundTasks": "Hide background tasks", + "filter": { + "prefix": "Filter:", + "all": "All Tasks", + "foregroundOnly": "Foreground Only" + } } From 145263b385b2974fa16e1f4f62d073f1ad041fac Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 16:39:19 +0000 Subject: [PATCH 23/24] fix: add missing i18n translations for background task history keys Add backgroundTag, interruptedTag, showBackgroundTasks, hideBackgroundTasks, and filter.* keys to all 17 non-English locale files (ca, de, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW). --- webview-ui/src/i18n/locales/ca/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/de/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/es/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/fr/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/hi/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/id/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/it/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/ja/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/ko/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/nl/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/pl/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/pt-BR/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/ru/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/tr/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/vi/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/zh-CN/history.json | 11 ++++++++++- webview-ui/src/i18n/locales/zh-TW/history.json | 11 ++++++++++- 17 files changed, 170 insertions(+), 17 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/history.json b/webview-ui/src/i18n/locales/ca/history.json index ab1bba7582e..7c0ced7e5f2 100644 --- a/webview-ui/src/i18n/locales/ca/history.json +++ b/webview-ui/src/i18n/locales/ca/history.json @@ -54,5 +54,14 @@ "subtaskTag": "Subtasca", "deleteWithSubtasks": "Això també eliminarà {{count}} subtasca(s). Estàs segur?", "expandSubtasks": "Expandir subtasques", - "collapseSubtasks": "Contreure subtasques" + "collapseSubtasks": "Contreure subtasques", + "backgroundTag": "Segon pla", + "interruptedTag": "Interrompuda", + "showBackgroundTasks": "Mostrar tasques en segon pla", + "hideBackgroundTasks": "Amagar tasques en segon pla", + "filter": { + "prefix": "Filtre:", + "all": "Totes les tasques", + "foregroundOnly": "Només primer pla" + } } diff --git a/webview-ui/src/i18n/locales/de/history.json b/webview-ui/src/i18n/locales/de/history.json index 46064d6e2ea..2006bf2645d 100644 --- a/webview-ui/src/i18n/locales/de/history.json +++ b/webview-ui/src/i18n/locales/de/history.json @@ -54,5 +54,14 @@ "subtaskTag": "Teilaufgabe", "deleteWithSubtasks": "Dies löscht auch {{count}} Teilaufgabe(n). Bist du sicher?", "expandSubtasks": "Teilaufgaben erweitern", - "collapseSubtasks": "Teilaufgaben einklappen" + "collapseSubtasks": "Teilaufgaben einklappen", + "backgroundTag": "Hintergrund", + "interruptedTag": "Unterbrochen", + "showBackgroundTasks": "Hintergrundaufgaben anzeigen", + "hideBackgroundTasks": "Hintergrundaufgaben ausblenden", + "filter": { + "prefix": "Filter:", + "all": "Alle Aufgaben", + "foregroundOnly": "Nur Vordergrund" + } } diff --git a/webview-ui/src/i18n/locales/es/history.json b/webview-ui/src/i18n/locales/es/history.json index 820f003d402..f05bf529004 100644 --- a/webview-ui/src/i18n/locales/es/history.json +++ b/webview-ui/src/i18n/locales/es/history.json @@ -54,5 +54,14 @@ "subtaskTag": "Subtarea", "deleteWithSubtasks": "Esto también eliminará {{count}} subtarea(s). ¿Estás seguro?", "expandSubtasks": "Expandir subtareas", - "collapseSubtasks": "Contraer subtareas" + "collapseSubtasks": "Contraer subtareas", + "backgroundTag": "Segundo plano", + "interruptedTag": "Interrumpida", + "showBackgroundTasks": "Mostrar tareas en segundo plano", + "hideBackgroundTasks": "Ocultar tareas en segundo plano", + "filter": { + "prefix": "Filtro:", + "all": "Todas las tareas", + "foregroundOnly": "Solo primer plano" + } } diff --git a/webview-ui/src/i18n/locales/fr/history.json b/webview-ui/src/i18n/locales/fr/history.json index d84cfcb190a..eaf219e626c 100644 --- a/webview-ui/src/i18n/locales/fr/history.json +++ b/webview-ui/src/i18n/locales/fr/history.json @@ -54,5 +54,14 @@ "subtaskTag": "Sous-tâche", "deleteWithSubtasks": "Cela supprimera aussi {{count}} sous-tâche(s). Êtes-vous sûr ?", "expandSubtasks": "Développer les sous-tâches", - "collapseSubtasks": "Réduire les sous-tâches" + "collapseSubtasks": "Réduire les sous-tâches", + "backgroundTag": "Arrière-plan", + "interruptedTag": "Interrompue", + "showBackgroundTasks": "Afficher les tâches en arrière-plan", + "hideBackgroundTasks": "Masquer les tâches en arrière-plan", + "filter": { + "prefix": "Filtre :", + "all": "Toutes les tâches", + "foregroundOnly": "Premier plan uniquement" + } } diff --git a/webview-ui/src/i18n/locales/hi/history.json b/webview-ui/src/i18n/locales/hi/history.json index 0d4cb40dd9e..81eea38e7ce 100644 --- a/webview-ui/src/i18n/locales/hi/history.json +++ b/webview-ui/src/i18n/locales/hi/history.json @@ -47,5 +47,14 @@ "subtaskTag": "उप-कार्य", "deleteWithSubtasks": "यह {{count}} उप-कार्य(कों) को भी हटा देगा। क्या आप निश्चित हैं?", "expandSubtasks": "उप-कार्य विस्तारित करें", - "collapseSubtasks": "उप-कार्य संपीड़ित करें" + "collapseSubtasks": "उप-कार्य संपीड़ित करें", + "backgroundTag": "बैकग्राउंड", + "interruptedTag": "बाधित", + "showBackgroundTasks": "बैकग्राउंड टास्क दिखाएं", + "hideBackgroundTasks": "बैकग्राउंड टास्क छुपाएं", + "filter": { + "prefix": "फ़िल्टर:", + "all": "सभी टास्क", + "foregroundOnly": "केवल फ़ोरग्राउंड" + } } diff --git a/webview-ui/src/i18n/locales/id/history.json b/webview-ui/src/i18n/locales/id/history.json index 7796061107e..5ae44f939f1 100644 --- a/webview-ui/src/i18n/locales/id/history.json +++ b/webview-ui/src/i18n/locales/id/history.json @@ -56,5 +56,14 @@ "subtaskTag": "Subtask", "deleteWithSubtasks": "Ini juga akan menghapus {{count}} subtask. Apakah Anda yakin?", "expandSubtasks": "Perluas subtask", - "collapseSubtasks": "Tutup subtask" + "collapseSubtasks": "Tutup subtask", + "backgroundTag": "Latar belakang", + "interruptedTag": "Terinterupsi", + "showBackgroundTasks": "Tampilkan tugas latar belakang", + "hideBackgroundTasks": "Sembunyikan tugas latar belakang", + "filter": { + "prefix": "Filter:", + "all": "Semua Tugas", + "foregroundOnly": "Hanya Latar Depan" + } } diff --git a/webview-ui/src/i18n/locales/it/history.json b/webview-ui/src/i18n/locales/it/history.json index aa728ef8f60..340e7d59ea8 100644 --- a/webview-ui/src/i18n/locales/it/history.json +++ b/webview-ui/src/i18n/locales/it/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Sottoattività", "deleteWithSubtasks": "Questo eliminerà anche {{count}} sottoattività. Sei sicuro?", "expandSubtasks": "Espandi sottoattività", - "collapseSubtasks": "Comprimi sottoattività" + "collapseSubtasks": "Comprimi sottoattività", + "backgroundTag": "Background", + "interruptedTag": "Interrotto", + "showBackgroundTasks": "Mostra attività in background", + "hideBackgroundTasks": "Nascondi attività in background", + "filter": { + "prefix": "Filtro:", + "all": "Tutte le attività", + "foregroundOnly": "Solo primo piano" + } } diff --git a/webview-ui/src/i18n/locales/ja/history.json b/webview-ui/src/i18n/locales/ja/history.json index b73baa3a763..6d47b2d217a 100644 --- a/webview-ui/src/i18n/locales/ja/history.json +++ b/webview-ui/src/i18n/locales/ja/history.json @@ -47,5 +47,14 @@ "subtaskTag": "サブタスク", "deleteWithSubtasks": "これにより {{count}} サブタスクも削除されます。よろしいですか?", "expandSubtasks": "サブタスクを展開", - "collapseSubtasks": "サブタスクを折りたたむ" + "collapseSubtasks": "サブタスクを折りたたむ", + "backgroundTag": "バックグラウンド", + "interruptedTag": "中断", + "showBackgroundTasks": "バックグラウンドタスクを表示", + "hideBackgroundTasks": "バックグラウンドタスクを非表示", + "filter": { + "prefix": "フィルター:", + "all": "すべてのタスク", + "foregroundOnly": "フォアグラウンドのみ" + } } diff --git a/webview-ui/src/i18n/locales/ko/history.json b/webview-ui/src/i18n/locales/ko/history.json index 0363feaaffb..3b9c3e88f90 100644 --- a/webview-ui/src/i18n/locales/ko/history.json +++ b/webview-ui/src/i18n/locales/ko/history.json @@ -47,5 +47,14 @@ "subtaskTag": "부분작업", "deleteWithSubtasks": "이는 {{count}} 부분작업도 삭제합니다. 확실하십니까?", "expandSubtasks": "부분작업 확장", - "collapseSubtasks": "부분작업 축소" + "collapseSubtasks": "부분작업 축소", + "backgroundTag": "백그라운드", + "interruptedTag": "중단됨", + "showBackgroundTasks": "백그라운드 작업 표시", + "hideBackgroundTasks": "백그라운드 작업 숨기기", + "filter": { + "prefix": "필터:", + "all": "모든 작업", + "foregroundOnly": "포그라운드만" + } } diff --git a/webview-ui/src/i18n/locales/nl/history.json b/webview-ui/src/i18n/locales/nl/history.json index 012059ed047..cd769522e5f 100644 --- a/webview-ui/src/i18n/locales/nl/history.json +++ b/webview-ui/src/i18n/locales/nl/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Subtaak", "deleteWithSubtasks": "Dit zal ook {{count}} subtaak(en) verwijderen. Weet je het zeker?", "expandSubtasks": "Subtaken uitvouwen", - "collapseSubtasks": "Subtaken samenvouwen" + "collapseSubtasks": "Subtaken samenvouwen", + "backgroundTag": "Achtergrond", + "interruptedTag": "Onderbroken", + "showBackgroundTasks": "Achtergrondtaken weergeven", + "hideBackgroundTasks": "Achtergrondtaken verbergen", + "filter": { + "prefix": "Filter:", + "all": "Alle taken", + "foregroundOnly": "Alleen voorgrond" + } } diff --git a/webview-ui/src/i18n/locales/pl/history.json b/webview-ui/src/i18n/locales/pl/history.json index 7ec4b40d8f5..859f3ce9b98 100644 --- a/webview-ui/src/i18n/locales/pl/history.json +++ b/webview-ui/src/i18n/locales/pl/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Podzadanie", "deleteWithSubtasks": "Spowoduje to usunięcie {{count}} podzadania(ń). Jesteś pewny?", "expandSubtasks": "Rozwiń podzadania", - "collapseSubtasks": "Zwiń podzadania" + "collapseSubtasks": "Zwiń podzadania", + "backgroundTag": "W tle", + "interruptedTag": "Przerwane", + "showBackgroundTasks": "Pokaż zadania w tle", + "hideBackgroundTasks": "Ukryj zadania w tle", + "filter": { + "prefix": "Filtr:", + "all": "Wszystkie zadania", + "foregroundOnly": "Tylko na pierwszym planie" + } } diff --git a/webview-ui/src/i18n/locales/pt-BR/history.json b/webview-ui/src/i18n/locales/pt-BR/history.json index 7966df1f463..202df2173c8 100644 --- a/webview-ui/src/i18n/locales/pt-BR/history.json +++ b/webview-ui/src/i18n/locales/pt-BR/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Subtarefa", "deleteWithSubtasks": "Isso também excluirá {{count}} subtarefa(s). Tem certeza?", "expandSubtasks": "Expandir subtarefas", - "collapseSubtasks": "Recolher subtarefas" + "collapseSubtasks": "Recolher subtarefas", + "backgroundTag": "Segundo plano", + "interruptedTag": "Interrompida", + "showBackgroundTasks": "Mostrar tarefas em segundo plano", + "hideBackgroundTasks": "Ocultar tarefas em segundo plano", + "filter": { + "prefix": "Filtro:", + "all": "Todas as tarefas", + "foregroundOnly": "Somente primeiro plano" + } } diff --git a/webview-ui/src/i18n/locales/ru/history.json b/webview-ui/src/i18n/locales/ru/history.json index 7852362348b..25214a17d74 100644 --- a/webview-ui/src/i18n/locales/ru/history.json +++ b/webview-ui/src/i18n/locales/ru/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Подзадача", "deleteWithSubtasks": "Это также удалит {{count}} подзадачу(и). Вы уверены?", "expandSubtasks": "Развернуть подзадачи", - "collapseSubtasks": "Свернуть подзадачи" + "collapseSubtasks": "Свернуть подзадачи", + "backgroundTag": "Фоновая", + "interruptedTag": "Прервана", + "showBackgroundTasks": "Показать фоновые задачи", + "hideBackgroundTasks": "Скрыть фоновые задачи", + "filter": { + "prefix": "Фильтр:", + "all": "Все задачи", + "foregroundOnly": "Только активные" + } } diff --git a/webview-ui/src/i18n/locales/tr/history.json b/webview-ui/src/i18n/locales/tr/history.json index fb7b6c68320..183ccbce310 100644 --- a/webview-ui/src/i18n/locales/tr/history.json +++ b/webview-ui/src/i18n/locales/tr/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Alt görev", "deleteWithSubtasks": "Bu, {{count}} alt görev(i) de silecektir. Emin misiniz?", "expandSubtasks": "Alt görevleri genişlet", - "collapseSubtasks": "Alt görevleri daralt" + "collapseSubtasks": "Alt görevleri daralt", + "backgroundTag": "Arka plan", + "interruptedTag": "Kesintiye uğradı", + "showBackgroundTasks": "Arka plan görevlerini göster", + "hideBackgroundTasks": "Arka plan görevlerini gizle", + "filter": { + "prefix": "Filtre:", + "all": "Tüm Görevler", + "foregroundOnly": "Yalnızca Ön Plan" + } } diff --git a/webview-ui/src/i18n/locales/vi/history.json b/webview-ui/src/i18n/locales/vi/history.json index 779953e5406..22db4c20bc5 100644 --- a/webview-ui/src/i18n/locales/vi/history.json +++ b/webview-ui/src/i18n/locales/vi/history.json @@ -47,5 +47,14 @@ "subtaskTag": "Tác vụ con", "deleteWithSubtasks": "Điều này cũng sẽ xóa {{count}} tác vụ con. Bạn có chắc không?", "expandSubtasks": "Mở rộng tác vụ con", - "collapseSubtasks": "Thu gọn tác vụ con" + "collapseSubtasks": "Thu gọn tác vụ con", + "backgroundTag": "Nền", + "interruptedTag": "Bị gián đoạn", + "showBackgroundTasks": "Hiển thị tác vụ nền", + "hideBackgroundTasks": "Ẩn tác vụ nền", + "filter": { + "prefix": "Bộ lọc:", + "all": "Tất cả tác vụ", + "foregroundOnly": "Chỉ tiền cảnh" + } } diff --git a/webview-ui/src/i18n/locales/zh-CN/history.json b/webview-ui/src/i18n/locales/zh-CN/history.json index 20a73240ea9..32e5a7bb472 100644 --- a/webview-ui/src/i18n/locales/zh-CN/history.json +++ b/webview-ui/src/i18n/locales/zh-CN/history.json @@ -47,5 +47,14 @@ "subtaskTag": "子任务", "deleteWithSubtasks": "这也将删除 {{count}} 个子任务。您确定吗?", "expandSubtasks": "展开子任务", - "collapseSubtasks": "收起子任务" + "collapseSubtasks": "收起子任务", + "backgroundTag": "后台", + "interruptedTag": "已中断", + "showBackgroundTasks": "显示后台任务", + "hideBackgroundTasks": "隐藏后台任务", + "filter": { + "prefix": "筛选:", + "all": "所有任务", + "foregroundOnly": "仅前台" + } } diff --git a/webview-ui/src/i18n/locales/zh-TW/history.json b/webview-ui/src/i18n/locales/zh-TW/history.json index 1e12190a69b..6b5bc577641 100644 --- a/webview-ui/src/i18n/locales/zh-TW/history.json +++ b/webview-ui/src/i18n/locales/zh-TW/history.json @@ -47,5 +47,14 @@ "subtaskTag": "子工作", "deleteWithSubtasks": "這也將刪除 {{count}} 個子工作。您確定嗎?", "expandSubtasks": "展開子工作", - "collapseSubtasks": "收起子工作" + "collapseSubtasks": "收起子工作", + "backgroundTag": "背景", + "interruptedTag": "已中斷", + "showBackgroundTasks": "顯示背景工作", + "hideBackgroundTasks": "隱藏背景工作", + "filter": { + "prefix": "篩選:", + "all": "所有工作", + "foregroundOnly": "僅前景" + } } From 19976973cbf56eafa0c1e9a5d57e348b2353b406 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 16:50:43 +0000 Subject: [PATCH 24/24] feat: consolidate Phase 7 + wire replay view for background tasks + add interrupted tooltip - Consolidate Phase 7a (FileLockManager), 7b (LockGuardedToolExecutor + tool runner integration), and 7c (persistent background task history) into single branch - Wire background task clicks in History view to open the read-only BackgroundTaskReplayView instead of creating a resumable task - Add StandardTooltip on interrupted background tasks explaining they were interrupted due to VS Code closing - Add interruptedTooltip i18n key across all locales - Add tests for replay view routing and tooltip rendering Addresses feedback from #12330 --- packages/types/src/index.ts | 2 +- packages/types/src/vscode-extension-host.ts | 3 +- .../prompts/tools/native-tools/new_task.ts | 1 + .../src/components/history/TaskItem.tsx | 3 ++ .../src/components/history/TaskItemFooter.tsx | 15 ++++--- .../history/__tests__/TaskItem.spec.tsx | 44 +++++++++++++++++++ .../history/__tests__/TaskItemFooter.spec.tsx | 13 ++++++ webview-ui/src/i18n/locales/ca/history.json | 3 +- webview-ui/src/i18n/locales/de/history.json | 3 +- webview-ui/src/i18n/locales/en/history.json | 1 + webview-ui/src/i18n/locales/es/history.json | 3 +- webview-ui/src/i18n/locales/fr/history.json | 3 +- webview-ui/src/i18n/locales/hi/history.json | 3 +- webview-ui/src/i18n/locales/id/history.json | 3 +- webview-ui/src/i18n/locales/it/history.json | 3 +- webview-ui/src/i18n/locales/ja/history.json | 3 +- webview-ui/src/i18n/locales/ko/history.json | 3 +- webview-ui/src/i18n/locales/nl/history.json | 3 +- webview-ui/src/i18n/locales/pl/history.json | 3 +- .../src/i18n/locales/pt-BR/history.json | 3 +- webview-ui/src/i18n/locales/ru/history.json | 3 +- webview-ui/src/i18n/locales/tr/history.json | 3 +- webview-ui/src/i18n/locales/vi/history.json | 3 +- .../src/i18n/locales/zh-CN/history.json | 3 +- .../src/i18n/locales/zh-TW/history.json | 3 +- 25 files changed, 108 insertions(+), 25 deletions(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1eec32bbeae..5e954732df8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -21,7 +21,7 @@ export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" export * from "./task.js" -export * from "./task-context.js" +export { taskContextSchema, type TaskContext, mergePermissions } from "./task-context.js" export * from "./task-permissions.js" export * from "./todo.js" export * from "./skills.js" diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index d47cf2d4900..01b6fdbbd0c 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -573,8 +573,7 @@ export interface WebviewMessage { text?: string taskId?: string editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "cloud" - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "bgTaskReplay" | "bgTask" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "cloud" | "bgTaskReplay" | "bgTask" disabled?: boolean context?: string dataUri?: string diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f846eaf73a0..f0c22f23525 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -44,6 +44,7 @@ export default { permissions: { type: ["string", "null"], description: PERMISSIONS_PARAMETER_DESCRIPTION, + }, background: { type: ["string", "null"], description: BACKGROUND_PARAMETER_DESCRIPTION, diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index eba5e59ac94..9cb21047134 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -35,6 +35,9 @@ const TaskItem = ({ const handleClick = () => { if (isSelectionMode && onToggleSelection) { onToggleSelection(item.id, !isSelected) + } else if (item.background) { + // Background tasks open in the read-only replay view + vscode.postMessage({ type: "switchTab", tab: "bgTaskReplay", values: { taskId: item.id } }) } else { vscode.postMessage({ type: "showTaskWithId", text: item.id }) } diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index a1d440781e8..465c16100cb 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -32,13 +32,18 @@ const TaskItemFooter: React.FC = ({ {item.background && ( <> {item.status === "interrupted" ? ( - + + + + {t("history:interruptedTag")} + + ) : ( - + <> + + {t("history:backgroundTag")} + )} - - {item.status === "interrupted" ? t("history:interruptedTag") : t("history:backgroundTag")} - · )} diff --git a/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx index df8fc742d3c..9651327c01d 100644 --- a/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx @@ -109,4 +109,48 @@ describe("TaskItem", () => { const taskItem = screen.getByTestId("task-item-1") expect(taskItem).toHaveClass("hover:text-vscode-foreground") }) + + it("sends switchTab message for background tasks to open replay view", async () => { + const { vscode } = await import("@/utils/vscode") + const backgroundTask = { ...mockTask, id: "bg-1", background: true } + + render( + , + ) + + fireEvent.click(screen.getByTestId("task-item-bg-1")) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "switchTab", + tab: "bgTaskReplay", + values: { taskId: "bg-1" }, + }) + }) + + it("sends showTaskWithId message for non-background tasks", async () => { + const { vscode } = await import("@/utils/vscode") + + render( + , + ) + + fireEvent.click(screen.getByTestId("task-item-1")) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "showTaskWithId", + text: "1", + }) + }) }) diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index 337ec2aecd9..6de1ab0fd38 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -123,4 +123,17 @@ describe("TaskItemFooter", () => { expect(screen.getByText("history:backgroundTag")).toBeInTheDocument() expect(screen.queryByText("history:interruptedTag")).not.toBeInTheDocument() }) + + it("wraps interrupted tag in a tooltip explaining VS Code was closed", () => { + const interruptedItem = { ...mockItem, background: true, status: "interrupted" as const } + render() + + // The interrupted tag should be present + expect(screen.getByText("history:interruptedTag")).toBeInTheDocument() + // The tooltip trigger wraps the tag -- verify the tooltip content key is used + // StandardTooltip renders a trigger element with the content as a prop + const tagElement = screen.getByText("history:interruptedTag") + // The tag and icon should be grouped inside a styled span + expect(tagElement.closest("span")).toHaveClass("text-vscode-editorWarning-foreground") + }) }) diff --git a/webview-ui/src/i18n/locales/ca/history.json b/webview-ui/src/i18n/locales/ca/history.json index 7c0ced7e5f2..0601cbb5569 100644 --- a/webview-ui/src/i18n/locales/ca/history.json +++ b/webview-ui/src/i18n/locales/ca/history.json @@ -63,5 +63,6 @@ "prefix": "Filtre:", "all": "Totes les tasques", "foregroundOnly": "Només primer pla" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/de/history.json b/webview-ui/src/i18n/locales/de/history.json index 2006bf2645d..98abd97fec0 100644 --- a/webview-ui/src/i18n/locales/de/history.json +++ b/webview-ui/src/i18n/locales/de/history.json @@ -63,5 +63,6 @@ "prefix": "Filter:", "all": "Alle Aufgaben", "foregroundOnly": "Nur Vordergrund" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 6d1d54606f1..e4429459d12 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -50,6 +50,7 @@ "collapseSubtasks": "Collapse subtasks", "backgroundTag": "Background", "interruptedTag": "Interrupted", + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running.", "showBackgroundTasks": "Show background tasks", "hideBackgroundTasks": "Hide background tasks", "filter": { diff --git a/webview-ui/src/i18n/locales/es/history.json b/webview-ui/src/i18n/locales/es/history.json index f05bf529004..18c8ea4eaa0 100644 --- a/webview-ui/src/i18n/locales/es/history.json +++ b/webview-ui/src/i18n/locales/es/history.json @@ -63,5 +63,6 @@ "prefix": "Filtro:", "all": "Todas las tareas", "foregroundOnly": "Solo primer plano" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/fr/history.json b/webview-ui/src/i18n/locales/fr/history.json index eaf219e626c..f2782169187 100644 --- a/webview-ui/src/i18n/locales/fr/history.json +++ b/webview-ui/src/i18n/locales/fr/history.json @@ -63,5 +63,6 @@ "prefix": "Filtre :", "all": "Toutes les tâches", "foregroundOnly": "Premier plan uniquement" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/hi/history.json b/webview-ui/src/i18n/locales/hi/history.json index 81eea38e7ce..2bf9aa3a6e9 100644 --- a/webview-ui/src/i18n/locales/hi/history.json +++ b/webview-ui/src/i18n/locales/hi/history.json @@ -56,5 +56,6 @@ "prefix": "फ़िल्टर:", "all": "सभी टास्क", "foregroundOnly": "केवल फ़ोरग्राउंड" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/id/history.json b/webview-ui/src/i18n/locales/id/history.json index 5ae44f939f1..a874a632712 100644 --- a/webview-ui/src/i18n/locales/id/history.json +++ b/webview-ui/src/i18n/locales/id/history.json @@ -65,5 +65,6 @@ "prefix": "Filter:", "all": "Semua Tugas", "foregroundOnly": "Hanya Latar Depan" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/it/history.json b/webview-ui/src/i18n/locales/it/history.json index 340e7d59ea8..454033ea57f 100644 --- a/webview-ui/src/i18n/locales/it/history.json +++ b/webview-ui/src/i18n/locales/it/history.json @@ -56,5 +56,6 @@ "prefix": "Filtro:", "all": "Tutte le attività", "foregroundOnly": "Solo primo piano" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/ja/history.json b/webview-ui/src/i18n/locales/ja/history.json index 6d47b2d217a..e16efbb26e0 100644 --- a/webview-ui/src/i18n/locales/ja/history.json +++ b/webview-ui/src/i18n/locales/ja/history.json @@ -56,5 +56,6 @@ "prefix": "フィルター:", "all": "すべてのタスク", "foregroundOnly": "フォアグラウンドのみ" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/ko/history.json b/webview-ui/src/i18n/locales/ko/history.json index 3b9c3e88f90..76ee71d839d 100644 --- a/webview-ui/src/i18n/locales/ko/history.json +++ b/webview-ui/src/i18n/locales/ko/history.json @@ -56,5 +56,6 @@ "prefix": "필터:", "all": "모든 작업", "foregroundOnly": "포그라운드만" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/nl/history.json b/webview-ui/src/i18n/locales/nl/history.json index cd769522e5f..216de93a520 100644 --- a/webview-ui/src/i18n/locales/nl/history.json +++ b/webview-ui/src/i18n/locales/nl/history.json @@ -56,5 +56,6 @@ "prefix": "Filter:", "all": "Alle taken", "foregroundOnly": "Alleen voorgrond" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/pl/history.json b/webview-ui/src/i18n/locales/pl/history.json index 859f3ce9b98..0932d0b216f 100644 --- a/webview-ui/src/i18n/locales/pl/history.json +++ b/webview-ui/src/i18n/locales/pl/history.json @@ -56,5 +56,6 @@ "prefix": "Filtr:", "all": "Wszystkie zadania", "foregroundOnly": "Tylko na pierwszym planie" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/pt-BR/history.json b/webview-ui/src/i18n/locales/pt-BR/history.json index 202df2173c8..ad5b785b03e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/history.json +++ b/webview-ui/src/i18n/locales/pt-BR/history.json @@ -56,5 +56,6 @@ "prefix": "Filtro:", "all": "Todas as tarefas", "foregroundOnly": "Somente primeiro plano" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/ru/history.json b/webview-ui/src/i18n/locales/ru/history.json index 25214a17d74..70c332f34fe 100644 --- a/webview-ui/src/i18n/locales/ru/history.json +++ b/webview-ui/src/i18n/locales/ru/history.json @@ -56,5 +56,6 @@ "prefix": "Фильтр:", "all": "Все задачи", "foregroundOnly": "Только активные" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/tr/history.json b/webview-ui/src/i18n/locales/tr/history.json index 183ccbce310..aeee83bbf59 100644 --- a/webview-ui/src/i18n/locales/tr/history.json +++ b/webview-ui/src/i18n/locales/tr/history.json @@ -56,5 +56,6 @@ "prefix": "Filtre:", "all": "Tüm Görevler", "foregroundOnly": "Yalnızca Ön Plan" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/vi/history.json b/webview-ui/src/i18n/locales/vi/history.json index 22db4c20bc5..c7192e9ce83 100644 --- a/webview-ui/src/i18n/locales/vi/history.json +++ b/webview-ui/src/i18n/locales/vi/history.json @@ -56,5 +56,6 @@ "prefix": "Bộ lọc:", "all": "Tất cả tác vụ", "foregroundOnly": "Chỉ tiền cảnh" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/zh-CN/history.json b/webview-ui/src/i18n/locales/zh-CN/history.json index 32e5a7bb472..dd7b9b79b70 100644 --- a/webview-ui/src/i18n/locales/zh-CN/history.json +++ b/webview-ui/src/i18n/locales/zh-CN/history.json @@ -56,5 +56,6 @@ "prefix": "筛选:", "all": "所有任务", "foregroundOnly": "仅前台" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." } diff --git a/webview-ui/src/i18n/locales/zh-TW/history.json b/webview-ui/src/i18n/locales/zh-TW/history.json index 6b5bc577641..367e5e04ea6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/history.json +++ b/webview-ui/src/i18n/locales/zh-TW/history.json @@ -56,5 +56,6 @@ "prefix": "篩選:", "all": "所有工作", "foregroundOnly": "僅前景" - } + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." }