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/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/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 952e823eeca..c4898015128 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -4,6 +4,42 @@ import { execSync } from "child_process" import { ViewsContainer, Views, Menus, Configuration, Keybindings, contributesSchema } from "./types.js" +/** + * Copy a single file with retry logic to handle transient Windows file-locking + * errors (EBUSY, EPERM, EACCES) that occur when antivirus or indexing services + * hold brief locks on files during CI builds. + */ +function copyFileWithRetry(src: string, dst: string, maxRetries: number = 5): void { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + fs.copyFileSync(src, dst) + return + } catch (error) { + const isRetryable = + error instanceof Error && + "code" in error && + ((error as NodeJS.ErrnoException).code === "EBUSY" || + (error as NodeJS.ErrnoException).code === "EPERM" || + (error as NodeJS.ErrnoException).code === "EACCES") + + if (!isRetryable || attempt === maxRetries) { + throw error + } + + const baseDelay = process.platform === "win32" ? 200 : 100 + const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), 2000) + console.warn(`[copyFileWithRetry] Attempt ${attempt} failed for ${src}, retrying in ${delay}ms...`) + + // Synchronous sleep (same pattern as rmDir). + const start = Date.now() + + while (Date.now() - start < delay) { + /* Busy wait */ + } + } + } +} + function copyDir(srcDir: string, dstDir: string, count: number): number { const entries = fs.readdirSync(srcDir, { withFileTypes: true }) @@ -16,7 +52,7 @@ function copyDir(srcDir: string, dstDir: string, count: number): number { count = copyDir(srcPath, dstPath, count) } else { count = count + 1 - fs.copyFileSync(srcPath, dstPath) + copyFileWithRetry(srcPath, dstPath) } } @@ -98,7 +134,7 @@ export function copyPaths(copyPaths: [string, string, CopyPathOptions?][], srcDi const count = copyDir(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath), 0) console.log(`[copyPaths] Copied ${count} files from ${srcRelPath} to ${dstRelPath}`) } else { - fs.copyFileSync(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath)) + copyFileWithRetry(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath)) console.log(`[copyPaths] Copied ${srcRelPath} to ${dstRelPath}`) } } catch (error) { diff --git a/packages/types/src/__tests__/context-handoff.spec.ts b/packages/types/src/__tests__/context-handoff.spec.ts new file mode 100644 index 00000000000..4bfc90d57b9 --- /dev/null +++ b/packages/types/src/__tests__/context-handoff.spec.ts @@ -0,0 +1,51 @@ +import { contextHandoffSummarySchema } from "../context-handoff.js" + +describe("ContextHandoffSummary schema", () => { + it("validates a complete summary", () => { + const summary = { + mode: "code", + filesModified: ["src/app.ts", "src/utils.ts"], + filesRead: ["src/config.ts"], + commandsExecuted: ["npm test"], + toolUsageCounts: { write_to_file: 2, read_file: 1 }, + apiRequestCount: 5, + result: "Task completed successfully", + } + const result = contextHandoffSummarySchema.safeParse(summary) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filesModified).toEqual(["src/app.ts", "src/utils.ts"]) + expect(result.data.mode).toBe("code") + } + }) + + it("accepts minimal summary with only result", () => { + const summary = { result: "Done" } + const result = contextHandoffSummarySchema.safeParse(summary) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filesModified).toEqual([]) + expect(result.data.filesRead).toEqual([]) + expect(result.data.commandsExecuted).toEqual([]) + expect(result.data.toolUsageCounts).toEqual({}) + expect(result.data.apiRequestCount).toBe(0) + } + }) + + it("rejects summary without result", () => { + const summary = { mode: "code", filesModified: [] } + const result = contextHandoffSummarySchema.safeParse(summary) + expect(result.success).toBe(false) + }) + + it("applies defaults for optional array fields", () => { + const summary = { result: "Done", mode: "debug" } + const result = contextHandoffSummarySchema.safeParse(summary) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.mode).toBe("debug") + expect(result.data.filesModified).toEqual([]) + expect(result.data.commandsExecuted).toEqual([]) + } + }) +}) diff --git a/packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts b/packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts new file mode 100644 index 00000000000..a71f1dd4a98 --- /dev/null +++ b/packages/types/src/__tests__/orchestrator-context-handoff-prompt.spec.ts @@ -0,0 +1,25 @@ +import { DEFAULT_MODES } from "../mode.js" + +describe("Orchestrator context handoff prompt", () => { + const orchestratorMode = DEFAULT_MODES.find((m: { slug: string }) => m.slug === "orchestrator") + + it("should have an orchestrator mode", () => { + expect(orchestratorMode).toBeDefined() + }) + + it("should include context handoff guidance in customInstructions", () => { + expect(orchestratorMode!.customInstructions).toContain("structured context handoff summary") + }) + + it("should mention files modified in context handoff guidance", () => { + expect(orchestratorMode!.customInstructions).toContain("files modified") + }) + + it("should mention passing context to subsequent subtasks", () => { + expect(orchestratorMode!.customInstructions).toContain("subsequent subtasks") + }) + + it("should mention identifying potential conflicts", () => { + expect(orchestratorMode!.customInstructions).toContain("potential conflicts") + }) +}) diff --git a/packages/types/src/__tests__/orchestrator-permissions-prompt.spec.ts b/packages/types/src/__tests__/orchestrator-permissions-prompt.spec.ts new file mode 100644 index 00000000000..1a6a9bcc3ae --- /dev/null +++ b/packages/types/src/__tests__/orchestrator-permissions-prompt.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest" +import { DEFAULT_MODES } from "../mode.js" +import type { ModeConfig } from "../mode.js" + +describe("Orchestrator mode - permissions prompt guidance", () => { + const orchestratorMode = DEFAULT_MODES.find((m: ModeConfig) => m.slug === "orchestrator") + + it("should have the orchestrator mode defined", () => { + expect(orchestratorMode).toBeDefined() + }) + + it("should include permissions guidance in customInstructions", () => { + expect(orchestratorMode!.customInstructions).toContain("permissions") + expect(orchestratorMode!.customInstructions).toContain("filePatterns") + expect(orchestratorMode!.customInstructions).toContain("commandPatterns") + expect(orchestratorMode!.customInstructions).toContain("allowedTools") + expect(orchestratorMode!.customInstructions).toContain("deniedTools") + }) + + it("should mention most-restrictive-wins semantics", () => { + expect(orchestratorMode!.customInstructions).toContain("most-restrictive-wins") + }) + + it("should provide example use cases for permissions", () => { + // Guidance about restricting file access + expect(orchestratorMode!.customInstructions).toContain("specific directory") + // Guidance about read-only research tasks + expect(orchestratorMode!.customInstructions).toContain("read-only research") + // Guidance about blocking shell access + expect(orchestratorMode!.customInstructions).toContain("shell access") + }) +}) diff --git a/packages/types/src/__tests__/task-context.spec.ts b/packages/types/src/__tests__/task-context.spec.ts new file mode 100644 index 00000000000..0e28d23dff7 --- /dev/null +++ b/packages/types/src/__tests__/task-context.spec.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest" + +import { + taskPermissionsSchema, + taskContextSchema, + mergePermissions, + type TaskPermissions, + type TaskContext, +} from "../task-context.js" + +describe("TaskPermissions schema", () => { + it("accepts empty object", () => { + const result = taskPermissionsSchema.parse({}) + expect(result).toEqual({}) + }) + + it("accepts full permissions object", () => { + const permissions: TaskPermissions = { + fileReadPatterns: ["docs/**", "src/**"], + fileWritePatterns: ["docs/**"], + allowedCommands: ["npm test"], + blockedCommands: ["rm -rf"], + readOnly: true, + allowedTools: ["read_file", "list_files"], + } + const result = taskPermissionsSchema.parse(permissions) + expect(result).toEqual(permissions) + }) + + it("accepts partial permissions", () => { + const result = taskPermissionsSchema.parse({ readOnly: true }) + expect(result).toEqual({ readOnly: true }) + }) + + it("rejects invalid types", () => { + expect(() => taskPermissionsSchema.parse({ readOnly: "yes" })).toThrow() + expect(() => taskPermissionsSchema.parse({ fileReadPatterns: "docs/**" })).toThrow() + }) +}) + +describe("TaskContext schema", () => { + it("accepts minimal context", () => { + const context: TaskContext = { mode: "code" } + const result = taskContextSchema.parse(context) + expect(result.mode).toBe("code") + }) + + it("accepts full context", () => { + const context: TaskContext = { + mode: "architect", + apiConfigName: "gpt-4", + permissions: { + readOnly: true, + fileReadPatterns: ["docs/**"], + }, + inheritSkills: true, + skillOverrides: ["custom-skill"], + workspacePath: "/workspace/project", + parentTaskId: "parent-123", + rootTaskId: "root-456", + } + const result = taskContextSchema.parse(context) + expect(result).toEqual(context) + }) + + it("rejects missing mode", () => { + expect(() => taskContextSchema.parse({})).toThrow() + }) +}) + +describe("mergePermissions", () => { + it("returns undefined when both are undefined", () => { + expect(mergePermissions(undefined, undefined)).toBeUndefined() + }) + + it("returns child when parent is undefined", () => { + const child: TaskPermissions = { readOnly: true } + expect(mergePermissions(undefined, child)).toEqual(child) + }) + + it("returns parent when child is undefined", () => { + const parent: TaskPermissions = { readOnly: true } + expect(mergePermissions(parent, undefined)).toEqual(parent) + }) + + it("merges readOnly with OR logic", () => { + expect(mergePermissions({ readOnly: true }, { readOnly: false })).toMatchObject({ readOnly: true }) + expect(mergePermissions({ readOnly: false }, { readOnly: true })).toMatchObject({ readOnly: true }) + expect(mergePermissions({ readOnly: false }, { readOnly: false })).toMatchObject({}) + }) + + it("intersects fileWritePatterns", () => { + const parent: TaskPermissions = { fileWritePatterns: ["docs/**", "src/**", "package.json"] } + const child: TaskPermissions = { fileWritePatterns: ["docs/**", "package.json"] } + const result = mergePermissions(parent, child) + expect(result?.fileWritePatterns).toEqual(["docs/**", "package.json"]) + }) + + it("intersects allowedTools", () => { + const parent: TaskPermissions = { allowedTools: ["read_file", "list_files", "search_files"] } + const child: TaskPermissions = { allowedTools: ["read_file", "search_files", "write_to_file"] } + const result = mergePermissions(parent, child) + expect(result?.allowedTools).toEqual(["read_file", "search_files"]) + }) + + it("unions blockedCommands", () => { + const parent: TaskPermissions = { blockedCommands: ["rm -rf"] } + const child: TaskPermissions = { blockedCommands: ["git push", "rm -rf"] } + const result = mergePermissions(parent, child) + expect(result?.blockedCommands).toEqual(["rm -rf", "git push"]) + }) + + it("returns defined array when only one side specifies it", () => { + const parent: TaskPermissions = { fileReadPatterns: ["docs/**"] } + const child: TaskPermissions = {} + const result = mergePermissions(parent, child) + expect(result?.fileReadPatterns).toEqual(["docs/**"]) + }) + + it("returns empty array when intersection is empty", () => { + const parent: TaskPermissions = { allowedTools: ["read_file"] } + const child: TaskPermissions = { allowedTools: ["write_to_file"] } + const result = mergePermissions(parent, child) + expect(result?.allowedTools).toEqual([]) + }) +}) diff --git a/packages/types/src/__tests__/task-permissions.spec.ts b/packages/types/src/__tests__/task-permissions.spec.ts new file mode 100644 index 00000000000..48990c98752 --- /dev/null +++ b/packages/types/src/__tests__/task-permissions.spec.ts @@ -0,0 +1,325 @@ +import { describe, it, expect } from "vitest" +import { + mergeTaskPermissions, + matchesAnyPattern, + matchesAllPatternLayers, + taskPermissionsSchema, + toTaskPermissions, + isSafeRegex, +} from "../task-permissions.js" +import type { TaskPermissions } from "../task-permissions.js" + +describe("TaskPermissions", () => { + describe("taskPermissionsSchema", () => { + it("validates a valid permissions object", () => { + const result = taskPermissionsSchema.safeParse({ + filePatterns: ["src/components/.*"], + commandPatterns: ["npm test.*"], + allowedTools: ["read_file", "write_to_file"], + deniedTools: ["execute_command"], + }) + expect(result.success).toBe(true) + }) + + it("validates an empty object", () => { + const result = taskPermissionsSchema.safeParse({}) + expect(result.success).toBe(true) + }) + + it("validates partial permissions", () => { + const result = taskPermissionsSchema.safeParse({ + filePatterns: ["src/.*"], + }) + expect(result.success).toBe(true) + }) + + it("rejects non-string array values", () => { + const result = taskPermissionsSchema.safeParse({ + filePatterns: [123], + }) + expect(result.success).toBe(false) + }) + + it("rejects invalid regex patterns in filePatterns", () => { + const result = taskPermissionsSchema.safeParse({ + filePatterns: ["[invalid"], + }) + expect(result.success).toBe(false) + }) + + it("rejects invalid regex patterns in commandPatterns", () => { + const result = taskPermissionsSchema.safeParse({ + commandPatterns: ["(unclosed"], + }) + expect(result.success).toBe(false) + }) + }) + + describe("toTaskPermissions", () => { + it("wraps flat filePatterns into a single layer", () => { + const input = { filePatterns: ["src/.*"] } + const result = toTaskPermissions(input) + expect(result._filePatternLayers).toEqual([["src/.*"]]) + expect(result.filePatterns).toEqual(["src/.*"]) + }) + + it("wraps flat commandPatterns into a single layer", () => { + const input = { commandPatterns: ["npm test.*"] } + const result = toTaskPermissions(input) + expect(result._commandPatternLayers).toEqual([["npm test.*"]]) + }) + + it("leaves layers undefined when patterns are not set", () => { + const input = { allowedTools: ["read_file"] } + const result = toTaskPermissions(input) + expect(result._filePatternLayers).toBeUndefined() + expect(result._commandPatternLayers).toBeUndefined() + }) + }) + + describe("mergeTaskPermissions", () => { + it("returns undefined when both are undefined", () => { + expect(mergeTaskPermissions(undefined, undefined)).toBeUndefined() + }) + + it("returns child when parent is undefined", () => { + const child: TaskPermissions = { filePatterns: ["src/.*"] } + expect(mergeTaskPermissions(undefined, child)).toEqual(child) + }) + + it("returns parent when child is undefined", () => { + const parent: TaskPermissions = { filePatterns: ["src/.*"] } + expect(mergeTaskPermissions(parent, undefined)).toEqual(parent) + }) + + it("accumulates filePatterns as separate layers when both defined", () => { + const parent = toTaskPermissions({ filePatterns: ["src/.*", "tests/.*"] }) + const child = toTaskPermissions({ filePatterns: ["src/.*", "docs/.*"] }) + const merged = mergeTaskPermissions(parent, child) + // Both layers are kept (AND semantics between layers) + expect(merged?._filePatternLayers).toEqual([ + ["src/.*", "tests/.*"], + ["src/.*", "docs/.*"], + ]) + }) + + it("accumulates commandPatterns as separate layers when both defined", () => { + const parent = toTaskPermissions({ commandPatterns: ["npm test.*", "npm run lint"] }) + const child = toTaskPermissions({ commandPatterns: ["npm test.*", "npm run build"] }) + const merged = mergeTaskPermissions(parent, child) + expect(merged?._commandPatternLayers).toEqual([ + ["npm test.*", "npm run lint"], + ["npm test.*", "npm run build"], + ]) + }) + + it("intersects allowedTools when both defined", () => { + const parent: TaskPermissions = { allowedTools: ["read_file", "write_to_file", "search_files"] } + const child: TaskPermissions = { allowedTools: ["read_file", "execute_command"] } + const merged = mergeTaskPermissions(parent, child) + expect(merged?.allowedTools).toEqual(["read_file"]) + }) + + it("unions deniedTools when both defined", () => { + const parent: TaskPermissions = { deniedTools: ["execute_command"] } + const child: TaskPermissions = { deniedTools: ["write_to_file"] } + const merged = mergeTaskPermissions(parent, child) + expect(merged?.deniedTools).toEqual(["execute_command", "write_to_file"]) + }) + + it("deduplicates deniedTools in union", () => { + const parent: TaskPermissions = { deniedTools: ["execute_command", "write_to_file"] } + const child: TaskPermissions = { deniedTools: ["execute_command", "search_files"] } + const merged = mergeTaskPermissions(parent, child) + expect(merged?.deniedTools).toEqual(["execute_command", "write_to_file", "search_files"]) + }) + + it("uses parent filePatterns when child has none", () => { + const parent = toTaskPermissions({ filePatterns: ["src/.*"] }) + const child: TaskPermissions = { deniedTools: ["execute_command"] } + const merged = mergeTaskPermissions(parent, child) + expect(merged?._filePatternLayers).toEqual([["src/.*"]]) + expect(merged?.deniedTools).toEqual(["execute_command"]) + }) + + it("returns empty array when allowedTools intersection is empty", () => { + const parent: TaskPermissions = { allowedTools: ["read_file"] } + const child: TaskPermissions = { allowedTools: ["write_to_file"] } + const merged = mergeTaskPermissions(parent, child) + expect(merged?.allowedTools).toEqual([]) + }) + + it("handles nested delegation where child narrows scope", () => { + const grandparent = toTaskPermissions({ + filePatterns: ["src/.*"], + commandPatterns: ["npm.*"], + allowedTools: ["read_file", "write_to_file", "search_files"], + deniedTools: ["execute_command"], + }) + const parent = toTaskPermissions({ + filePatterns: ["src/components/.*"], + allowedTools: ["read_file", "write_to_file"], + }) + + const merged = mergeTaskPermissions(grandparent, parent) + + // Both layers are kept -- runtime enforces AND between them + expect(merged?._filePatternLayers).toEqual([["src/.*"], ["src/components/.*"]]) + // allowedTools intersection: read_file and write_to_file are in both + expect(merged?.allowedTools).toEqual(["read_file", "write_to_file"]) + // commandPatterns: only grandparent has them, so they pass through + expect(merged?._commandPatternLayers).toEqual([["npm.*"]]) + // deniedTools: only grandparent has them, so they pass through + expect(merged?.deniedTools).toEqual(["execute_command"]) + }) + + it("deduplicates identical pattern layers", () => { + const parent = toTaskPermissions({ filePatterns: ["src/.*"] }) + const child = toTaskPermissions({ filePatterns: ["src/.*"] }) + const merged = mergeTaskPermissions(parent, child) + // Identical layers are deduplicated + expect(merged?._filePatternLayers).toEqual([["src/.*"]]) + }) + }) + + describe("matchesAnyPattern", () => { + it("matches a simple regex pattern", () => { + expect(matchesAnyPattern("src/components/Button.tsx", ["src/components/.*"])).toBe(true) + }) + + it("does not match when no patterns match", () => { + expect(matchesAnyPattern("tests/unit/test.ts", ["src/components/.*"])).toBe(false) + }) + + it("matches when at least one pattern matches", () => { + expect(matchesAnyPattern("tests/unit/test.ts", ["src/.*", "tests/.*"])).toBe(true) + }) + + it("handles invalid regex gracefully", () => { + expect(matchesAnyPattern("test.ts", ["[invalid"])).toBe(false) + }) + + it("matches command patterns", () => { + expect(matchesAnyPattern("npm test -- --coverage", ["npm test.*"])).toBe(true) + }) + + it("does not match restricted commands", () => { + expect(matchesAnyPattern("rm -rf /", ["npm.*", "yarn.*"])).toBe(false) + }) + + it("anchors patterns so substrings do not match", () => { + // "src/.*" should NOT match a path that merely contains "src/" as a substring + expect(matchesAnyPattern("evil/src/components/foo.ts", ["src/.*"])).toBe(false) + // But should still match paths that start with src/ + expect(matchesAnyPattern("src/components/foo.ts", ["src/.*"])).toBe(true) + }) + + it("respects pre-anchored patterns (starting with ^)", () => { + // A pattern already starting with ^ should not be double-wrapped + expect(matchesAnyPattern("src/foo.ts", ["^src/.*$"])).toBe(true) + expect(matchesAnyPattern("evil/src/foo.ts", ["^src/.*$"])).toBe(false) + }) + }) + + describe("matchesAllPatternLayers", () => { + it("returns true when layers is undefined", () => { + expect(matchesAllPatternLayers("anything", undefined)).toBe(true) + }) + + it("returns true when layers is empty", () => { + expect(matchesAllPatternLayers("anything", [])).toBe(true) + }) + + it("returns true when value matches all layers", () => { + const layers = [["src/.*"], ["src/components/.*"]] + expect(matchesAllPatternLayers("src/components/Button.tsx", layers)).toBe(true) + }) + + it("returns false when value fails to match one layer", () => { + const layers = [["src/.*"], ["src/components/.*"]] + // Matches src/.* but not src/components/.* + expect(matchesAllPatternLayers("src/utils/helper.ts", layers)).toBe(false) + }) + + it("handles single layer like matchesAnyPattern", () => { + const layers = [["src/.*", "tests/.*"]] + expect(matchesAllPatternLayers("tests/unit/test.ts", layers)).toBe(true) + expect(matchesAllPatternLayers("docs/readme.md", layers)).toBe(false) + }) + }) + + describe("isSafeRegex", () => { + it("accepts simple file path patterns", () => { + expect(isSafeRegex("src/.*")).toBe(true) + expect(isSafeRegex("src/components/.*\\.tsx")).toBe(true) + expect(isSafeRegex("npm test.*")).toBe(true) + }) + + it("rejects nested quantifiers (classic ReDoS)", () => { + expect(isSafeRegex("(a+)+")).toBe(false) + expect(isSafeRegex("(a*)+")).toBe(false) + expect(isSafeRegex("(a+)*")).toBe(false) + expect(isSafeRegex("(a+){2,}")).toBe(false) + }) + + it("rejects overlapping alternations in repeated groups", () => { + expect(isSafeRegex("(a|a)+")).toBe(false) + expect(isSafeRegex("(.|a)*")).toBe(false) + }) + + it("rejects patterns exceeding maximum length", () => { + const longPattern = "a".repeat(201) + expect(isSafeRegex(longPattern)).toBe(false) + }) + + it("accepts patterns at maximum length", () => { + const maxPattern = "a".repeat(200) + expect(isSafeRegex(maxPattern)).toBe(true) + }) + }) + + describe("schema ReDoS rejection", () => { + it("rejects ReDoS-vulnerable patterns in filePatterns", () => { + const result = taskPermissionsSchema.safeParse({ + filePatterns: ["(a+)+"], + }) + expect(result.success).toBe(false) + }) + + it("rejects ReDoS-vulnerable patterns in commandPatterns", () => { + const result = taskPermissionsSchema.safeParse({ + commandPatterns: ["(cmd|cmd)*"], + }) + expect(result.success).toBe(false) + }) + + it("rejects overly long patterns at schema level", () => { + const result = taskPermissionsSchema.safeParse({ + filePatterns: ["a".repeat(201)], + }) + expect(result.success).toBe(false) + }) + }) + + describe("persistence round-trip", () => { + it("taskPermissionsSchema can parse persisted permissions (without internal fields)", () => { + // Simulate what gets persisted: only the input-level fields + const persisted = { + filePatterns: ["src/.*"], + commandPatterns: ["npm test.*"], + allowedTools: ["read_file"], + deniedTools: ["execute_command"], + } + const result = taskPermissionsSchema.safeParse(persisted) + expect(result.success).toBe(true) + if (result.success) { + // Can be converted back to internal representation + const restored = toTaskPermissions(result.data) + expect(restored._filePatternLayers).toEqual([["src/.*"]]) + expect(restored._commandPatternLayers).toEqual([["npm test.*"]]) + expect(restored.allowedTools).toEqual(["read_file"]) + expect(restored.deniedTools).toEqual(["execute_command"]) + } + }) + }) +}) diff --git a/packages/types/src/context-handoff.ts b/packages/types/src/context-handoff.ts new file mode 100644 index 00000000000..0067a241f5d --- /dev/null +++ b/packages/types/src/context-handoff.ts @@ -0,0 +1,31 @@ +import { z } from "zod" + +/** + * ContextHandoffSummary + * + * Structured summary of what a subtask accomplished during execution. + * Automatically collected when a subtask completes via attempt_completion + * and passed back to the parent task alongside the freeform result string. + * + * This gives the parent (typically the Orchestrator) structured visibility + * into the child's work without requiring the child to manually enumerate + * every file it touched or command it ran. + */ +export const contextHandoffSummarySchema = z.object({ + /** Mode the subtask ran in (e.g., "code", "debug", "architect") */ + mode: z.string().optional(), + /** Files that were created or modified by the subtask */ + filesModified: z.array(z.string()).default([]), + /** Files that were read (but not modified) by the subtask */ + filesRead: z.array(z.string()).default([]), + /** Shell commands that were executed by the subtask */ + commandsExecuted: z.array(z.string()).default([]), + /** Count of each tool type used (e.g., { write_to_file: 3, read_file: 5 }) */ + toolUsageCounts: z.record(z.string(), z.number()).default({}), + /** Total number of API requests made during the subtask */ + apiRequestCount: z.number().default(0), + /** The freeform completion result from attempt_completion */ + result: z.string(), +}) + +export type ContextHandoffSummary = z.infer diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index a60d1a75b65..0045b82a29e 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -1,5 +1,30 @@ import { z } from "zod" +import { contextHandoffSummarySchema } from "./context-handoff.js" +import { taskPermissionsSchema } from "./task-permissions.js" + +/** + * SubtaskQueueItem — a single queued subtask definition for sequential fan-out. + * Used by the orchestrator to define a pipeline of subtasks that execute one after another. + */ +export const subtaskQueueItemSchema = z.object({ + mode: z.string(), + message: z.string(), +}) + +export type SubtaskQueueItem = z.infer + +/** + * SubtaskResult — the result of a completed subtask in a queue. + */ +export const subtaskResultSchema = z.object({ + taskId: z.string(), + mode: z.string(), + summary: z.string(), +}) + +export type SubtaskResult = z.infer + /** * HistoryItem */ @@ -20,12 +45,50 @@ 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) completedByChildId: z.string().optional(), // Child that completed and resumed this parent completionResultSummary: z.string().optional(), // Summary from completed child + // Sequential fan-out queue (Phase 2) + subtaskQueue: z.array(subtaskQueueItemSchema).optional(), // Remaining subtasks to execute + subtaskQueueIndex: z.number().optional(), // Current position in the original queue (0-based) + subtaskResults: z.array(subtaskResultSchema).optional(), // Results from completed queue subtasks + contextHandoffSummary: contextHandoffSummarySchema.optional(), // Structured context from completed child + taskPermissions: taskPermissionsSchema.optional(), // Permission boundaries set by parent task }) 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/packages/types/src/index.ts b/packages/types/src/index.ts index 64e2a3ded79..236b8be1e0f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,6 +11,7 @@ export * from "./experiment.js" export * from "./followup.js" export * from "./git.js" export * from "./global-settings.js" +export * from "./context-handoff.js" export * from "./history.js" export * from "./image-generation.js" export * from "./ipc.js" @@ -20,6 +21,8 @@ export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" export * from "./task.js" +export { taskContextSchema, type TaskContext, mergePermissions } from "./task-context.js" +export * from "./task-permissions.js" export * from "./todo.js" export * from "./skills.js" export * from "./terminal.js" diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 249e9820af0..17c340b8b18 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -222,6 +222,6 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [ description: "Coordinate tasks across multiple modes", groups: [], customInstructions: - "Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask's specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask's mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you're delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.", + 'Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask\'s specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask\'s mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you\'re delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\n8. When delegating subtasks, consider using the optional `permissions` parameter on `new_task` to restrict what the subtask can do. This is especially useful when:\n * The subtask should only modify files in a specific directory (use `filePatterns`, e.g. `["src/components/.*"]`).\n * The subtask should only run certain commands (use `commandPatterns`, e.g. `["npm test.*", "npm run lint"]`).\n * The subtask should be limited to specific tools (use `allowedTools`, e.g. `["read_file", "search_files"]` for read-only research tasks).\n * Certain tools should be explicitly blocked (use `deniedTools`, e.g. `["execute_command"]` to prevent shell access).\n Permissions are enforced at runtime and follow most-restrictive-wins semantics when subtasks are nested. Use them to keep subtasks focused and safe.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.\\n\\n9. When a subtask completes, you will receive a structured context handoff summary alongside the completion result. This summary includes the files modified, files read, commands executed, and tool usage counts from the subtask. Use this structured data to:\\n * Verify the subtask accomplished what was requested by checking the files modified list.\\n * Pass relevant context to subsequent subtasks (e.g., "The previous subtask modified `src/components/Button.tsx` and `src/styles/button.css`").\\n * Identify potential conflicts when multiple subtasks touch the same files.\\n * Provide accurate progress summaries to the user.', }, ] as const diff --git a/packages/types/src/task-context.ts b/packages/types/src/task-context.ts new file mode 100644 index 00000000000..7040f0297b9 --- /dev/null +++ b/packages/types/src/task-context.ts @@ -0,0 +1,227 @@ +import { z } from "zod" + +/** + * TaskPermissions defines fine-grained permission boundaries for a subtask. + * + * These permissions allow the orchestrator (or parent task) to restrict what + * a child task can do, making parallel execution safer by preventing + * unintended side effects across task boundaries. + * + * ## Design Notes + * + * Phase 3a introduces the types and plumbing. Enforcement is deferred to + * Phase 3b (read-only parallelism) and Phase 3d (write parallelism). + * + * The permission model is intentionally additive: if no permissions are + * specified, the task inherits full capabilities from its mode. Permissions + * can only *restrict*, never *expand* beyond what the mode allows. + */ +export const taskPermissionsSchema = z.object({ + /** + * Glob patterns restricting which files the task may read. + * If empty or undefined, the task can read any file (subject to mode restrictions). + * Examples: ["docs/**", "src/utils/**"] + */ + fileReadPatterns: z.array(z.string()).optional(), + + /** + * Glob patterns restricting which files the task may write/edit. + * If empty or undefined, the task can write any file (subject to mode restrictions). + * Examples: ["docs/**", "package.json"] + */ + fileWritePatterns: z.array(z.string()).optional(), + + /** + * Allowlist of shell commands the task may execute. + * If empty or undefined, the task can execute any command (subject to mode restrictions). + * Matched as prefixes against the command string. + * Examples: ["npm test", "npx vitest", "git status"] + */ + allowedCommands: z.array(z.string()).optional(), + + /** + * Blocklist of shell commands the task may NOT execute. + * Takes precedence over allowedCommands. + * Examples: ["rm -rf", "git push"] + */ + blockedCommands: z.array(z.string()).optional(), + + /** + * Whether the task is restricted to read-only operations. + * When true, the task cannot use write tools (write_to_file, apply_diff, + * execute_command, etc.). This is the primary mechanism for Phase 3b + * read-only parallelism. + */ + readOnly: z.boolean().optional(), + + /** + * Explicit list of tool names the task is allowed to use. + * If empty or undefined, all tools available to the mode are allowed. + * Examples: ["read_file", "list_files", "search_files"] + */ + allowedTools: z.array(z.string()).optional(), +}) + +export type TaskPermissions = z.infer + +/** + * TaskContext encapsulates all per-task configuration that a Task needs + * to operate independently of the ClineProvider's shared mutable state. + * + * ## Purpose + * + * Today, Task reads mode, API config, and other settings from the provider + * via `provider.getState()` at construction time and during execution. + * This couples Task execution to the provider's current state, which + * prevents multiple tasks from running concurrently (since they'd all + * read/write the same shared state). + * + * TaskContext captures a snapshot of everything a Task needs at creation + * time, so the Task can operate with its own isolated configuration. + * + * ## Lifecycle + * + * 1. Built by the parent (orchestrator or provider) when creating a subtask + * 2. Passed to the Task constructor as an immutable snapshot + * 3. The Task uses this context instead of reaching back to the provider + * for mode/config/permissions during execution + * + * ## Phase 3a Scope + * + * In Phase 3a, TaskContext is optional -- tasks that don't receive one + * fall back to the existing provider.getState() behavior. This ensures + * full backward compatibility while enabling incremental adoption. + */ +export const taskContextSchema = z.object({ + /** + * The mode slug for this task (e.g., "code", "architect", "ask"). + * Snapshot at task creation time -- does not change if the provider's + * mode changes later. + */ + mode: z.string(), + + /** + * The API configuration profile name for this task. + * Allows subtasks to use different models (including local ones) + * from the parent task. + */ + apiConfigName: z.string().optional(), + + /** + * Permission boundaries for this task. + * If undefined, the task inherits full capabilities from its mode. + */ + permissions: taskPermissionsSchema.optional(), + + /** + * Whether this task should inherit skills from the parent. + * Defaults to true if not specified. + */ + inheritSkills: z.boolean().optional(), + + /** + * Additional skill overrides for this task. + * These are merged with (or replace) inherited skills depending + * on the inheritSkills setting. + */ + skillOverrides: z.array(z.string()).optional(), + + /** + * The workspace path for this task. + * Allows subtasks to operate in different workspace roots. + */ + workspacePath: z.string().optional(), + + /** + * ID of the parent task that created this context. + * Used for lineage tracking and result aggregation. + */ + parentTaskId: z.string().optional(), + + /** + * ID of the root task in the delegation chain. + * Used for hierarchical task management. + */ + rootTaskId: z.string().optional(), +}) + +export type TaskContext = z.infer + +/** + * Merge two TaskPermissions objects, producing the most restrictive + * combination. This is used when a parent task's permissions should + * further constrain a child task's permissions. + * + * Rules: + * - readOnly: true if either is true + * - allowedTools: intersection if both specified, otherwise the one that's specified + * - fileReadPatterns / fileWritePatterns: intersection if both specified + * - allowedCommands: intersection if both specified + * - blockedCommands: union (all blocked commands from both) + */ +export function mergePermissions( + parent: TaskPermissions | undefined, + child: TaskPermissions | undefined, +): TaskPermissions | undefined { + if (!parent && !child) { + return undefined + } + + if (!parent) { + return child + } + + if (!child) { + return parent + } + + return { + readOnly: parent.readOnly || child.readOnly || undefined, + + fileReadPatterns: intersectArrays(parent.fileReadPatterns, child.fileReadPatterns), + + fileWritePatterns: intersectArrays(parent.fileWritePatterns, child.fileWritePatterns), + + allowedCommands: intersectArrays(parent.allowedCommands, child.allowedCommands), + + blockedCommands: unionArrays(parent.blockedCommands, child.blockedCommands), + + allowedTools: intersectArrays(parent.allowedTools, child.allowedTools), + } +} + +/** Return intersection of two optional arrays, or the defined one if only one exists. */ +function intersectArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined { + if (!a && !b) { + return undefined + } + + if (!a) { + return b + } + + if (!b) { + return a + } + + const setB = new Set(b) + const result = a.filter((item) => setB.has(item)) + return result.length > 0 ? result : [] +} + +/** Return union of two optional arrays. */ +function unionArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined { + if (!a && !b) { + return undefined + } + + if (!a) { + return b + } + + if (!b) { + return a + } + + return Array.from(new Set([...a, ...b])) +} diff --git a/packages/types/src/task-permissions.ts b/packages/types/src/task-permissions.ts new file mode 100644 index 00000000000..938b321ec77 --- /dev/null +++ b/packages/types/src/task-permissions.ts @@ -0,0 +1,292 @@ +import { z } from "zod" + +/** + * TaskPermissions defines permission boundaries that a parent task can impose + * on a subtask created via the `new_task` tool. + * + * When nested subtasks are created, permissions are merged using + * "most-restrictive-wins" semantics: a child can never grant itself + * more access than its parent. + */ + +/** Maximum allowed length for a regex pattern to limit complexity. */ +const MAX_REGEX_PATTERN_LENGTH = 200 + +/** + * Heuristic check for ReDoS-vulnerable patterns. + * Detects common dangerous constructs like nested quantifiers: + * (a+)+, (a*)+, (a+)*, (a*){2,}, etc. + * These can cause catastrophic backtracking on crafted input. + */ +export function isSafeRegex(pattern: string): boolean { + if (pattern.length > MAX_REGEX_PATTERN_LENGTH) { + return false + } + + // Detect nested quantifiers: a group with a quantifier inside, followed by an outer quantifier. + // Examples: (a+)+, (a+)*, (a*){2,}, (?:a+)+ + // This regex looks for: group containing a quantifier, followed by another quantifier + const nestedQuantifierPattern = /\([^)]*[+*][^)]*\)[+*{]/ + if (nestedQuantifierPattern.test(pattern)) { + return false + } + + // Detect overlapping alternations inside repeated groups: (a|a)+, (.|a)+ + // where both alternatives can match the same input + const overlappingAlternationInGroup = /\([^)]*\|[^)]*\)[+*{]/ + if (overlappingAlternationInGroup.test(pattern)) { + return false + } + + return true +} + +/** + * Zod refinement that rejects strings which are not valid regular expressions, + * and also rejects patterns that are vulnerable to ReDoS (catastrophic backtracking). + */ +const regexString = z + .string() + .max(MAX_REGEX_PATTERN_LENGTH, { + message: `Regex pattern must be at most ${MAX_REGEX_PATTERN_LENGTH} characters`, + }) + .refine( + (val) => { + try { + new RegExp(val) + return true + } catch { + return false + } + }, + { message: "Invalid regular expression" }, + ) + .refine((val) => isSafeRegex(val), { + message: + "Regex pattern rejected: potentially vulnerable to ReDoS (catastrophic backtracking). Avoid nested quantifiers like (a+)+ or overlapping alternations in repeated groups.", + }) + +export const taskPermissionsSchema = z.object({ + /** + * Regex patterns for allowed file paths. + * When set, file operations (read/write) are restricted to paths matching + * at least one of these patterns. Patterns are automatically anchored + * (wrapped in `^(?:...)$`) at runtime so they match the full path. + */ + filePatterns: z.array(regexString).optional(), + + /** + * Regex patterns for allowed shell commands. + * When set, command execution is restricted to commands matching + * at least one of these patterns. Patterns are automatically anchored. + */ + commandPatterns: z.array(regexString).optional(), + + /** + * Explicit tool allowlist. When set, only these tools may be used + * by the subtask (in addition to always-available tools like + * attempt_completion and ask_followup_question). + */ + allowedTools: z.array(z.string()).optional(), + + /** + * Explicit tool blocklist. These tools are denied regardless of + * mode configuration. + */ + deniedTools: z.array(z.string()).optional(), +}) + +/** The shape accepted as input from the model via the new_task tool. */ +export type TaskPermissionsInput = z.infer + +/** + * Internal representation of task permissions. Extends the input shape with + * layered pattern fields that accumulate across nested delegation so that + * each ancestor's constraints are enforced independently (AND semantics + * between layers, OR semantics within a layer). + */ +export interface TaskPermissions extends TaskPermissionsInput { + /** + * Accumulated file-pattern layers from ancestor tasks. + * Each inner array is an OR-group; all layers must match (AND between layers). + * Populated only by `mergeTaskPermissions` -- never set from model input. + */ + _filePatternLayers?: string[][] + /** + * Accumulated command-pattern layers from ancestor tasks. + * Same semantics as `_filePatternLayers`. + */ + _commandPatternLayers?: string[][] +} + +/** + * Convert a validated input object (flat arrays) into the internal + * `TaskPermissions` representation, wrapping patterns into single layers. + */ +export function toTaskPermissions(input: TaskPermissionsInput): TaskPermissions { + return { + ...input, + _filePatternLayers: input.filePatterns ? [input.filePatterns] : undefined, + _commandPatternLayers: input.commandPatterns ? [input.commandPatterns] : undefined, + } +} + +/** + * Merge two TaskPermissions using most-restrictive-wins semantics. + * + * - filePatterns / commandPatterns: accumulated as independent layers so that + * a value must match at least one pattern from EACH ancestor's layer. + * - allowedTools: intersection of both lists (if both defined). + * - deniedTools: union of both lists (most restrictive). + * + * @returns merged permissions, or undefined if both inputs are undefined. + */ +export function mergeTaskPermissions( + parent: TaskPermissions | undefined, + child: TaskPermissions | undefined, +): TaskPermissions | undefined { + if (!parent && !child) { + return undefined + } + if (!parent) { + return child + } + if (!child) { + return parent + } + + // Collect pattern layers from both sides. Each side may already carry + // accumulated layers from earlier merges (_*PatternLayers) as well as + // its own top-level patterns (filePatterns / commandPatterns). + const filePatternLayers = collectPatternLayers( + parent._filePatternLayers, + parent.filePatterns, + child._filePatternLayers, + child.filePatterns, + ) + + const commandPatternLayers = collectPatternLayers( + parent._commandPatternLayers, + parent.commandPatterns, + child._commandPatternLayers, + child.commandPatterns, + ) + + return { + // The top-level field stores the child's own patterns (used for display / + // serialization); runtime enforcement uses the layers. + filePatterns: child.filePatterns ?? parent.filePatterns, + commandPatterns: child.commandPatterns ?? parent.commandPatterns, + _filePatternLayers: filePatternLayers.length > 0 ? filePatternLayers : undefined, + _commandPatternLayers: commandPatternLayers.length > 0 ? commandPatternLayers : undefined, + allowedTools: intersectOptionalArrays(parent.allowedTools, child.allowedTools), + deniedTools: unionOptionalArrays(parent.deniedTools, child.deniedTools), + } +} + +/** + * Collect pattern layers from parent and child, deduplicating identical layers. + */ +function collectPatternLayers( + parentLayers: string[][] | undefined, + parentPatterns: string[] | undefined, + childLayers: string[][] | undefined, + childPatterns: string[] | undefined, +): string[][] { + const layers: string[][] = [] + const seen = new Set() + + const addLayer = (layer: string[]) => { + if (layer.length === 0) return + const key = JSON.stringify(layer) + if (!seen.has(key)) { + seen.add(key) + layers.push(layer) + } + } + + // Add accumulated parent layers + if (parentLayers) { + for (const layer of parentLayers) { + addLayer(layer) + } + } else if (parentPatterns && parentPatterns.length > 0) { + addLayer(parentPatterns) + } + + // Add accumulated child layers + if (childLayers) { + for (const layer of childLayers) { + addLayer(layer) + } + } else if (childPatterns && childPatterns.length > 0) { + addLayer(childPatterns) + } + + return layers +} + +/** + * Check if a value matches at least one pattern in a list of regex patterns. + */ +export function matchesAnyPattern(value: string, patterns: string[]): boolean { + return patterns.some((pattern) => { + try { + // Anchor patterns so they must match the entire value, not a substring. + // This prevents "src/.*" from matching "evil/src/foo". + const anchored = pattern.startsWith("^") ? pattern : `^(?:${pattern})$` + return new RegExp(anchored).test(value) + } catch { + // Invalid regex -- treat as non-match + return false + } + }) +} + +/** + * Check if a value matches ALL pattern layers (AND between layers, OR within each layer). + * Returns true if there are no layers. + */ +export function matchesAllPatternLayers(value: string, layers: string[][] | undefined): boolean { + if (!layers || layers.length === 0) { + return true + } + return layers.every((layer) => matchesAnyPattern(value, layer)) +} + +/** + * Intersect two optional arrays. If both are defined, return elements present + * in both. If only one is defined, return that one. If neither, return undefined. + */ +function intersectOptionalArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined { + if (!a && !b) { + return undefined + } + if (!a) { + return b + } + if (!b) { + return a + } + + const setB = new Set(b) + const result = a.filter((item) => setB.has(item)) + return result.length > 0 ? result : [] +} + +/** + * Union two optional arrays, deduplicating entries. + */ +function unionOptionalArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined { + if (!a && !b) { + return undefined + } + if (!a) { + return b + } + if (!b) { + return a + } + + return [...new Set([...a, ...b])] +} diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 7447dc772e7..1e4586aab1a 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -4,7 +4,9 @@ import { RooCodeEventName } from "./events.js" import type { RooCodeSettings } from "./global-settings.js" import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js" import type { ToolUsage, ToolName } from "./tool.js" +import type { TaskPermissions } from "./task-permissions.js" import type { TodoItem } from "./todo.js" +import type { TaskContext } from "./task-context.js" /** * TaskProviderLike @@ -90,10 +92,19 @@ 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 + /** + * Optional isolated task context containing mode, API config, and permissions. + * When provided, the task uses this context instead of reading from the provider. + * Phase 3a foundation for concurrent task execution. + */ + taskContext?: TaskContext + /** Permission boundaries for the task, set by the parent via new_task tool. + * When set, restricts what file paths, commands, and tools the task may use. */ + taskPermissions?: TaskPermissions } export enum TaskStatus { diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a4ef802efbc..08ae76c65cf 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 @@ -92,6 +104,8 @@ export interface ExtensionMessage { | "folderSelected" | "skills" | "fileContent" + | "backgroundTaskMessages" + | "backgroundTaskProgress" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -104,6 +118,11 @@ export interface ExtensionMessage { | "chatButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" +<<<<<<< HEAD + | "cloudButtonClicked" + | "backgroundTasksButtonClicked" +======= +>>>>>>> origin/main | "didBecomeVisible" | "focusInput" | "switchTab" @@ -160,6 +179,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 @@ -514,6 +536,10 @@ export interface WebviewMessage { | "createWorktreeInclude" | "checkoutBranch" | "browseForWorktreePath" + // Background task messages + | "requestBackgroundTaskMessages" + | "subscribeToBackgroundTask" + | "unsubscribeFromBackgroundTask" // Skills messages | "requestSkills" | "createSkill" @@ -524,7 +550,11 @@ export interface WebviewMessage { text?: string taskId?: string editedMessageContent?: string +<<<<<<< HEAD + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "cloud" | "bgTaskReplay" | "bgTask" +======= tab?: "settings" | "history" | "mcp" | "modes" | "chat" +>>>>>>> origin/main disabled?: boolean context?: string dataUri?: string @@ -766,6 +796,13 @@ export interface ClineSayTool { description?: string // Properties for skill tool skill?: string + // Properties for newTask tool - permission boundaries set by parent + permissions?: { + filePatterns?: string[] + commandPatterns?: string[] + allowedTools?: string[] + deniedTools?: string[] + } } export interface ClineAskUseMcpServer { diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 55d45174af0..3dc8a5bfea6 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -45,6 +45,7 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + "backgroundTasksButtonClicked", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index a78c41b7c06..868b6fa84c5 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -73,6 +73,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId, emit: providerEmit, getCurrentTask: vi.fn(() => ({ taskId: "child-1" })), @@ -122,6 +123,7 @@ describe("History resume delegation - parent metadata transitions", () => { it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "p1", @@ -205,6 +207,7 @@ describe("History resume delegation - parent metadata transitions", () => { it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "p-tool", @@ -291,6 +294,7 @@ describe("History resume delegation - parent metadata transitions", () => { it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "p-no-tool", @@ -351,6 +355,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "parent-2", @@ -391,6 +396,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "p3", @@ -457,6 +463,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockImplementation(async (id: string) => { if (id === "parent-rpd06") { return { @@ -527,6 +534,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "p4", @@ -580,6 +588,7 @@ describe("History resume delegation - parent metadata transitions", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockImplementation(async (id: string) => { if (id === "parent-rpd02") { return { @@ -726,6 +735,7 @@ describe("History resume delegation - parent metadata transitions", () => { it("handles empty history gracefully when injecting synthetic messages", async () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId: vi.fn().mockResolvedValue({ historyItem: { id: "p5", diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index fac9a7bcade..f72d21a4a42 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -138,6 +138,7 @@ describe("Nested delegation resume (A → B → C)", () => { const provider = { contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), getTaskWithId, emit: emitSpy, getCurrentTask: vi.fn(() => (currentActiveId ? ({ taskId: currentActiveId } as any) : undefined)), diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 4b04fb5bbb9..ef2b0b12104 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -47,6 +47,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { getTaskWithId, updateTaskHistory, handleModeSwitch, + getState: vi.fn().mockResolvedValue({ mode: "code", currentApiConfigName: "default" }), log: vi.fn(), } as unknown as ClineProvider @@ -68,6 +69,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { initialTodos: [], initialStatus: "active", startTask: false, + taskContext: expect.objectContaining({ mode: "code" }), }) // Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus) @@ -129,6 +131,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { getTaskWithId, updateTaskHistory, handleModeSwitch, + getState: vi.fn().mockResolvedValue({ mode: "code", currentApiConfigName: "default" }), log: vi.fn(), } as unknown as ClineProvider diff --git a/src/__tests__/sequential-fan-out.spec.ts b/src/__tests__/sequential-fan-out.spec.ts new file mode 100644 index 00000000000..792278caee3 --- /dev/null +++ b/src/__tests__/sequential-fan-out.spec.ts @@ -0,0 +1,247 @@ +/** + * Tests for Phase 2: Sequential fan-out / fan-in. + * + * Tests the subtask queue mechanism where an orchestrator can define + * multiple subtasks that execute one after another with automatic transitions. + */ + +import { describe, it, expect, vi } from "vitest" +import { RooCodeEventName } from "@roo-code/types" +import type { HistoryItem, SubtaskQueueItem } from "@roo-code/types" + +import { ClineProvider } from "../core/webview/ClineProvider" + +describe("Sequential fan-out queue types", () => { + it("SubtaskQueueItem has required mode and message fields", () => { + const item: SubtaskQueueItem = { mode: "code", message: "Implement feature X" } + expect(item.mode).toBe("code") + expect(item.message).toBe("Implement feature X") + }) + + it("HistoryItem can include subtask queue fields", () => { + const historyItem: Partial = { + id: "test-1", + subtaskQueue: [ + { mode: "code", message: "Step 1" }, + { mode: "debug", message: "Step 2" }, + ], + subtaskQueueIndex: 0, + subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Done" }], + } + expect(historyItem.subtaskQueue).toHaveLength(2) + expect(historyItem.subtaskQueueIndex).toBe(0) + expect(historyItem.subtaskResults).toHaveLength(1) + }) + + it("HistoryItem subtask queue fields are optional", () => { + const historyItem: Partial = { + id: "test-2", + status: "active", + } + expect(historyItem.subtaskQueue).toBeUndefined() + expect(historyItem.subtaskQueueIndex).toBeUndefined() + expect(historyItem.subtaskResults).toBeUndefined() + }) +}) + +describe("advanceSubtaskQueue", () => { + const makeHistoryItem = (overrides: Partial = {}): HistoryItem => ({ + id: "parent-1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + ...overrides, + }) + + it("returns handled=true when there are more subtasks in the queue", async () => { + const emitSpy = vi.fn() + const mockChild = { taskId: "child-2", start: vi.fn() } + const provider = { + getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-1" }), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: makeHistoryItem({ id: "child-1", mode: "code", status: "active" }), + }), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + createTask: vi.fn().mockResolvedValue(mockChild), + emit: emitSpy, + log: vi.fn(), + } + + // Queue items represent ADDITIONAL subtasks after the initial child. + // subtaskQueueIndex=0 means queue[0] is the next to dispatch. + const subtaskQueue: SubtaskQueueItem[] = [ + { mode: "code", message: "Step 1" }, + { mode: "debug", message: "Step 2" }, + ] + + const historyItem = makeHistoryItem({ + subtaskQueue, + subtaskQueueIndex: 0, + subtaskResults: [], + childIds: ["child-1"], + }) + + const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Initial task done", + historyItem, + }) + + expect(result.handled).toBe(true) + + // Should have closed the current child + expect(provider.removeClineFromStack).toHaveBeenCalled() + + // Should have marked child as completed + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ id: "child-1", status: "completed" }), + ) + + // Should have switched mode to queue[0]'s mode (the next item to dispatch) + expect(provider.handleModeSwitch).toHaveBeenCalledWith("code") + + // Should have created the next child with queue[0]'s message + expect(provider.createTask).toHaveBeenCalledWith("Step 1", undefined, undefined, { + initialTodos: [], + initialStatus: "active", + startTask: false, + }) + + // Should have started the next child + expect(mockChild.start).toHaveBeenCalled() + + // Should have updated parent with advanced queue index (0 -> 1) + // completedMode comes from child's history (mode: "code") + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-1", + subtaskQueueIndex: 1, + subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Initial task done" }], + awaitingChildId: "child-2", + delegatedToId: "child-2", + }), + ) + + // Should have emitted delegation events + expect(emitSpy).toHaveBeenCalledWith( + RooCodeEventName.TaskDelegationCompleted, + "parent-1", + "child-1", + "Initial task done", + ) + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-2") + }) + + it("returns handled=false with aggregated summary when queue is exhausted", async () => { + const provider = { + getCurrentTask: vi.fn().mockReturnValue({ taskId: "child-2" }), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: makeHistoryItem({ id: "child-2", mode: "code", status: "active" }), + }), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + handleModeSwitch: vi.fn(), + createTask: vi.fn(), + emit: vi.fn(), + log: vi.fn(), + formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults, + } + + // Queue has 1 item, subtaskQueueIndex=1 means queue[0] was already dispatched. + // Now that child completes and the queue is exhausted. + const subtaskQueue: SubtaskQueueItem[] = [{ mode: "code", message: "Step 1" }] + + const historyItem = makeHistoryItem({ + subtaskQueue, + subtaskQueueIndex: 1, + subtaskResults: [{ taskId: "child-1", mode: "code", summary: "Step 1 done" }], + childIds: ["child-1", "child-2"], + }) + + const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-2", + completionResultSummary: "Step 2 done", + historyItem, + }) + + expect(result.handled).toBe(false) + expect(result.aggregatedSummary).toContain("Sequential Fan-Out Complete") + expect(result.aggregatedSummary).toContain("Step 1 done") + expect(result.aggregatedSummary).toContain("Step 2 done") + + // Should NOT have created a new child + expect(provider.createTask).not.toHaveBeenCalled() + + // Should have cleared queue from parent metadata + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + subtaskQueue: undefined, + subtaskQueueIndex: undefined, + }), + ) + }) + + it("returns handled=false immediately when queue is empty", async () => { + const provider = { + getCurrentTask: vi.fn(), + removeClineFromStack: vi.fn(), + getTaskWithId: vi.fn(), + updateTaskHistory: vi.fn(), + emit: vi.fn(), + log: vi.fn(), + formatAggregatedQueueResults: (ClineProvider.prototype as any).formatAggregatedQueueResults, + } + + const historyItem = makeHistoryItem({ + subtaskQueue: [], + subtaskQueueIndex: 0, + }) + + const result = await (ClineProvider.prototype as any).advanceSubtaskQueue.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Done", + historyItem, + }) + + expect(result.handled).toBe(false) + expect(result.aggregatedSummary).toBe("Done") + }) +}) + +describe("formatAggregatedQueueResults", () => { + it("formats multiple results into a structured summary", () => { + const results = [ + { taskId: "child-1", mode: "code", summary: "Implemented feature X" }, + { taskId: "child-2", mode: "debug", summary: "Fixed bugs in feature X" }, + ] + + const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults(results, "Final result") + + expect(formatted).toContain("Sequential Fan-Out Complete (2 subtasks)") + expect(formatted).toContain("Subtask 1 (code)") + expect(formatted).toContain("Implemented feature X") + expect(formatted).toContain("Subtask 2 (debug)") + expect(formatted).toContain("Fixed bugs in feature X") + }) + + it("returns last summary when results array is empty", () => { + const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults([], "Just a summary") + expect(formatted).toBe("Just a summary") + }) + + it("handles single result", () => { + const results = [{ taskId: "child-1", mode: "code", summary: "Done" }] + const formatted = (ClineProvider.prototype as any).formatAggregatedQueueResults(results, "Done") + expect(formatted).toContain("Sequential Fan-Out Complete (1 subtask)") + expect(formatted).toContain("Subtask 1 (code)") + }) +}) diff --git a/src/__tests__/task-context-builder.spec.ts b/src/__tests__/task-context-builder.spec.ts new file mode 100644 index 00000000000..ccf3b9e6841 --- /dev/null +++ b/src/__tests__/task-context-builder.spec.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from "vitest" + +import { buildTaskContext, buildChildTaskContext } from "../core/task/TaskContextBuilder" +import { defaultModeSlug } from "../shared/modes" +import type { TaskContext } from "@roo-code/types" + +describe("buildTaskContext", () => { + it("snapshots mode and API config from provider state", async () => { + const provider = { + getState: vi.fn().mockResolvedValue({ + mode: "architect", + currentApiConfigName: "gpt-4-profile", + }), + } as any + + const ctx = await buildTaskContext(provider) + expect(ctx.mode).toBe("architect") + expect(ctx.apiConfigName).toBe("gpt-4-profile") + expect(ctx.inheritSkills).toBe(true) + }) + + it("applies overrides over provider state", async () => { + const provider = { + getState: vi.fn().mockResolvedValue({ + mode: "code", + currentApiConfigName: "default", + }), + } as any + + const ctx = await buildTaskContext(provider, { + mode: "ask", + apiConfigName: "local-model", + permissions: { readOnly: true }, + parentTaskId: "parent-1", + }) + + expect(ctx.mode).toBe("ask") + expect(ctx.apiConfigName).toBe("local-model") + expect(ctx.permissions?.readOnly).toBe(true) + expect(ctx.parentTaskId).toBe("parent-1") + }) + + it("falls back to defaults when provider state is empty", async () => { + const provider = { + getState: vi.fn().mockResolvedValue(null), + } as any + + const ctx = await buildTaskContext(provider) + expect(ctx.mode).toBe(defaultModeSlug) + expect(ctx.apiConfigName).toBe("default") + }) +}) + +describe("buildChildTaskContext", () => { + it("inherits parent context when no overrides", () => { + const parent: TaskContext = { + mode: "orchestrator", + apiConfigName: "gpt-4", + permissions: { fileWritePatterns: ["docs/**"] }, + inheritSkills: true, + workspacePath: "/workspace", + rootTaskId: "root-1", + } + + const child = buildChildTaskContext(parent, { parentTaskId: "parent-1" }) + + expect(child.mode).toBe("orchestrator") + expect(child.apiConfigName).toBe("gpt-4") + expect(child.permissions?.fileWritePatterns).toEqual(["docs/**"]) + expect(child.workspacePath).toBe("/workspace") + expect(child.rootTaskId).toBe("root-1") + expect(child.parentTaskId).toBe("parent-1") + }) + + it("overrides mode and API config for child", () => { + const parent: TaskContext = { + mode: "orchestrator", + apiConfigName: "gpt-4", + rootTaskId: "root-1", + } + + const child = buildChildTaskContext(parent, { + mode: "code", + apiConfigName: "local-llama", + parentTaskId: "parent-1", + }) + + expect(child.mode).toBe("code") + expect(child.apiConfigName).toBe("local-llama") + expect(child.rootTaskId).toBe("root-1") + }) + + it("merges permissions using most-restrictive rule", () => { + const parent: TaskContext = { + mode: "orchestrator", + permissions: { + fileWritePatterns: ["docs/**", "src/**"], + allowedTools: ["read_file", "write_to_file", "list_files"], + }, + } + + const child = buildChildTaskContext(parent, { + mode: "code", + permissions: { + fileWritePatterns: ["docs/**"], + allowedTools: ["read_file", "list_files"], + }, + parentTaskId: "parent-1", + }) + + // Intersection of file write patterns + expect(child.permissions?.fileWritePatterns).toEqual(["docs/**"]) + // Intersection of allowed tools + expect(child.permissions?.allowedTools).toEqual(["read_file", "list_files"]) + }) + + it("inherits parent permissions when child specifies none", () => { + const parent: TaskContext = { + mode: "orchestrator", + permissions: { readOnly: true }, + } + + const child = buildChildTaskContext(parent, { + mode: "ask", + parentTaskId: "parent-1", + }) + + expect(child.permissions?.readOnly).toBe(true) + }) +}) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index a2ef93d8ad0..abeb3c291e0 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -108,6 +108,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/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c391f9852cf..3c9f1569e5c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -633,6 +633,8 @@ export class NativeToolCallParser { mode: partialArgs.mode, message: partialArgs.message, todos: partialArgs.todos, + task_queue: partialArgs.task_queue, + permissions: partialArgs.permissions, } } break @@ -982,6 +984,8 @@ export class NativeToolCallParser { mode: args.mode, message: args.message, todos: args.todos, + task_queue: args.task_queue, + permissions: args.permissions, } as NativeArgsFor } break diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 49ce56a305d..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. @@ -486,6 +491,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 +560,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)) } @@ -589,6 +611,7 @@ export async function presentAssistantMessage(cline: Task) { block.params, stateExperiments, includedTools, + cline.taskPermissions, ) } catch (error) { cline.consecutiveMistakeCount++ @@ -648,245 +671,285 @@ 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 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", + }) + } + + // 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/context-handoff/__tests__/collectContextSummary.spec.ts b/src/core/context-handoff/__tests__/collectContextSummary.spec.ts new file mode 100644 index 00000000000..72d605d3c1c --- /dev/null +++ b/src/core/context-handoff/__tests__/collectContextSummary.spec.ts @@ -0,0 +1,198 @@ +import type { ClineMessage } from "@roo-code/types" +import { collectContextSummary, formatContextSummaryForParent } from "../collectContextSummary" + +describe("collectContextSummary", () => { + it("extracts files modified from tool messages", () => { + const messages: ClineMessage[] = [ + { + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }), + }, + { + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "newFileCreated", path: "src/utils.ts" }), + }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.filesModified).toEqual(["src/app.ts", "src/utils.ts"]) + expect(summary.mode).toBe("code") + expect(summary.result).toBe("Done") + }) + + it("extracts files read from tool messages", () => { + const messages: ClineMessage[] = [ + { ts: 1, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "src/config.ts" }) }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.filesRead).toEqual(["src/config.ts"]) + }) + + it("removes files from filesRead if they were also modified", () => { + const messages: ClineMessage[] = [ + { ts: 1, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "src/app.ts" }) }, + { + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }), + }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.filesModified).toEqual(["src/app.ts"]) + expect(summary.filesRead).toEqual([]) + }) + + it("extracts executed commands", () => { + const messages: ClineMessage[] = [ + { ts: 1, type: "ask", ask: "command", text: "npm test" }, + { ts: 2, type: "ask", ask: "command", text: "npm run build" }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.commandsExecuted).toEqual(["npm test", "npm run build"]) + expect(summary.toolUsageCounts["execute_command"]).toBe(2) + }) + + it("counts API requests", () => { + const messages: ClineMessage[] = [ + { ts: 1, type: "say", say: "api_req_started" }, + { ts: 2, type: "say", say: "api_req_started" }, + { ts: 3, type: "say", say: "api_req_started" }, + ] + + const summary = collectContextSummary(messages, "debug", "Fixed it") + expect(summary.apiRequestCount).toBe(3) + }) + + it("counts tool usage correctly", () => { + const messages: ClineMessage[] = [ + { ts: 1, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "a.ts" }) }, + { ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", path: "b.ts" }) }, + { + ts: 3, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "c.ts" }), + }, + { ts: 4, type: "ask", ask: "tool", text: JSON.stringify({ tool: "searchFiles" }) }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.toolUsageCounts["read_file"]).toBe(2) + expect(summary.toolUsageCounts["write_to_file"]).toBe(1) + expect(summary.toolUsageCounts["search_files"]).toBe(1) + }) + + it("deduplicates modified files", () => { + const messages: ClineMessage[] = [ + { + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }), + }, + { + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "src/app.ts" }), + }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.filesModified).toEqual(["src/app.ts"]) + }) + + it("handles empty messages array", () => { + const summary = collectContextSummary([], "code", "Nothing done") + expect(summary.filesModified).toEqual([]) + expect(summary.filesRead).toEqual([]) + expect(summary.commandsExecuted).toEqual([]) + expect(summary.apiRequestCount).toBe(0) + expect(summary.result).toBe("Nothing done") + }) + + it("handles malformed tool JSON gracefully", () => { + const messages: ClineMessage[] = [ + { ts: 1, type: "ask", ask: "tool", text: "not valid json" }, + { ts: 2, type: "ask", ask: "tool", text: undefined }, + ] + + // Should not throw + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.filesModified).toEqual([]) + }) + + it("sorts files alphabetically", () => { + const messages: ClineMessage[] = [ + { + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "z-file.ts" }), + }, + { + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "a-file.ts" }), + }, + ] + + const summary = collectContextSummary(messages, "code", "Done") + expect(summary.filesModified).toEqual(["a-file.ts", "z-file.ts"]) + }) +}) + +describe("formatContextSummaryForParent", () => { + it("formats a complete summary into readable text", () => { + const summary = { + mode: "code", + filesModified: ["src/app.ts"], + filesRead: ["src/config.ts"], + commandsExecuted: ["npm test"], + toolUsageCounts: { write_to_file: 1, read_file: 1 }, + apiRequestCount: 3, + result: "Task completed", + } + + const formatted = formatContextSummaryForParent(summary) + expect(formatted).toContain("Result:\nTask completed") + expect(formatted).toContain("Mode: code") + expect(formatted).toContain("Files Modified:") + expect(formatted).toContain("src/app.ts") + expect(formatted).toContain("Files Read:") + expect(formatted).toContain("src/config.ts") + expect(formatted).toContain("Commands Executed:") + expect(formatted).toContain("npm test") + expect(formatted).toContain("Tool Usage:") + expect(formatted).toContain("API Requests: 3") + }) + + it("omits empty sections", () => { + const summary = { + mode: undefined, + filesModified: [], + filesRead: [], + commandsExecuted: [], + toolUsageCounts: {}, + apiRequestCount: 0, + result: "Done", + } + + const formatted = formatContextSummaryForParent(summary) + expect(formatted).toContain("Result:\nDone") + expect(formatted).not.toContain("Files Modified:") + expect(formatted).not.toContain("Files Read:") + expect(formatted).not.toContain("Commands Executed:") + expect(formatted).not.toContain("Tool Usage:") + expect(formatted).toContain("API Requests: 0") + }) +}) diff --git a/src/core/context-handoff/collectContextSummary.ts b/src/core/context-handoff/collectContextSummary.ts new file mode 100644 index 00000000000..64670629a2e --- /dev/null +++ b/src/core/context-handoff/collectContextSummary.ts @@ -0,0 +1,169 @@ +import type { ClineMessage, ContextHandoffSummary } from "@roo-code/types" +import type { ClineSayTool } from "@roo-code/types" + +/** + * Tool types that indicate file modifications. + */ +const FILE_MODIFY_TOOLS: ClineSayTool["tool"][] = ["editedExistingFile", "appliedDiff", "newFileCreated"] + +/** + * Tool types that indicate file reads. + */ +const FILE_READ_TOOLS: ClineSayTool["tool"][] = ["readFile"] + +/** + * Maps ClineSayTool tool names to canonical tool names used in toolUsageCounts. + */ +const TOOL_NAME_MAP: Record = { + editedExistingFile: "write_to_file", + appliedDiff: "apply_diff", + newFileCreated: "write_to_file", + codebaseSearch: "codebase_search", + readFile: "read_file", + readCommandOutput: "read_command_output", + listFilesTopLevel: "list_files", + listFilesRecursive: "list_files", + searchFiles: "search_files", + switchMode: "switch_mode", + newTask: "new_task", + finishTask: "attempt_completion", + generateImage: "generate_image", + imageGenerated: "generate_image", + runSlashCommand: "slash_command", + updateTodoList: "update_todo_list", + skill: "skill", +} + +/** + * Safely parses a JSON string from a ClineMessage's text field. + * Returns undefined if parsing fails. + */ +function safeParseToolJson(text: string | undefined): ClineSayTool | undefined { + if (!text) return undefined + try { + return JSON.parse(text) as ClineSayTool + } catch { + return undefined + } +} + +/** + * Collects a structured context summary from a task's clineMessages. + * + * Scans the message history to extract: + * - Files that were modified (write_to_file, apply_diff, new file creation) + * - Files that were read + * - Shell commands that were executed + * - Tool usage counts + * - API request count + * + * @param messages - The task's clineMessages array + * @param mode - The mode the task ran in + * @param result - The freeform completion result from attempt_completion + * @returns A ContextHandoffSummary with deduplicated, sorted data + */ +export function collectContextSummary( + messages: ClineMessage[], + mode: string | undefined, + result: string, +): ContextHandoffSummary { + const filesModified = new Set() + const filesRead = new Set() + const commandsExecuted: string[] = [] + const toolUsageCounts: Record = {} + let apiRequestCount = 0 + + for (const msg of messages) { + // Count API requests + if (msg.say === "api_req_started") { + apiRequestCount++ + continue + } + + // Extract tool usage from "tool" ask/say messages + if (msg.ask === "tool" || msg.say === "tool") { + const toolData = safeParseToolJson(msg.text) + if (!toolData) continue + + // Map to canonical tool name and count + const canonicalName = TOOL_NAME_MAP[toolData.tool] + if (canonicalName) { + toolUsageCounts[canonicalName] = (toolUsageCounts[canonicalName] || 0) + 1 + } + + // Track file modifications + if (FILE_MODIFY_TOOLS.includes(toolData.tool) && toolData.path) { + filesModified.add(toolData.path) + } + + // Track file reads (only if not also modified) + if (FILE_READ_TOOLS.includes(toolData.tool) && toolData.path) { + filesRead.add(toolData.path) + } + + // Track commands from "command" ask messages + continue + } + + // Extract executed commands + if (msg.ask === "command" && msg.text) { + commandsExecuted.push(msg.text) + toolUsageCounts["execute_command"] = (toolUsageCounts["execute_command"] || 0) + 1 + } + } + + // Remove files from filesRead if they were also modified + for (const file of filesModified) { + filesRead.delete(file) + } + + return { + mode, + filesModified: Array.from(filesModified).sort(), + filesRead: Array.from(filesRead).sort(), + commandsExecuted, + toolUsageCounts, + apiRequestCount, + result, + } +} + +/** + * Formats a ContextHandoffSummary into a human-readable string + * suitable for injection into the parent's API conversation history. + * + * @param summary - The structured context summary + * @returns A formatted string with sections for each data category + */ +export function formatContextSummaryForParent(summary: ContextHandoffSummary): string { + const sections: string[] = [] + + sections.push(`Result:\n${summary.result}`) + + if (summary.mode) { + sections.push(`Mode: ${summary.mode}`) + } + + if (summary.filesModified.length > 0) { + sections.push(`Files Modified:\n${summary.filesModified.map((f) => ` - ${f}`).join("\n")}`) + } + + if (summary.filesRead.length > 0) { + sections.push(`Files Read:\n${summary.filesRead.map((f) => ` - ${f}`).join("\n")}`) + } + + if (summary.commandsExecuted.length > 0) { + sections.push(`Commands Executed:\n${summary.commandsExecuted.map((c) => ` - ${c}`).join("\n")}`) + } + + if (Object.keys(summary.toolUsageCounts).length > 0) { + const toolLines = Object.entries(summary.toolUsageCounts) + .sort(([, a], [, b]) => b - a) + .map(([tool, count]) => ` - ${tool}: ${count}`) + sections.push(`Tool Usage:\n${toolLines.join("\n")}`) + } + + sections.push(`API Requests: ${summary.apiRequestCount}`) + + return sections.join("\n\n") +} diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f8e29e549d9..f0c22f23525 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -2,7 +2,9 @@ import type OpenAI from "openai" const NEW_TASK_DESCRIPTION = `Create a new task instance in the chosen mode using your provided message and initial todo list (if required). -CRITICAL: This tool MUST be called alone. Do NOT call this tool alongside other tools in the same message turn. If you need to gather information before delegating, use other tools in a separate turn first, then call new_task by itself in the next turn.` +CRITICAL: This tool MUST be called alone. Do NOT call this tool alongside other tools in the same message turn. If you need to gather information before delegating, use other tools in a separate turn first, then call new_task by itself in the next turn. + +SEQUENTIAL FAN-OUT: You can optionally provide a task_queue parameter to define additional subtasks that will execute automatically in sequence after the first subtask completes. Each queued subtask runs one after another without returning to the parent in between, saving time and API calls. Use this when you have planned multiple independent subtasks upfront. The first subtask is defined by the mode and message parameters; subsequent subtasks are defined in the task_queue array.` const MODE_PARAMETER_DESCRIPTION = `Slug of the mode to begin the new task in (e.g., code, debug, architect)` @@ -10,6 +12,10 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos` +const TASK_QUEUE_PARAMETER_DESCRIPTION = `Optional JSON array of additional subtasks to execute sequentially after the first subtask completes. Each element is an object with "mode" (string) and "message" (string). Example: [{"mode":"code","message":"Implement feature X"},{"mode":"debug","message":"Test feature X"}]. When provided, the system automatically transitions between subtasks without returning to the parent, collecting all results. The parent receives aggregated results when the entire queue completes.` +const PERMISSIONS_PARAMETER_DESCRIPTION = `Optional JSON object defining permission boundaries for the subtask. Allows the parent to restrict the subtask's access. Supports: filePatterns (array of regex patterns for allowed file paths), commandPatterns (array of regex patterns for allowed commands), allowedTools (array of tool names the subtask may use), deniedTools (array of tool names the subtask may NOT use). Example: {"filePatterns":["src/components/.*"],"commandPatterns":["npm test.*"],"deniedTools":["execute_command"]}` +const BACKGROUND_PARAMETER_DESCRIPTION = `When set to "true", the task runs in the background concurrently with the current task. Background tasks are restricted to read-only tools only (read_file, list_files, search_files, codebase_search). Results are delivered asynchronously when the background task completes. Use for research, analysis, or documentation lookup while continuing other work.` + export default { type: "function", function: { @@ -31,6 +37,18 @@ export default { type: ["string", "null"], description: TODOS_PARAMETER_DESCRIPTION, }, + task_queue: { + type: ["string", "null"], + description: TASK_QUEUE_PARAMETER_DESCRIPTION, + }, + permissions: { + type: ["string", "null"], + description: PERMISSIONS_PARAMETER_DESCRIPTION, + }, + background: { + type: ["string", "null"], + description: BACKGROUND_PARAMETER_DESCRIPTION, + }, }, required: ["mode", "message", "todos"], additionalProperties: false, 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 4b771269713..220b68d860c 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -1,7 +1,7 @@ import NodeCache from "node-cache" import getFolderSize from "get-folder-size" -import type { ClineMessage, HistoryItem } from "@roo-code/types" +import type { ClineMessage, HistoryItem, TaskPermissionsInput } from "@roo-code/types" import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" @@ -24,7 +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({ @@ -38,6 +42,8 @@ export async function taskMetadata({ mode, apiConfigName, initialStatus, + taskPermissions, + background, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -112,6 +118,8 @@ export async function taskMetadata({ mode, ...(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 97f07fcc7aa..e86d20ce8c0 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, @@ -39,6 +40,9 @@ import { TaskStatus, TodoItem, getApiProtocol, + type TaskPermissions, + mergeTaskPermissions, + toTaskPermissions, getModelId, isRetiredProvider, isIdleAsk, @@ -51,6 +55,7 @@ import { MIN_CHECKPOINT_TIMEOUT_SECONDS, MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, + type TaskContext, } from "@roo-code/types" // api @@ -98,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" @@ -152,13 +158,27 @@ 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. + * This is the foundation for Phase 3a task isolation -- tasks that carry their + * own context can eventually run concurrently without shared state conflicts. + * + * If not provided, the task falls back to the existing provider.getState() behavior. + */ + taskContext?: TaskContext + /** When true, the task runs in the background: webview updates are suppressed and all tool uses are auto-approved. */ + isBackgroundTask?: boolean + /** Callback invoked when a background task completes (via attempt_completion). */ + onBackgroundComplete?: (taskId: string, result: string) => void } export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string readonly parentTaskId?: string + readonly taskPermissions?: TaskPermissions childTaskId?: string pendingNewTaskToolCallId?: string @@ -172,6 +192,15 @@ export class Task extends EventEmitter implements TaskLike { readonly taskNumber: number readonly workspacePath: string + /** + * Isolated task context carrying mode, API config, and permission boundaries. + * When set, the task uses this context instead of reading shared provider state. + * This is the foundation for concurrent task execution in later phases. + * + * @see TaskContext in @roo-code/types + */ + readonly taskContext?: TaskContext + /** * The mode associated with this task. Persisted across sessions * to maintain user context when reopening tasks from history. @@ -406,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 @@ -430,6 +459,10 @@ export class Task extends EventEmitter implements TaskLike { initialTodos, workspacePath, initialStatus, + taskContext, + taskPermissions, + isBackgroundTask = false, + onBackgroundComplete, }: TaskOptions) { super() @@ -456,6 +489,14 @@ export class Task extends EventEmitter implements TaskLike { this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId this.childTaskId = undefined + // Merge task permissions with parent (most-restrictive-wins). + // When restoring from history, use the persisted permissions as the base; + // when creating fresh, use the permissions passed via new_task tool. + const effectivePermissions = historyItem?.taskPermissions + ? toTaskPermissions(historyItem.taskPermissions) + : taskPermissions + this.taskPermissions = mergeTaskPermissions(parentTask?.taskPermissions, effectivePermissions) + this.metadata = { task: historyItem ? historyItem.task : task, images: historyItem ? [] : images, @@ -491,6 +532,9 @@ export class Task extends EventEmitter implements TaskLike { this.parentTask = parentTask this.taskNumber = taskNumber this.initialStatus = initialStatus + this.taskContext = taskContext + this.isBackgroundTask = isBackgroundTask + this.onBackgroundComplete = onBackgroundComplete this.assistantMessageParser = undefined @@ -544,6 +588,14 @@ export class Task extends EventEmitter implements TaskLike { this._taskApiConfigName = historyItem.apiConfigName this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() + } else if (taskContext) { + // Phase 3a: Use isolated TaskContext instead of reading from provider state. + // This allows the task to carry its own mode and API config snapshot, + // independent of the provider's shared mutable state. + this._taskMode = taskContext.mode || defaultModeSlug + this._taskApiConfigName = taskContext.apiConfigName ?? "default" + this.taskModeReady = Promise.resolve() + this.taskApiConfigReady = Promise.resolve() } else { this._taskMode = undefined this._taskApiConfigName = undefined @@ -1175,6 +1227,19 @@ export class Task extends EventEmitter implements TaskLike { await this.taskApiConfigReady } + // Serialize only the input-level permission fields for persistence + // (exclude internal _*PatternLayers fields which are runtime-only) + const persistablePermissions = this.taskPermissions + ? { + ...(this.taskPermissions.filePatterns && { filePatterns: this.taskPermissions.filePatterns }), + ...(this.taskPermissions.commandPatterns && { + commandPatterns: this.taskPermissions.commandPatterns, + }), + ...(this.taskPermissions.allowedTools && { allowedTools: this.taskPermissions.allowedTools }), + ...(this.taskPermissions.deniedTools && { deniedTools: this.taskPermissions.deniedTools }), + } + : undefined + const { historyItem, tokenUsage } = await taskMetadata({ taskId: this.taskId, rootTaskId: this.rootTaskId, @@ -1186,6 +1251,10 @@ export class Task extends EventEmitter implements TaskLike { mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. initialStatus: this.initialStatus, + taskPermissions: + persistablePermissions && Object.keys(persistablePermissions).length > 0 + ? persistablePermissions + : undefined, }) // Emit token/tool usage updates using debounced function @@ -2293,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) => { @@ -4523,6 +4600,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/TaskContextBuilder.ts b/src/core/task/TaskContextBuilder.ts new file mode 100644 index 00000000000..dbcf88f87bc --- /dev/null +++ b/src/core/task/TaskContextBuilder.ts @@ -0,0 +1,63 @@ +import { type TaskContext, type TaskPermissions, mergePermissions } from "@roo-code/types" + +import { defaultModeSlug } from "../../shared/modes" +import type { ClineProvider } from "../webview/ClineProvider" + +/** + * Build a TaskContext from the current provider state. + * + * This factory snapshots the provider's current mode and API config + * into an immutable TaskContext that a child task can carry independently. + * This is the key enabler for Phase 3a: tasks no longer need to reach + * back into the provider for their mode/config during execution. + * + * @param provider - The ClineProvider to snapshot state from + * @param overrides - Optional overrides (e.g., mode from new_task tool) + * @returns A TaskContext snapshot + */ +export async function buildTaskContext( + provider: ClineProvider, + overrides?: Partial, +): Promise { + const state = await provider.getState() + + const context: TaskContext = { + mode: overrides?.mode ?? state?.mode ?? defaultModeSlug, + apiConfigName: overrides?.apiConfigName ?? state?.currentApiConfigName ?? "default", + permissions: overrides?.permissions, + inheritSkills: overrides?.inheritSkills ?? true, + skillOverrides: overrides?.skillOverrides, + workspacePath: overrides?.workspacePath, + parentTaskId: overrides?.parentTaskId, + rootTaskId: overrides?.rootTaskId, + } + + return context +} + +/** + * Build a TaskContext for a child task, inheriting from a parent context + * and applying any child-specific overrides. + * + * Permission merging follows the "most restrictive" principle: + * the child's effective permissions are the intersection of the parent's + * permissions and any child-specific permissions. + * + * @param parentContext - The parent task's context + * @param childOverrides - Child-specific overrides + * @returns A new TaskContext for the child task + */ +export function buildChildTaskContext(parentContext: TaskContext, childOverrides: Partial): TaskContext { + const mergedPermissions = mergePermissions(parentContext.permissions, childOverrides.permissions) + + return { + mode: childOverrides.mode ?? parentContext.mode, + apiConfigName: childOverrides.apiConfigName ?? parentContext.apiConfigName, + permissions: mergedPermissions, + inheritSkills: childOverrides.inheritSkills ?? parentContext.inheritSkills, + skillOverrides: childOverrides.skillOverrides ?? parentContext.skillOverrides, + workspacePath: childOverrides.workspacePath ?? parentContext.workspacePath, + parentTaskId: childOverrides.parentTaskId, + rootTaskId: childOverrides.rootTaskId ?? parentContext.rootTaskId, + } +} 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/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e379..e681c88d386 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,6 +1,8 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" +import type { SubtaskQueueItem } from "@roo-code/types" +import { type TaskPermissions, taskPermissionsSchema, toTaskPermissions } from "@roo-code/types" import { Task } from "../task/Task" import { getModeBySlug } from "../../shared/modes" @@ -15,12 +17,18 @@ interface NewTaskParams { mode: string message: string todos?: string + task_queue?: string + permissions?: string + /** When true, the task runs in the background concurrently with the parent. Read-only tools only. */ + background?: string } export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { + const { mode, message, todos, task_queue, permissions: permissionsJson, background } = params + const { mode, message, todos, background } = params const { mode, message, todos } = params const { askApproval, handleError, pushToolResult } = callbacks @@ -82,6 +90,33 @@ export class NewTaskTool extends BaseTool<"new_task"> { } } + // Parse and validate permissions if provided + let parsedPermissions: TaskPermissions | undefined + if (permissionsJson) { + try { + const raw = JSON.parse(permissionsJson) + const result = taskPermissionsSchema.safeParse(raw) + if (!result.success) { + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + `Invalid permissions format: ${result.error.issues.map((i) => i.message).join(", ")}`, + ), + ) + return + } + parsedPermissions = toTaskPermissions(result.data) + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError("Invalid permissions: must be a valid JSON string")) + return + } + } + task.consecutiveMistakeCount = 0 // Un-escape one level of backslashes before '@' for hierarchical subtasks @@ -96,11 +131,49 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // Parse task_queue if provided (sequential fan-out) + let queueItems: SubtaskQueueItem[] = [] + if (task_queue) { + try { + const parsed = JSON.parse(task_queue) + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (typeof item.mode === "string" && typeof item.message === "string") { + // Validate each queued mode exists + const queuedMode = getModeBySlug(item.mode, state?.customModes) + if (!queuedMode) { + pushToolResult( + formatResponse.toolError( + `Invalid mode in task_queue: "${item.mode}". All queued subtasks must use valid modes.`, + ), + ) + return + } + queueItems.push({ mode: item.mode, message: item.message }) + } + } + } + } catch { + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "Invalid task_queue format: must be a JSON array of objects with 'mode' and 'message' properties.", + ), + ) + return + } + } + const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, todos: todoItems, + taskQueue: queueItems.length > 0 ? queueItems : undefined, + ...(parsedPermissions ? { permissions: parsedPermissions } : {}), + background: isBackground, }) const didApprove = await askApproval("tool", toolMessage) @@ -115,10 +188,16 @@ export class NewTaskTool extends BaseTool<"new_task"> { message: unescapedMessage, initialTodos: todoItems, mode, + subtaskQueue: queueItems.length > 0 ? queueItems : undefined, + permissions: parsedPermissions, }) // Reflect delegation in tool result (no pause/unpause, no wait) - pushToolResult(`Delegated to child task ${child.taskId}`) + const queueMsg = + queueItems.length > 0 + ? ` (${queueItems.length} additional subtask${queueItems.length > 1 ? "s" : ""} queued)` + : "" + pushToolResult(`Delegated to child task ${child.taskId}${queueMsg}`) return } catch (error) { await handleError("creating new task", error) @@ -130,12 +209,14 @@ export class NewTaskTool extends BaseTool<"new_task"> { const mode: string | undefined = block.params.mode const message: string | undefined = block.params.message const todos: string | undefined = block.params.todos + const taskQueue: string | undefined = block.params.task_queue const partialMessage = JSON.stringify({ tool: "newTask", mode: mode ?? "", content: message ?? "", todos: todos, + taskQueue: taskQueue, }) await task.ask("tool", partialMessage, block.partial).catch(() => {}) diff --git a/src/core/tools/__tests__/taskPermissionsEnforcement.spec.ts b/src/core/tools/__tests__/taskPermissionsEnforcement.spec.ts new file mode 100644 index 00000000000..f9154e1b214 --- /dev/null +++ b/src/core/tools/__tests__/taskPermissionsEnforcement.spec.ts @@ -0,0 +1,302 @@ +import { describe, it, expect } from "vitest" +import { isToolAllowedForMode, TaskPermissionError } from "../validateToolUse" +import type { TaskPermissions } from "@roo-code/types" +import { toTaskPermissions, mergeTaskPermissions } from "@roo-code/types" +import type { ModeConfig } from "@roo-code/types" + +const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "You are a coder", + groups: ["read", "edit", "command", "mcp"], +} + +describe("TaskPermissions enforcement in isToolAllowedForMode", () => { + describe("deniedTools", () => { + it("throws TaskPermissionError when tool is in deniedTools", () => { + const permissions: TaskPermissions = { + deniedTools: ["execute_command"], + } + expect(() => + isToolAllowedForMode( + "execute_command", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toThrow(TaskPermissionError) + }) + + it("allows tools not in deniedTools", () => { + const permissions: TaskPermissions = { + deniedTools: ["execute_command"], + } + expect( + isToolAllowedForMode( + "read_file", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + + it("never denies ALWAYS_AVAILABLE_TOOLS even when in deniedTools", () => { + const permissions: TaskPermissions = { + deniedTools: ["attempt_completion", "ask_followup_question"], + } + // attempt_completion should always be allowed + expect( + isToolAllowedForMode( + "attempt_completion", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toBe(true) + // ask_followup_question should always be allowed + expect( + isToolAllowedForMode( + "ask_followup_question", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + }) + + describe("allowedTools", () => { + it("throws TaskPermissionError when tool is not in allowedTools", () => { + const permissions: TaskPermissions = { + allowedTools: ["read_file", "search_files"], + } + expect(() => + isToolAllowedForMode( + "write_to_file", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toThrow(TaskPermissionError) + }) + + it("allows tools in allowedTools", () => { + const permissions: TaskPermissions = { + allowedTools: ["read_file", "write_to_file"], + } + expect( + isToolAllowedForMode( + "read_file", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + + it("always allows ALWAYS_AVAILABLE_TOOLS even when allowedTools is set", () => { + const permissions: TaskPermissions = { + allowedTools: ["read_file"], + } + // attempt_completion and ask_followup_question should always be allowed + expect( + isToolAllowedForMode( + "attempt_completion", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + }) + + describe("filePatterns", () => { + it("throws TaskPermissionError when file path doesn't match any pattern", () => { + const permissions = toTaskPermissions({ + filePatterns: ["src/components/.*"], + }) + expect(() => + isToolAllowedForMode( + "write_to_file", + "code", + [codeMode], + undefined, + { path: "src/utils/helper.ts" }, + undefined, + undefined, + permissions, + ), + ).toThrow(TaskPermissionError) + }) + + it("allows file paths matching a pattern", () => { + const permissions = toTaskPermissions({ + filePatterns: ["src/components/.*"], + }) + expect( + isToolAllowedForMode( + "write_to_file", + "code", + [codeMode], + undefined, + { path: "src/components/Button.tsx" }, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + + it("does not restrict tools without file paths", () => { + const permissions = toTaskPermissions({ + filePatterns: ["src/components/.*"], + }) + expect( + isToolAllowedForMode( + "search_files", + "code", + [codeMode], + undefined, + { regex: "TODO" }, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + }) + + describe("filePatterns layered enforcement", () => { + it("enforces all pattern layers (AND between layers)", () => { + const parent = toTaskPermissions({ filePatterns: ["src/.*"] }) + const child = toTaskPermissions({ filePatterns: ["src/components/.*"] }) + const merged = mergeTaskPermissions(parent, child)! + + // src/components/Button.tsx matches both layers + expect( + isToolAllowedForMode( + "write_to_file", + "code", + [codeMode], + undefined, + { path: "src/components/Button.tsx" }, + undefined, + undefined, + merged, + ), + ).toBe(true) + + // src/utils/helper.ts matches parent layer but not child layer + expect(() => + isToolAllowedForMode( + "write_to_file", + "code", + [codeMode], + undefined, + { path: "src/utils/helper.ts" }, + undefined, + undefined, + merged, + ), + ).toThrow(TaskPermissionError) + + // tests/test.ts matches neither layer + expect(() => + isToolAllowedForMode( + "write_to_file", + "code", + [codeMode], + undefined, + { path: "tests/test.ts" }, + undefined, + undefined, + merged, + ), + ).toThrow(TaskPermissionError) + }) + }) + + describe("commandPatterns", () => { + it("throws TaskPermissionError when command doesn't match any pattern", () => { + const permissions = toTaskPermissions({ + commandPatterns: ["npm test.*", "npm run lint"], + }) + expect(() => + isToolAllowedForMode( + "execute_command", + "code", + [codeMode], + undefined, + { command: "rm -rf /" }, + undefined, + undefined, + permissions, + ), + ).toThrow(TaskPermissionError) + }) + + it("allows commands matching a pattern", () => { + const permissions = toTaskPermissions({ + commandPatterns: ["npm test.*", "npm run lint"], + }) + expect( + isToolAllowedForMode( + "execute_command", + "code", + [codeMode], + undefined, + { command: "npm test -- --coverage" }, + undefined, + undefined, + permissions, + ), + ).toBe(true) + }) + }) + + describe("no permissions", () => { + it("allows all tools when taskPermissions is undefined", () => { + expect( + isToolAllowedForMode( + "execute_command", + "code", + [codeMode], + undefined, + undefined, + undefined, + undefined, + undefined, + ), + ).toBe(true) + }) + }) +}) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 243a170ed90..6dd9597f0a6 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -1,5 +1,5 @@ -import type { ToolName, ModeConfig, ExperimentId, GroupOptions, GroupEntry } from "@roo-code/types" -import { toolNames as validToolNames } from "@roo-code/types" +import type { ToolName, ModeConfig, ExperimentId, GroupOptions, GroupEntry, TaskPermissions } from "@roo-code/types" +import { toolNames as validToolNames, matchesAllPatternLayers } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes" @@ -37,6 +37,7 @@ export function validateToolUse( toolParams?: Record, experiments?: Record, includedTools?: string[], + taskPermissions?: TaskPermissions, ): void { // First, check if the tool name is actually a valid/known tool // This catches completely invalid tool names like "edit_file" that don't exist @@ -56,6 +57,7 @@ export function validateToolUse( toolParams, experiments, includedTools, + taskPermissions, ) ) { throw new Error(`Tool "${toolName}" is not allowed in ${mode} mode.`) @@ -117,6 +119,19 @@ function doesFileMatchRegex(filePath: string, pattern: string): boolean { } } +/** + * Error thrown when a tool is denied by TaskPermissions. + */ +export class TaskPermissionError extends Error { + constructor( + public readonly toolName: string, + public readonly reason: string, + ) { + super(`Tool "${toolName}" is not allowed: ${reason}`) + this.name = "TaskPermissionError" + } +} + export function isToolAllowedForMode( tool: string, modeSlug: string, @@ -125,11 +140,79 @@ export function isToolAllowedForMode( toolParams?: Record, // All tool parameters experiments?: Record, includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) + taskPermissions?: TaskPermissions, ): boolean { // Resolve alias to canonical name (e.g., "search_and_replace" → "edit") const resolvedTool = TOOL_ALIASES[tool] ?? tool const resolvedIncludedTools = includedTools?.map((t) => TOOL_ALIASES[t] ?? t) + // Check TaskPermissions first -- these are set by the parent task via new_task + if (taskPermissions) { + const isAlwaysAvailable = ALWAYS_AVAILABLE_TOOLS.includes(tool as any) + + // Check deniedTools (but never deny always-available tools like attempt_completion) + if ( + !isAlwaysAvailable && + (taskPermissions.deniedTools?.includes(resolvedTool) || taskPermissions.deniedTools?.includes(tool)) + ) { + throw new TaskPermissionError(tool, "This tool is denied by the parent task's permission boundaries.") + } + + // Check allowedTools (if set, only these tools are permitted) + if (taskPermissions.allowedTools) { + const isAllowed = + taskPermissions.allowedTools.includes(resolvedTool) || + taskPermissions.allowedTools.includes(tool) || + // Always allow certain critical tools regardless of allowlist + isAlwaysAvailable + if (!isAllowed) { + throw new TaskPermissionError(tool, "This tool is not in the parent task's allowed tools list.") + } + } + + // Check filePatterns -- use layered enforcement when available (AND between + // layers, OR within each layer), fall back to flat filePatterns as a single layer. + const filePatternLayers = + taskPermissions._filePatternLayers ?? + (taskPermissions.filePatterns?.length ? [taskPermissions.filePatterns] : undefined) + + if (filePatternLayers && filePatternLayers.length > 0) { + const filePath = toolParams?.path || toolParams?.file_path + if (filePath && typeof filePath === "string") { + if (!matchesAllPatternLayers(filePath, filePatternLayers)) { + throw new TaskPermissionError(tool, `File "${filePath}" is outside the allowed file patterns.`) + } + } + + // Check apply_patch file paths + if (tool === "apply_patch" && typeof toolParams?.patch === "string") { + const patchFilePaths = extractFilePathsFromPatch(toolParams.patch) + for (const patchFilePath of patchFilePaths) { + if (!matchesAllPatternLayers(patchFilePath, filePatternLayers)) { + throw new TaskPermissionError( + tool, + `File "${patchFilePath}" in patch is outside the allowed file patterns.`, + ) + } + } + } + } + + // Check commandPatterns -- same layered approach as filePatterns. + const commandPatternLayers = + taskPermissions._commandPatternLayers ?? + (taskPermissions.commandPatterns?.length ? [taskPermissions.commandPatterns] : undefined) + + if (commandPatternLayers && commandPatternLayers.length > 0 && resolvedTool === "execute_command") { + const command = toolParams?.command + if (command && typeof command === "string") { + if (!matchesAllPatternLayers(command, commandPatternLayers)) { + throw new TaskPermissionError(tool, `Command "${command}" is outside the allowed command patterns.`) + } + } + } + } + // Check tool requirements first — explicit disabling takes priority over everything, // including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently // at both the filtering layer and the execution-time validation layer. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 61a54f8ead7..7d68d2ad506 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -80,15 +80,25 @@ import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" +import { buildTaskContext } from "../task/TaskContextBuilder" +import { BackgroundTaskRunner, BACKGROUND_TASK_ALLOWED_TOOLS } from "../task/BackgroundTaskRunner" +import { + BackgroundTaskRunner, + BACKGROUND_TASK_ALLOWED_TOOLS, + BackgroundTaskRunnerCallbacks, +} from "../task/BackgroundTaskRunner" import { webviewMessageHandler } from "./webviewMessageHandler" -import type { ClineMessage, TodoItem } from "@roo-code/types" +import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions, ContextHandoffSummary } from "@roo-code/types" +import { collectContextSummary, formatContextSummaryForParent } from "../context-handoff/collectContextSummary" import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" 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 @@ -149,7 +159,13 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number +<<<<<<< HEAD + /** 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 latestAnnouncementId = "may-2026-final-roo-code-release" // Final Roo Code release announcement. +>>>>>>> origin/main public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -2785,8 +2801,11 @@ export class ClineProvider message: string initialTodos: TodoItem[] mode: string + subtaskQueue?: SubtaskQueueItem[] + /** Optional permission boundaries for the child task (Phase 3a) */ + permissions?: TaskPermissions }): Promise { - const { parentTaskId, message, initialTodos, mode } = params + const { parentTaskId, message, initialTodos, mode, subtaskQueue, permissions } = params // Metadata-driven delegation is always enabled @@ -2862,24 +2881,36 @@ export class ClineProvider ) } - // 4) Create child as sole active (parent reference preserved for lineage) + // 4) Build an isolated TaskContext for the child (Phase 3a). + // This snapshots mode, API config, and permission boundaries so the child + // task carries its own context instead of reading shared provider state. + const childTaskContext = await buildTaskContext(this, { + mode, + permissions, + parentTaskId, + rootTaskId: parent.rootTaskId ?? parent.taskId, + }) + + // 5) Create child as sole active (parent reference preserved for lineage) // Pass initialStatus: "active" to ensure the child task's historyItem is created // with status from the start, avoiding race conditions where the task might // call attempt_completion before status is persisted separately. // // Pass startTask: false to prevent the child from beginning its task loop // (and writing to globalState via saveClineMessages → updateTaskHistory) - // before we persist the parent's delegation metadata in step 5. - // Without this, the child's fire-and-forget startTask() races with step 5, + // before we persist the parent's delegation metadata in step 6. + // Without this, the child's fire-and-forget startTask() races with step 6, // and the last writer to globalState overwrites the other's changes— // causing the parent's delegation fields to be lost. const child = await this.createTask(message, undefined, parent as any, { initialTodos, initialStatus: "active", + taskPermissions: permissions, startTask: false, + taskContext: childTaskContext, }) - // 5) Persist parent delegation metadata BEFORE the child starts writing. + // 6) Persist parent delegation metadata BEFORE the child starts writing. try { const { historyItem } = await this.getTaskWithId(parentTaskId) const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) @@ -2889,6 +2920,9 @@ export class ClineProvider delegatedToId: child.taskId, awaitingChildId: child.taskId, childIds, + ...(subtaskQueue && subtaskQueue.length > 0 + ? { subtaskQueue, subtaskQueueIndex: 0, subtaskResults: [] } + : {}), } await this.updateTaskHistory(updatedHistory) } catch (err) { @@ -2899,10 +2933,10 @@ export class ClineProvider ) } - // 6) Start the child task now that parent metadata is safely persisted. + // 7) Start the child task now that parent metadata is safely persisted. child.start() - // 7) Emit TaskDelegated (provider-level) + // 8) Emit TaskDelegated (provider-level) try { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) } catch { @@ -2920,12 +2954,28 @@ export class ClineProvider childTaskId: string completionResultSummary: string }): Promise { - const { parentTaskId, childTaskId, completionResultSummary } = params + const { parentTaskId, childTaskId } = params + let effectiveSummary = params.completionResultSummary const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages const { historyItem } = await this.getTaskWithId(parentTaskId) + // PHASE 2: Sequential fan-out — check if parent has queued subtasks + if (historyItem.subtaskQueue && historyItem.subtaskQueue.length > 0) { + const queueAdvanceResult = await this.advanceSubtaskQueue({ + parentTaskId, + childTaskId, + completionResultSummary: effectiveSummary, + historyItem, + }) + if (queueAdvanceResult.handled) { + return // Queue advanced to next subtask; do NOT reopen parent + } + // Queue exhausted — use aggregated summary and continue with normal reopen + effectiveSummary = queueAdvanceResult.aggregatedSummary + } + let parentClineMessages: ClineMessage[] = [] try { parentClineMessages = await readTaskMessages({ @@ -2946,6 +2996,42 @@ export class ClineProvider parentApiMessages = [] } + // 1b) Collect structured context from the child's clineMessages + let contextSummary: ContextHandoffSummary | undefined + let formattedSummary = completionResultSummary + try { + let childClineMessages: ClineMessage[] = [] + // Prefer in-memory messages from the current task if it's still the active child + const currentTask = this.getCurrentTask() + if (currentTask?.taskId === childTaskId && currentTask.clineMessages.length > 0) { + childClineMessages = currentTask.clineMessages + } else { + childClineMessages = await readTaskMessages({ + taskId: childTaskId, + globalStoragePath, + }) + } + + // Get child's mode from history + let childMode: string | undefined + try { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + childMode = childHistory.mode + } catch { + // non-fatal + } + + contextSummary = collectContextSummary(childClineMessages, childMode, completionResultSummary) + formattedSummary = formatContextSummaryForParent(contextSummary) + } catch (err) { + this.log( + `[reopenParentFromDelegation] Failed to collect context summary for child ${childTaskId} (non-fatal): ${ + (err as Error)?.message ?? String(err) + }`, + ) + // Fall back to unstructured summary + } + // 2) Inject synthetic records: UI subtask_result and update API tool_result const ts = Date.now() @@ -2953,10 +3039,26 @@ 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", - text: completionResultSummary, + text: effectiveSummary, + text: contextSummary ? JSON.stringify(contextSummary) : completionResultSummary, ts, } parentClineMessages.push(subtaskUiMessage) @@ -2988,8 +3090,10 @@ 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 with enriched summary + block.content = apiResultText // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + block.content = `Subtask ${childTaskId} completed.\n\n${formattedSummary}` alreadyHasToolResult = true break } @@ -3004,7 +3108,8 @@ export class ClineProvider { type: "tool_result" as const, tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + content: apiResultText, + content: `Subtask ${childTaskId} completed.\n\n${formattedSummary}`, }, ], ts, @@ -3027,7 +3132,7 @@ export class ClineProvider content: [ { type: "text" as const, - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + text: apiResultText, }, ], ts, @@ -3069,7 +3174,9 @@ export class ClineProvider ...historyItem, status: "active", completedByChildId: childTaskId, + completionResultSummary: effectiveSummary, completionResultSummary, + contextHandoffSummary: contextSummary, awaitingChildId: undefined, childIds, } @@ -3077,7 +3184,7 @@ export class ClineProvider // 6) Emit TaskDelegationCompleted (provider-level) try { - this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, effectiveSummary) } catch { // non-fatal } @@ -3111,6 +3218,152 @@ export class ClineProvider } } + /** + * Advance the sequential fan-out subtask queue. + * Called when a child completes and the parent has a subtaskQueue. + * + * Returns { handled: true } if the next subtask was started (caller should return). + * Returns { handled: false, aggregatedSummary } if queue is exhausted (caller should continue with normal reopen). + */ + private async advanceSubtaskQueue(params: { + parentTaskId: string + childTaskId: string + completionResultSummary: string + historyItem: HistoryItem + }): Promise<{ handled: true } | { handled: false; aggregatedSummary: string }> { + const { parentTaskId, childTaskId, completionResultSummary, historyItem } = params + const { subtaskQueue, subtaskQueueIndex, subtaskResults } = historyItem + if (!subtaskQueue || subtaskQueue.length === 0) { + return { handled: false, aggregatedSummary: completionResultSummary } + } + + // currentIndex is the next queue item to dispatch (0-based). + // When the initial child (from mode/message params) completes, currentIndex is 0, + // meaning queue[0] should be dispatched first. + const currentIndex = subtaskQueueIndex ?? 0 + + // Close current child if still open + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + // Fetch child history to get the child's actual mode and mark it completed + let completedMode = "unknown" + try { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + completedMode = childHistory.mode ?? "unknown" + await this.updateTaskHistory({ ...childHistory, status: "completed" }) + } catch (err) { + this.log( + `[advanceSubtaskQueue] Failed to persist child completed status for ${childTaskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // Record this child's result using the child's actual mode + const updatedResults = [ + ...(subtaskResults ?? []), + { taskId: childTaskId, mode: completedMode, summary: completionResultSummary }, + ] + + // Emit completion event for the finished child + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + } catch { + // non-fatal + } + + if (currentIndex < subtaskQueue.length) { + // More subtasks in queue — start the next one + const nextSubtask = subtaskQueue[currentIndex] + this.log( + `[advanceSubtaskQueue] Auto-advancing queue: subtask ${currentIndex + 1}/${subtaskQueue.length} (mode: ${nextSubtask.mode})`, + ) + + // Switch mode + try { + await this.handleModeSwitch(nextSubtask.mode as any) + } catch (e) { + this.log( + `[advanceSubtaskQueue] handleModeSwitch failed for queued mode '${nextSubtask.mode}': ${ + (e as Error)?.message ?? String(e) + }`, + ) + } + + // Create next child + const nextChild = await this.createTask(nextSubtask.message, undefined, undefined, { + initialTodos: [], + initialStatus: "active", + startTask: false, + }) + + // Update parent metadata + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId, nextChild.taskId])) + await this.updateTaskHistory({ + ...historyItem, + status: "delegated", + delegatedToId: nextChild.taskId, + awaitingChildId: nextChild.taskId, + childIds, + subtaskQueue, + subtaskQueueIndex: currentIndex + 1, + subtaskResults: updatedResults, + }) + + // Start the child + nextChild.start() + + try { + this.emit(RooCodeEventName.TaskDelegated, parentTaskId, nextChild.taskId) + } catch { + // non-fatal + } + + return { handled: true } + } + + // Queue exhausted — aggregate results and let normal reopen proceed + const aggregatedSummary = this.formatAggregatedQueueResults(updatedResults, completionResultSummary) + + // Clear queue from parent metadata (will be fully updated by caller) + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) + await this.updateTaskHistory({ + ...historyItem, + subtaskQueue: undefined, + subtaskQueueIndex: undefined, + subtaskResults: updatedResults, + childIds, + }) + + return { handled: false, aggregatedSummary } + } + + /** + * Format aggregated results from all completed subtasks in a queue. + */ + private formatAggregatedQueueResults( + results: Array<{ taskId: string; mode: string; summary: string }>, + lastSummary: string, + ): string { + if (results.length === 0) { + return lastSummary + } + + const lines = [`## Sequential Fan-Out Complete (${results.length} subtask${results.length > 1 ? "s" : ""})`, ""] + + for (let i = 0; i < results.length; i++) { + const r = results[i] + lines.push(`### Subtask ${i + 1} (${r.mode}) — ${r.taskId}`) + lines.push(r.summary) + lines.push("") + } + + return lines.join("\n") + } + /** * Convert a file path to a webview-accessible URI * This method safely converts file paths to URIs that can be loaded in the webview 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 fac7ed10d57..9a912a5f46f 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -22,7 +22,7 @@ import { import { customToolRegistry } from "@roo-code/core" 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" @@ -767,6 +767,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 diff --git a/src/package.json b/src/package.json index cf14fd1ecb1..8902b2e5fe2 100644 --- a/src/package.json +++ b/src/package.json @@ -159,6 +159,11 @@ "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.backgroundTasksButtonClicked", + "title": "Background Tasks", + "icon": "$(server-process)" } ], "menus": { @@ -219,9 +224,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": [ @@ -241,9 +251,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/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/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__/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/__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/__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 new file mode 100644 index 00000000000..8d6896b83a8 --- /dev/null +++ b/src/services/file-lock/index.ts @@ -0,0 +1,58 @@ +export { + FileLockManager, + type FileLockManagerOptions, + type FileLockInfo, + type LockConflict, + type FileLockEvent, + type FileLockEventListener, +} from "./FileLockManager" + +export { + LockGuardedToolExecutor, + extractWriteTargetPaths, + 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 +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 4cac8335ea7..0179e1c551b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -56,6 +56,9 @@ export const toolParamNames = [ "start_line", "end_line", "todos", + "task_queue", + "permissions", // new_task parameter for subtask permission boundaries + "background", // new_task parameter for background task execution "prompt", "image", // read_file parameters (native protocol) @@ -102,6 +105,8 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } + new_task: { mode: string; message: string; todos?: string; 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 @@ -240,6 +245,9 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" + params: Partial, "mode" | "message" | "todos" | "task_queue">> + params: Partial, "mode" | "message" | "todos" | "permissions">> + params: Partial, "mode" | "message" | "todos" | "background">> params: Partial, "mode" | "message" | "todos">> } diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 99847dc8eb4..b47d52f59f4 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" @@ -19,7 +21,12 @@ import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonI import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" +<<<<<<< HEAD +type Tab = "settings" | "history" | "chat" | "cloud" +type Tab = "settings" | "history" | "chat" | "bgTaskReplay" | "bgTask" +======= type Tab = "settings" | "history" | "chat" +>>>>>>> origin/main interface DeleteMessageDialogState { isOpen: boolean @@ -43,6 +50,11 @@ const tabsByMessageAction: Partial>>>>>> origin/main } const App = () => { @@ -50,6 +62,7 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const [replayTaskId, setReplayTaskId] = useState(null) const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, @@ -88,6 +101,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 @@ -174,6 +191,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 097f166efd9..1763b79b387 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() { @@ -206,6 +224,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/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 2fd3a274f6a..f4ee2668be6 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -869,6 +869,39 @@ export const ChatRowContent = ({
+ {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 && ( + 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__/ChatRow.permissions.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.permissions.spec.tsx new file mode 100644 index 00000000000..5f36e4ea54c --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.permissions.spec.tsx @@ -0,0 +1,182 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ChatRowContent } from "../ChatRow" +import type { HistoryItem, ClineMessage } from "@roo-code/types" + +// Mock vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (msg: unknown) => mockPostMessage(msg), + }, +})) + +// Mock i18n - return key-based strings so we can assert on the right keys +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + const map: Record = { + "chat:subtasks.wantsToCreate": "Roo wants to create a new subtask", + "chat:subtasks.permissionBoundaries": "Permission Boundaries", + "chat:subtasks.goToSubtask": "Go to subtask", + } + if (key === "chat:subtasks.permissionFilePatterns" && params?.patterns) { + return `Allowed files: ${params.patterns}` + } + if (key === "chat:subtasks.permissionCommandPatterns" && params?.patterns) { + return `Allowed commands: ${params.patterns}` + } + if (key === "chat:subtasks.permissionAllowedTools" && params?.tools) { + return `Allowed tools: ${params.tools}` + } + if (key === "chat:subtasks.permissionDeniedTools" && params?.tools) { + return `Denied tools: ${params.tools}` + } + return map[key] ?? key + }, + i18n: { exists: () => true }, + }), + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock extension state context +let mockCurrentTaskItem: Partial | undefined = undefined +let mockClineMessages: ClineMessage[] = [] + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + alwaysAllowMcp: false, + currentCheckpoint: null, + mode: "code", + apiConfiguration: {}, + clineMessages: mockClineMessages, + currentTaskItem: mockCurrentTaskItem, + }), +})) + +// Mock useSelectedModel hook +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ info: { supportsImages: true } }), +})) + +const queryClient = new QueryClient() + +function renderChatRow(message: any, currentTaskItem?: Partial, clineMessages?: ClineMessage[]) { + mockCurrentTaskItem = currentTaskItem + mockClineMessages = clineMessages || [message] + + return render( + + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + /> + , + ) +} + +describe("ChatRow - permission boundaries display", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("should display permission boundaries when permissions are set on a newTask", () => { + const message = { + ts: Date.now(), + type: "ask" as const, + ask: "tool" as const, + text: JSON.stringify({ + tool: "newTask", + mode: "code", + content: "Edit the Button component", + permissions: { + filePatterns: ["src/components/.*"], + commandPatterns: ["npm test.*"], + deniedTools: ["execute_command"], + }, + }), + } + + renderChatRow(message) + + expect(screen.getByText("Permission Boundaries")).toBeInTheDocument() + expect(screen.getByText("Allowed files: src/components/.*")).toBeInTheDocument() + expect(screen.getByText("Allowed commands: npm test.*")).toBeInTheDocument() + expect(screen.getByText("Denied tools: execute_command")).toBeInTheDocument() + }) + + it("should display allowedTools when set", () => { + const message = { + ts: Date.now(), + type: "ask" as const, + ask: "tool" as const, + text: JSON.stringify({ + tool: "newTask", + mode: "ask", + content: "Research the API", + permissions: { + allowedTools: ["read_file", "search_files", "codebase_search"], + }, + }), + } + + renderChatRow(message) + + expect(screen.getByText("Permission Boundaries")).toBeInTheDocument() + expect(screen.getByText("Allowed tools: read_file, search_files, codebase_search")).toBeInTheDocument() + }) + + it("should not display permission boundaries when permissions are not set", () => { + const message = { + ts: Date.now(), + type: "ask" as const, + ask: "tool" as const, + text: JSON.stringify({ + tool: "newTask", + mode: "code", + content: "Implement feature X", + }), + } + + renderChatRow(message) + + expect(screen.queryByText("Permission Boundaries")).not.toBeInTheDocument() + }) + + it("should display multiple permission types together", () => { + const message = { + ts: Date.now(), + type: "ask" as const, + ask: "tool" as const, + text: JSON.stringify({ + tool: "newTask", + mode: "code", + content: "Edit and test components", + permissions: { + filePatterns: ["src/components/.*", "src/utils/.*"], + commandPatterns: ["npm test.*", "npm run lint"], + allowedTools: ["read_file", "write_to_file", "execute_command"], + deniedTools: ["apply_patch"], + }, + }), + } + + renderChatRow(message) + + expect(screen.getByText("Permission Boundaries")).toBeInTheDocument() + expect(screen.getByText("Allowed files: src/components/.*, src/utils/.*")).toBeInTheDocument() + expect(screen.getByText("Allowed commands: npm test.*, npm run lint")).toBeInTheDocument() + expect(screen.getByText("Allowed tools: read_file, write_to_file, execute_command")).toBeInTheDocument() + expect(screen.getByText("Denied tools: apply_patch")).toBeInTheDocument() + }) +}) 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/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 d0dc367e646..465c16100cb 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,25 @@ const TaskItemFooter: React.FC = ({ return (
+ {/* Background task tag */} + {item.background && ( + <> + {item.status === "interrupted" ? ( + + + + {t("history:interruptedTag")} + + + ) : ( + <> + + {t("history:backgroundTag")} + + )} + · + + )} {/* Subtask tag */} {isSubtask && ( <> 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 aa334d94c26..6de1ab0fd38 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -94,4 +94,46 @@ 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() + }) + + 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/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/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 3d590b1e8e4..16abd10d534 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -259,7 +259,16 @@ "resultContent": "Resultats de la subtasca", "defaultResult": "Si us plau, continua amb la següent tasca.", "completionInstructions": "Subtasca completada! Pots revisar els resultats i suggerir correccions o següents passos. Si tot sembla correcte, confirma per tornar el resultat a la tasca principal.", - "goToSubtask": "Veure tasca" + "goToSubtask": "Veure tasca", + "filesModified": "Fitxers modificats", + "filesRead": "Fitxers llegits", + "commandsExecuted": "Comandes executades", + "todoStats": "Tasques: {{completed}}/{{total}} completades", + "permissionBoundaries": "Límits de permisos", + "permissionFilePatterns": "Fitxers permesos: {{patterns}}", + "permissionCommandPatterns": "Comandes permeses: {{patterns}}", + "permissionAllowedTools": "Eines permeses: {{tools}}", + "permissionDeniedTools": "Eines denegades: {{tools}}" }, "questions": { "hasQuestion": "Roo té una pregunta" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Obrir configuració", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Resum del traspàs de context", + "mode": "Mode", + "filesModified": "Fitxers modificats", + "filesRead": "Fitxers llegits", + "commandsExecuted": "Comandes executades", + "apiRequests": "Sol·licituds d'API" } } diff --git a/webview-ui/src/i18n/locales/ca/history.json b/webview-ui/src/i18n/locales/ca/history.json index ab1bba7582e..0601cbb5569 100644 --- a/webview-ui/src/i18n/locales/ca/history.json +++ b/webview-ui/src/i18n/locales/ca/history.json @@ -54,5 +54,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/de/chat.json index bfa53ee65ef..3a95d01b5bd 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -259,7 +259,16 @@ "resultContent": "Teilaufgabenergebnisse", "defaultResult": "Bitte fahre mit der nächsten Aufgabe fort.", "completionInstructions": "Teilaufgabe abgeschlossen! Du kannst die Ergebnisse überprüfen und Korrekturen oder nächste Schritte vorschlagen. Wenn alles gut aussieht, bestätige, um das Ergebnis an die übergeordnete Aufgabe zurückzugeben.", - "goToSubtask": "Aufgabe anzeigen" + "goToSubtask": "Aufgabe anzeigen", + "filesModified": "Geänderte Dateien", + "filesRead": "Gelesene Dateien", + "commandsExecuted": "Ausgeführte Befehle", + "todoStats": "Aufgaben: {{completed}}/{{total}} erledigt", + "permissionBoundaries": "Berechtigungsgrenzen", + "permissionFilePatterns": "Erlaubte Dateien: {{patterns}}", + "permissionCommandPatterns": "Erlaubte Befehle: {{patterns}}", + "permissionAllowedTools": "Erlaubte Tools: {{tools}}", + "permissionDeniedTools": "Verweigerte Tools: {{tools}}" }, "questions": { "hasQuestion": "Roo hat eine Frage" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Einstellungen öffnen", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Kontextübergabe-Zusammenfassung", + "mode": "Modus", + "filesModified": "Geänderte Dateien", + "filesRead": "Gelesene Dateien", + "commandsExecuted": "Ausgeführte Befehle", + "apiRequests": "API-Anfragen" } } diff --git a/webview-ui/src/i18n/locales/de/history.json b/webview-ui/src/i18n/locales/de/history.json index 46064d6e2ea..98abd97fec0 100644 --- a/webview-ui/src/i18n/locales/de/history.json +++ b/webview-ui/src/i18n/locales/de/history.json @@ -54,5 +54,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 3068c92d740..e39d3fa5de0 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -301,7 +301,24 @@ "resultContent": "Subtask completed", "defaultResult": "Please continue to the next task.", "completionInstructions": "You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task.", - "goToSubtask": "View task" + "goToSubtask": "View task", + "filesModified": "Files Modified", + "filesRead": "Files Read", + "commandsExecuted": "Commands Executed", + "todoStats": "Todos: {{completed}}/{{total}} completed", +"permissionBoundaries": "Permission Boundaries", + "permissionFilePatterns": "Allowed files: {{patterns}}", + "permissionCommandPatterns": "Allowed commands: {{patterns}}", + "permissionAllowedTools": "Allowed tools: {{tools}}", + "permissionDeniedTools": "Denied tools: {{tools}}" + }, + "contextHandoff": { + "title": "Context Handoff Summary", + "mode": "Mode", + "filesModified": "Files Modified", + "filesRead": "Files Read", + "commandsExecuted": "Commands Executed", + "apiRequests": "API Requests" }, "questions": { "hasQuestion": "Roo has a question" diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 85174890e14..e4429459d12 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -47,5 +47,15 @@ "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", + "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": { + "prefix": "Filter:", + "all": "All Tasks", + "foregroundOnly": "Foreground Only" + } } diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index da6d8d96c01..269b710c6b2 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -259,7 +259,16 @@ "resultContent": "Resultados de la subtarea", "defaultResult": "Por favor, continúa con la siguiente tarea.", "completionInstructions": "¡Subtarea completada! Puedes revisar los resultados y sugerir correcciones o próximos pasos. Si todo se ve bien, confirma para devolver el resultado a la tarea principal.", - "goToSubtask": "Ver tarea" + "goToSubtask": "Ver tarea", + "filesModified": "Archivos modificados", + "filesRead": "Archivos leídos", + "commandsExecuted": "Comandos ejecutados", + "todoStats": "Tareas: {{completed}}/{{total}} completadas", + "permissionBoundaries": "Límites de permisos", + "permissionFilePatterns": "Archivos permitidos: {{patterns}}", + "permissionCommandPatterns": "Comandos permitidos: {{patterns}}", + "permissionAllowedTools": "Herramientas permitidas: {{tools}}", + "permissionDeniedTools": "Herramientas denegadas: {{tools}}" }, "questions": { "hasQuestion": "Roo tiene una pregunta" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Abrir configuración", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Resumen del traspaso de contexto", + "mode": "Modo", + "filesModified": "Archivos modificados", + "filesRead": "Archivos leídos", + "commandsExecuted": "Comandos ejecutados", + "apiRequests": "Solicitudes de API" } } diff --git a/webview-ui/src/i18n/locales/es/history.json b/webview-ui/src/i18n/locales/es/history.json index 820f003d402..18c8ea4eaa0 100644 --- a/webview-ui/src/i18n/locales/es/history.json +++ b/webview-ui/src/i18n/locales/es/history.json @@ -54,5 +54,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 625da021237..739f1392797 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -259,7 +259,16 @@ "resultContent": "Résultats de la sous-tâche", "defaultResult": "Veuillez continuer avec la tâche suivante.", "completionInstructions": "Sous-tâche terminée ! Vous pouvez examiner les résultats et suggérer des corrections ou les prochaines étapes. Si tout semble bon, confirmez pour retourner le résultat à la tâche parente.", - "goToSubtask": "Afficher la tâche" + "goToSubtask": "Afficher la tâche", + "filesModified": "Fichiers modifiés", + "filesRead": "Fichiers lus", + "commandsExecuted": "Commandes exécutées", + "todoStats": "Tâches : {{completed}}/{{total}} terminées", + "permissionBoundaries": "Limites de permissions", + "permissionFilePatterns": "Fichiers autorisés : {{patterns}}", + "permissionCommandPatterns": "Commandes autorisées : {{patterns}}", + "permissionAllowedTools": "Outils autorisés : {{tools}}", + "permissionDeniedTools": "Outils refusés : {{tools}}" }, "questions": { "hasQuestion": "Roo a une question" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Ouvrir les paramètres", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Résumé du transfert de contexte", + "mode": "Mode", + "filesModified": "Fichiers modifiés", + "filesRead": "Fichiers lus", + "commandsExecuted": "Commandes exécutées", + "apiRequests": "Requêtes API" } } diff --git a/webview-ui/src/i18n/locales/fr/history.json b/webview-ui/src/i18n/locales/fr/history.json index d84cfcb190a..f2782169187 100644 --- a/webview-ui/src/i18n/locales/fr/history.json +++ b/webview-ui/src/i18n/locales/fr/history.json @@ -54,5 +54,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index e88b183aff7..9abb5b40f72 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -259,7 +259,16 @@ "resultContent": "उपकार्य परिणाम", "defaultResult": "कृपया अगले कार्य पर जारी रखें।", "completionInstructions": "उपकार्य पूर्ण! आप परिणामों की समीक्षा कर सकते हैं और सुधार या अगले चरण सुझा सकते हैं। यदि सब कुछ ठीक लगता है, तो मुख्य कार्य को परिणाम वापस करने के लिए पुष्टि करें।", - "goToSubtask": "कार्य देखें" + "goToSubtask": "कार्य देखें", + "filesModified": "संशोधित फ़ाइलें", + "filesRead": "पढ़ी गई फ़ाइलें", + "commandsExecuted": "निष्पादित कमांड", + "todoStats": "कार्य: {{completed}}/{{total}} पूर्ण", + "permissionBoundaries": "अनुमति सीमाएँ", + "permissionFilePatterns": "अनुमत फ़ाइलें: {{patterns}}", + "permissionCommandPatterns": "अनुमत कमांड: {{patterns}}", + "permissionAllowedTools": "अनुमत टूल: {{tools}}", + "permissionDeniedTools": "अस्वीकृत टूल: {{tools}}" }, "questions": { "hasQuestion": "Roo का एक प्रश्न है" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "सेटिंग्स खोलें", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "कॉन्टेक्स्ट हैंडऑफ़ सारांश", + "mode": "मोड", + "filesModified": "संशोधित फ़ाइलें", + "filesRead": "पढ़ी गई फ़ाइलें", + "commandsExecuted": "निष्पादित कमांड", + "apiRequests": "API अनुरोध" } } diff --git a/webview-ui/src/i18n/locales/hi/history.json b/webview-ui/src/i18n/locales/hi/history.json index 0d4cb40dd9e..2bf9aa3a6e9 100644 --- a/webview-ui/src/i18n/locales/hi/history.json +++ b/webview-ui/src/i18n/locales/hi/history.json @@ -47,5 +47,15 @@ "subtaskTag": "उप-कार्य", "deleteWithSubtasks": "यह {{count}} उप-कार्य(कों) को भी हटा देगा। क्या आप निश्चित हैं?", "expandSubtasks": "उप-कार्य विस्तारित करें", - "collapseSubtasks": "उप-कार्य संपीड़ित करें" + "collapseSubtasks": "उप-कार्य संपीड़ित करें", + "backgroundTag": "बैकग्राउंड", + "interruptedTag": "बाधित", + "showBackgroundTasks": "बैकग्राउंड टास्क दिखाएं", + "hideBackgroundTasks": "बैकग्राउंड टास्क छुपाएं", + "filter": { + "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/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 8d5248b8146..fc5ed82c98c 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -310,7 +310,16 @@ "resultContent": "Hasil Subtugas", "defaultResult": "Silakan lanjutkan ke tugas berikutnya.", "completionInstructions": "Subtugas selesai! Kamu bisa meninjau hasilnya dan menyarankan koreksi atau langkah selanjutnya. Jika semuanya terlihat baik, konfirmasi untuk mengembalikan hasil ke tugas induk.", - "goToSubtask": "Lihat tugas" + "goToSubtask": "Lihat tugas", + "filesModified": "File yang Diubah", + "filesRead": "File yang Dibaca", + "commandsExecuted": "Perintah yang Dijalankan", + "todoStats": "Tugas: {{completed}}/{{total}} selesai", + "permissionBoundaries": "Batasan Izin", + "permissionFilePatterns": "File yang diizinkan: {{patterns}}", + "permissionCommandPatterns": "Perintah yang diizinkan: {{patterns}}", + "permissionAllowedTools": "Alat yang diizinkan: {{tools}}", + "permissionDeniedTools": "Alat yang ditolak: {{tools}}" }, "questions": { "hasQuestion": "Roo punya pertanyaan" @@ -493,5 +502,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Buka Pengaturan", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Ringkasan Serah Terima Konteks", + "mode": "Mode", + "filesModified": "File Dimodifikasi", + "filesRead": "File Dibaca", + "commandsExecuted": "Perintah Dieksekusi", + "apiRequests": "Permintaan API" } } diff --git a/webview-ui/src/i18n/locales/id/history.json b/webview-ui/src/i18n/locales/id/history.json index 7796061107e..a874a632712 100644 --- a/webview-ui/src/i18n/locales/id/history.json +++ b/webview-ui/src/i18n/locales/id/history.json @@ -56,5 +56,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/it/chat.json index fe69784f267..569f72765fe 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -259,7 +259,16 @@ "resultContent": "Risultati sottoattività", "defaultResult": "Per favore continua con la prossima attività.", "completionInstructions": "Sottoattività completata! Puoi rivedere i risultati e suggerire correzioni o prossimi passi. Se tutto sembra a posto, conferma per restituire il risultato all'attività principale.", - "goToSubtask": "Visualizza attività" + "goToSubtask": "Visualizza attività", + "filesModified": "File modificati", + "filesRead": "File letti", + "commandsExecuted": "Comandi eseguiti", + "todoStats": "Attività: {{completed}}/{{total}} completate", + "permissionBoundaries": "Limiti dei permessi", + "permissionFilePatterns": "File consentiti: {{patterns}}", + "permissionCommandPatterns": "Comandi consentiti: {{patterns}}", + "permissionAllowedTools": "Strumenti consentiti: {{tools}}", + "permissionDeniedTools": "Strumenti negati: {{tools}}" }, "questions": { "hasQuestion": "Roo ha una domanda" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Apri impostazioni", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Riepilogo del passaggio di contesto", + "mode": "Modalità", + "filesModified": "File modificati", + "filesRead": "File letti", + "commandsExecuted": "Comandi eseguiti", + "apiRequests": "Richieste API" } } diff --git a/webview-ui/src/i18n/locales/it/history.json b/webview-ui/src/i18n/locales/it/history.json index aa728ef8f60..454033ea57f 100644 --- a/webview-ui/src/i18n/locales/it/history.json +++ b/webview-ui/src/i18n/locales/it/history.json @@ -47,5 +47,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index e6132d3595d..f0bc6b1a405 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -259,7 +259,16 @@ "resultContent": "サブタスク結果", "defaultResult": "次のタスクに進んでください。", "completionInstructions": "サブタスク完了!結果を確認し、修正や次のステップを提案できます。問題なければ、親タスクに結果を返すために確認してください。", - "goToSubtask": "タスクを表示" + "goToSubtask": "タスクを表示", + "filesModified": "変更されたファイル", + "filesRead": "読み取ったファイル", + "commandsExecuted": "実行されたコマンド", + "todoStats": "タスク: {{completed}}/{{total}} 完了", + "permissionBoundaries": "権限の境界", + "permissionFilePatterns": "許可されたファイル: {{patterns}}", + "permissionCommandPatterns": "許可されたコマンド: {{patterns}}", + "permissionAllowedTools": "許可されたツール: {{tools}}", + "permissionDeniedTools": "拒否されたツール: {{tools}}" }, "questions": { "hasQuestion": "Rooは質問があります" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "設定を開く", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "コンテキスト引き継ぎサマリー", + "mode": "モード", + "filesModified": "変更されたファイル", + "filesRead": "読み取られたファイル", + "commandsExecuted": "実行されたコマンド", + "apiRequests": "APIリクエスト" } } diff --git a/webview-ui/src/i18n/locales/ja/history.json b/webview-ui/src/i18n/locales/ja/history.json index b73baa3a763..e16efbb26e0 100644 --- a/webview-ui/src/i18n/locales/ja/history.json +++ b/webview-ui/src/i18n/locales/ja/history.json @@ -47,5 +47,15 @@ "subtaskTag": "サブタスク", "deleteWithSubtasks": "これにより {{count}} サブタスクも削除されます。よろしいですか?", "expandSubtasks": "サブタスクを展開", - "collapseSubtasks": "サブタスクを折りたたむ" + "collapseSubtasks": "サブタスクを折りたたむ", + "backgroundTag": "バックグラウンド", + "interruptedTag": "中断", + "showBackgroundTasks": "バックグラウンドタスクを表示", + "hideBackgroundTasks": "バックグラウンドタスクを非表示", + "filter": { + "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/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 397a0f3c408..69d77458cff 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -259,7 +259,16 @@ "resultContent": "하위 작업 결과", "defaultResult": "다음 작업을 계속 진행해주세요.", "completionInstructions": "하위 작업 완료! 결과를 검토하고 수정 사항이나 다음 단계를 제안할 수 있습니다. 모든 것이 괜찮아 보이면, 부모 작업에 결과를 반환하기 위해 확인해주세요.", - "goToSubtask": "작업 보기" + "goToSubtask": "작업 보기", + "filesModified": "수정된 파일", + "filesRead": "읽은 파일", + "commandsExecuted": "실행된 명령어", + "todoStats": "할 일: {{completed}}/{{total}} 완료", + "permissionBoundaries": "권한 경계", + "permissionFilePatterns": "허용된 파일: {{patterns}}", + "permissionCommandPatterns": "허용된 명령: {{patterns}}", + "permissionAllowedTools": "허용된 도구: {{tools}}", + "permissionDeniedTools": "거부된 도구: {{tools}}" }, "questions": { "hasQuestion": "Roo에게 질문이 있습니다" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "설정 열기", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "컨텍스트 핸드오프 요약", + "mode": "모드", + "filesModified": "수정된 파일", + "filesRead": "읽은 파일", + "commandsExecuted": "실행된 명령", + "apiRequests": "API 요청" } } diff --git a/webview-ui/src/i18n/locales/ko/history.json b/webview-ui/src/i18n/locales/ko/history.json index 0363feaaffb..76ee71d839d 100644 --- a/webview-ui/src/i18n/locales/ko/history.json +++ b/webview-ui/src/i18n/locales/ko/history.json @@ -47,5 +47,15 @@ "subtaskTag": "부분작업", "deleteWithSubtasks": "이는 {{count}} 부분작업도 삭제합니다. 확실하십니까?", "expandSubtasks": "부분작업 확장", - "collapseSubtasks": "부분작업 축소" + "collapseSubtasks": "부분작업 축소", + "backgroundTag": "백그라운드", + "interruptedTag": "중단됨", + "showBackgroundTasks": "백그라운드 작업 표시", + "hideBackgroundTasks": "백그라운드 작업 숨기기", + "filter": { + "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/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 19aad43c466..26d0dc1f898 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -254,7 +254,16 @@ "resultContent": "Subtaakresultaten", "defaultResult": "Ga verder met de volgende taak.", "completionInstructions": "Subtaak voltooid! Je kunt de resultaten bekijken en eventuele correcties of volgende stappen voorstellen. Als alles goed is, bevestig dan om het resultaat terug te sturen naar de hoofdtaak.", - "goToSubtask": "Taak weergeven" + "goToSubtask": "Taak weergeven", + "filesModified": "Gewijzigde bestanden", + "filesRead": "Gelezen bestanden", + "commandsExecuted": "Uitgevoerde opdrachten", + "todoStats": "Taken: {{completed}}/{{total}} voltooid", + "permissionBoundaries": "Machtigingsgrenzen", + "permissionFilePatterns": "Toegestane bestanden: {{patterns}}", + "permissionCommandPatterns": "Toegestane opdrachten: {{patterns}}", + "permissionAllowedTools": "Toegestane tools: {{tools}}", + "permissionDeniedTools": "Geweigerde tools: {{tools}}" }, "questions": { "hasQuestion": "Roo heeft een vraag" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Instellingen openen", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Samenvatting contextoverdracht", + "mode": "Modus", + "filesModified": "Gewijzigde bestanden", + "filesRead": "Gelezen bestanden", + "commandsExecuted": "Uitgevoerde opdrachten", + "apiRequests": "API-verzoeken" } } diff --git a/webview-ui/src/i18n/locales/nl/history.json b/webview-ui/src/i18n/locales/nl/history.json index 012059ed047..216de93a520 100644 --- a/webview-ui/src/i18n/locales/nl/history.json +++ b/webview-ui/src/i18n/locales/nl/history.json @@ -47,5 +47,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index f83f44f76d0..4168a56856e 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -259,7 +259,16 @@ "resultContent": "Wyniki podzadania", "defaultResult": "Proszę kontynuować następne zadanie.", "completionInstructions": "Podzadanie zakończone! Możesz przejrzeć wyniki i zasugerować poprawki lub następne kroki. Jeśli wszystko wygląda dobrze, potwierdź, aby zwrócić wynik do zadania nadrzędnego.", - "goToSubtask": "Wyświetl zadanie" + "goToSubtask": "Wyświetl zadanie", + "filesModified": "Zmodyfikowane pliki", + "filesRead": "Odczytane pliki", + "commandsExecuted": "Wykonane polecenia", + "todoStats": "Zadania: {{completed}}/{{total}} ukończonych", + "permissionBoundaries": "Granice uprawnień", + "permissionFilePatterns": "Dozwolone pliki: {{patterns}}", + "permissionCommandPatterns": "Dozwolone polecenia: {{patterns}}", + "permissionAllowedTools": "Dozwolone narzędzia: {{tools}}", + "permissionDeniedTools": "Odmówione narzędzia: {{tools}}" }, "questions": { "hasQuestion": "Roo ma pytanie" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Otwórz ustawienia", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Podsumowanie przekazania kontekstu", + "mode": "Tryb", + "filesModified": "Zmodyfikowane pliki", + "filesRead": "Odczytane pliki", + "commandsExecuted": "Wykonane polecenia", + "apiRequests": "Żądania API" } } diff --git a/webview-ui/src/i18n/locales/pl/history.json b/webview-ui/src/i18n/locales/pl/history.json index 7ec4b40d8f5..0932d0b216f 100644 --- a/webview-ui/src/i18n/locales/pl/history.json +++ b/webview-ui/src/i18n/locales/pl/history.json @@ -47,5 +47,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 1e9c970a2bf..c8db1fe9599 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -259,7 +259,16 @@ "resultContent": "Resultados da subtarefa", "defaultResult": "Por favor, continue com a próxima tarefa.", "completionInstructions": "Subtarefa concluída! Você pode revisar os resultados e sugerir correções ou próximos passos. Se tudo parecer bom, confirme para retornar o resultado à tarefa principal.", - "goToSubtask": "Ver tarefa" + "goToSubtask": "Ver tarefa", + "filesModified": "Arquivos modificados", + "filesRead": "Arquivos lidos", + "commandsExecuted": "Comandos executados", + "todoStats": "Tarefas: {{completed}}/{{total}} concluídas", + "permissionBoundaries": "Limites de permissão", + "permissionFilePatterns": "Arquivos permitidos: {{patterns}}", + "permissionCommandPatterns": "Comandos permitidos: {{patterns}}", + "permissionAllowedTools": "Ferramentas permitidas: {{tools}}", + "permissionDeniedTools": "Ferramentas negadas: {{tools}}" }, "questions": { "hasQuestion": "Roo tem uma pergunta" @@ -487,5 +496,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Abrir configurações", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Resumo da transferência de contexto", + "mode": "Modo", + "filesModified": "Arquivos modificados", + "filesRead": "Arquivos lidos", + "commandsExecuted": "Comandos executados", + "apiRequests": "Requisições de API" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/history.json b/webview-ui/src/i18n/locales/pt-BR/history.json index 7966df1f463..ad5b785b03e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/history.json +++ b/webview-ui/src/i18n/locales/pt-BR/history.json @@ -47,5 +47,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 3bfdd00812f..cfbb9d94bca 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -255,7 +255,16 @@ "resultContent": "Результаты подзадачи", "defaultResult": "Пожалуйста, переходите к следующей задаче.", "completionInstructions": "Подзадача завершена! Вы можете просмотреть результаты и предложить исправления или следующие шаги. Если всё в порядке, подтвердите для возврата результата в родительскую задачу.", - "goToSubtask": "Просмотреть задачу" + "goToSubtask": "Просмотреть задачу", + "filesModified": "Изменённые файлы", + "filesRead": "Прочитанные файлы", + "commandsExecuted": "Выполненные команды", + "todoStats": "Задачи: {{completed}}/{{total}} выполнено", + "permissionBoundaries": "Границы разрешений", + "permissionFilePatterns": "Разрешённые файлы: {{patterns}}", + "permissionCommandPatterns": "Разрешённые команды: {{patterns}}", + "permissionAllowedTools": "Разрешённые инструменты: {{tools}}", + "permissionDeniedTools": "Запрещённые инструменты: {{tools}}" }, "questions": { "hasQuestion": "У Roo есть вопрос" @@ -488,5 +497,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Открыть настройки", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Сводка передачи контекста", + "mode": "Режим", + "filesModified": "Изменённые файлы", + "filesRead": "Прочитанные файлы", + "commandsExecuted": "Выполненные команды", + "apiRequests": "API-запросы" } } diff --git a/webview-ui/src/i18n/locales/ru/history.json b/webview-ui/src/i18n/locales/ru/history.json index 7852362348b..70c332f34fe 100644 --- a/webview-ui/src/i18n/locales/ru/history.json +++ b/webview-ui/src/i18n/locales/ru/history.json @@ -47,5 +47,15 @@ "subtaskTag": "Подзадача", "deleteWithSubtasks": "Это также удалит {{count}} подзадачу(и). Вы уверены?", "expandSubtasks": "Развернуть подзадачи", - "collapseSubtasks": "Свернуть подзадачи" + "collapseSubtasks": "Свернуть подзадачи", + "backgroundTag": "Фоновая", + "interruptedTag": "Прервана", + "showBackgroundTasks": "Показать фоновые задачи", + "hideBackgroundTasks": "Скрыть фоновые задачи", + "filter": { + "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/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 85ef61b53ba..b2c9bea796a 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -260,7 +260,16 @@ "resultContent": "Alt Görev Sonuçları", "defaultResult": "Lütfen sonraki göreve devam edin.", "completionInstructions": "Alt görev tamamlandı! Sonuçları inceleyebilir ve düzeltmeler veya sonraki adımlar önerebilirsiniz. Her şey iyi görünüyorsa, sonucu üst göreve döndürmek için onaylayın.", - "goToSubtask": "Görevi görüntüle" + "goToSubtask": "Görevi görüntüle", + "filesModified": "Değiştirilen Dosyalar", + "filesRead": "Okunan Dosyalar", + "commandsExecuted": "Çalıştırılan Komutlar", + "todoStats": "Görevler: {{completed}}/{{total}} tamamlandı", + "permissionBoundaries": "İzin Sınırları", + "permissionFilePatterns": "İzin verilen dosyalar: {{patterns}}", + "permissionCommandPatterns": "İzin verilen komutlar: {{patterns}}", + "permissionAllowedTools": "İzin verilen araçlar: {{tools}}", + "permissionDeniedTools": "Reddedilen araçlar: {{tools}}" }, "questions": { "hasQuestion": "Roo'nun bir sorusu var" @@ -488,5 +497,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Ayarları aç", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Bağlam Aktarımı Özeti", + "mode": "Mod", + "filesModified": "Değiştirilen Dosyalar", + "filesRead": "Okunan Dosyalar", + "commandsExecuted": "Yürütülen Komutlar", + "apiRequests": "API İstekleri" } } diff --git a/webview-ui/src/i18n/locales/tr/history.json b/webview-ui/src/i18n/locales/tr/history.json index fb7b6c68320..aeee83bbf59 100644 --- a/webview-ui/src/i18n/locales/tr/history.json +++ b/webview-ui/src/i18n/locales/tr/history.json @@ -47,5 +47,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 0dc0adf027b..1134e1b469b 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -260,7 +260,16 @@ "resultContent": "Kết quả nhiệm vụ phụ", "defaultResult": "Vui lòng tiếp tục với nhiệm vụ tiếp theo.", "completionInstructions": "Nhiệm vụ phụ đã hoàn thành! Bạn có thể xem lại kết quả và đề xuất các sửa đổi hoặc bước tiếp theo. Nếu mọi thứ có vẻ tốt, hãy xác nhận để trả kết quả về nhiệm vụ chính.", - "goToSubtask": "Xem nhiệm vụ" + "goToSubtask": "Xem nhiệm vụ", + "filesModified": "Tệp đã sửa đổi", + "filesRead": "Tệp đã đọc", + "commandsExecuted": "Lệnh đã thực thi", + "todoStats": "Việc cần làm: {{completed}}/{{total}} hoàn thành", + "permissionBoundaries": "Giới hạn quyền", + "permissionFilePatterns": "Tệp được phép: {{patterns}}", + "permissionCommandPatterns": "Lệnh được phép: {{patterns}}", + "permissionAllowedTools": "Công cụ được phép: {{tools}}", + "permissionDeniedTools": "Công cụ bị từ chối: {{tools}}" }, "questions": { "hasQuestion": "Roo có một câu hỏi" @@ -488,5 +497,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "Mở cài đặt", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "Tóm tắt chuyển giao ngữ cảnh", + "mode": "Chế độ", + "filesModified": "Tệp đã sửa đổi", + "filesRead": "Tệp đã đọc", + "commandsExecuted": "Lệnh đã thực thi", + "apiRequests": "Yêu cầu API" } } diff --git a/webview-ui/src/i18n/locales/vi/history.json b/webview-ui/src/i18n/locales/vi/history.json index 779953e5406..c7192e9ce83 100644 --- a/webview-ui/src/i18n/locales/vi/history.json +++ b/webview-ui/src/i18n/locales/vi/history.json @@ -47,5 +47,15 @@ "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" + }, + "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/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 16cc563a5bf..38dbfd65497 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -260,7 +260,16 @@ "resultContent": "子任务结果", "defaultResult": "请继续下一个任务。", "completionInstructions": "子任务已完成!您可以查看结果并提出修改或下一步建议。如果一切正常,请确认以将结果返回给主任务。", - "goToSubtask": "查看任务" + "goToSubtask": "查看任务", + "filesModified": "修改的文件", + "filesRead": "读取的文件", + "commandsExecuted": "执行的命令", + "todoStats": "待办事项:{{completed}}/{{total}} 已完成", + "permissionBoundaries": "权限边界", + "permissionFilePatterns": "允许的文件:{{patterns}}", + "permissionCommandPatterns": "允许的命令:{{patterns}}", + "permissionAllowedTools": "允许的工具:{{tools}}", + "permissionDeniedTools": "拒绝的工具:{{tools}}" }, "questions": { "hasQuestion": "Roo有一个问题" @@ -488,5 +497,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "打开设置", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "上下文交接摘要", + "mode": "模式", + "filesModified": "已修改的文件", + "filesRead": "已读取的文件", + "commandsExecuted": "已执行的命令", + "apiRequests": "API 请求" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/history.json b/webview-ui/src/i18n/locales/zh-CN/history.json index 20a73240ea9..dd7b9b79b70 100644 --- a/webview-ui/src/i18n/locales/zh-CN/history.json +++ b/webview-ui/src/i18n/locales/zh-CN/history.json @@ -47,5 +47,15 @@ "subtaskTag": "子任务", "deleteWithSubtasks": "这也将删除 {{count}} 个子任务。您确定吗?", "expandSubtasks": "展开子任务", - "collapseSubtasks": "收起子任务" + "collapseSubtasks": "收起子任务", + "backgroundTag": "后台", + "interruptedTag": "已中断", + "showBackgroundTasks": "显示后台任务", + "hideBackgroundTasks": "隐藏后台任务", + "filter": { + "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/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 0b3230be8a4..28d8c6417b8 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -304,7 +304,16 @@ "resultContent": "子任務結果", "defaultResult": "請繼續下一個工作。", "completionInstructions": "子任務已完成!您可以檢閱結果並提出修正或後續步驟。如果一切順利,請確認以將結果回傳給主任務。", - "goToSubtask": "查看工作" + "goToSubtask": "查看工作", + "filesModified": "修改的檔案", + "filesRead": "讀取的檔案", + "commandsExecuted": "執行的指令", + "todoStats": "待辦事項:{{completed}}/{{total}} 已完成", + "permissionBoundaries": "權限邊界", + "permissionFilePatterns": "允許的檔案:{{patterns}}", + "permissionCommandPatterns": "允許的命令:{{patterns}}", + "permissionAllowedTools": "允許的工具:{{tools}}", + "permissionDeniedTools": "拒絕的工具:{{tools}}" }, "questions": { "hasQuestion": "Roo 有一個問題" @@ -483,5 +492,13 @@ "message": "This provider is no longer supported. Please select a different provider in your API profile settings.", "openSettings": "開啟設定", "rooMessage": "As part of our decision to sunset the Roo Code extension, we also ended the Roo Code Router, which only existed to support the extension. Sorry about the hassle." + }, + "contextHandoff": { + "title": "上下文交接摘要", + "mode": "模式", + "filesModified": "已修改的檔案", + "filesRead": "已讀取的檔案", + "commandsExecuted": "已執行的命令", + "apiRequests": "API 請求" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/history.json b/webview-ui/src/i18n/locales/zh-TW/history.json index 1e12190a69b..367e5e04ea6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/history.json +++ b/webview-ui/src/i18n/locales/zh-TW/history.json @@ -47,5 +47,15 @@ "subtaskTag": "子工作", "deleteWithSubtasks": "這也將刪除 {{count}} 個子工作。您確定嗎?", "expandSubtasks": "展開子工作", - "collapseSubtasks": "收起子工作" + "collapseSubtasks": "收起子工作", + "backgroundTag": "背景", + "interruptedTag": "已中斷", + "showBackgroundTasks": "顯示背景工作", + "hideBackgroundTasks": "隱藏背景工作", + "filter": { + "prefix": "篩選:", + "all": "所有工作", + "foregroundOnly": "僅前景" + }, + "interruptedTooltip": "This background task was interrupted because VS Code was closed while it was still running." }