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 302fbe3454c..5a7bfdfa243 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 */ @@ -26,6 +51,12 @@ export const historyItemSchema = z.object({ 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 diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f3723bc68a1..1eec32bbeae 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 * 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 f981ba7bf9a..5ac6aca071f 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..d0fa680f8fc 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 @@ -94,6 +96,15 @@ export interface CreateTaskOptions { /** 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 d8d2bab0f82..52f07493c1a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -795,6 +795,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/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/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..dc0c98c7d11 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -589,6 +589,7 @@ export async function presentAssistantMessage(cline: Task) { block.params, stateExperiments, includedTools, + cline.taskPermissions, ) } catch (error) { cline.consecutiveMistakeCount++ 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..97a7eb1da25 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,9 @@ 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"]}` + export default { type: "function", function: { @@ -31,6 +36,14 @@ 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, + }, }, required: ["mode", "message", "todos"], additionalProperties: false, diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 4b771269713..83dee5fb478 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" @@ -25,6 +25,8 @@ export type TaskMetadataOptions = { apiConfigName?: string /** Initial status for the task (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" + /** Permission boundaries for the task, set by the parent via new_task tool */ + taskPermissions?: TaskPermissionsInput } export async function taskMetadata({ @@ -38,6 +40,7 @@ export async function taskMetadata({ mode, apiConfigName, initialStatus, + taskPermissions, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -112,6 +115,7 @@ export async function taskMetadata({ mode, ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), + ...(taskPermissions && { taskPermissions }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 97f07fcc7aa..a4d836af8b8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -39,6 +39,9 @@ import { TaskStatus, TodoItem, getApiProtocol, + type TaskPermissions, + mergeTaskPermissions, + toTaskPermissions, getModelId, isRetiredProvider, isIdleAsk, @@ -51,6 +54,7 @@ import { MIN_CHECKPOINT_TIMEOUT_SECONDS, MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, + type TaskContext, } from "@roo-code/types" // api @@ -153,12 +157,22 @@ export interface TaskOptions extends CreateTaskOptions { workspacePath?: string /** Initial status for the task's history item (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" + /** + * 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 } 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 +186,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. @@ -430,6 +453,8 @@ export class Task extends EventEmitter implements TaskLike { initialTodos, workspacePath, initialStatus, + taskContext, + taskPermissions, }: TaskOptions) { super() @@ -456,6 +481,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 +524,7 @@ export class Task extends EventEmitter implements TaskLike { this.parentTask = parentTask this.taskNumber = taskNumber this.initialStatus = initialStatus + this.taskContext = taskContext this.assistantMessageParser = undefined @@ -544,6 +578,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 +1217,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 +1241,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 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/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e379..af0bfe5b680 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,6 +1,11 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" +<<<<<<< HEAD +import type { SubtaskQueueItem } from "@roo-code/types" +======= +>>>>>>> 6c51a5d52 (fix: three bugs in task permissions - parser, deniedTools exemption, pattern merging) +import { type TaskPermissions, taskPermissionsSchema, toTaskPermissions } from "@roo-code/types" import { Task } from "../task/Task" import { getModeBySlug } from "../../shared/modes" @@ -15,13 +20,15 @@ interface NewTaskParams { mode: string message: string todos?: string + task_queue?: string + permissions?: string } export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos } = params + const { mode, message, todos, task_queue, permissions: permissionsJson } = params const { askApproval, handleError, pushToolResult } = callbacks try { @@ -82,6 +89,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 +130,48 @@ 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 } : {}), }) const didApprove = await askApproval("tool", toolMessage) @@ -115,10 +186,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 +207,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 c1b086a6a1a..68cdbe8ddd0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -83,9 +83,11 @@ 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 { 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" @@ -3017,8 +3019,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 @@ -3094,24 +3099,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])) @@ -3121,6 +3138,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) { @@ -3131,10 +3151,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 { @@ -3152,12 +3172,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({ @@ -3178,6 +3214,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() @@ -3203,7 +3275,8 @@ export class ClineProvider const subtaskUiMessage: ClineMessage = { type: "say", say: "subtask_result", - text: completionResultSummary, + text: effectiveSummary, + text: contextSummary ? JSON.stringify(contextSummary) : completionResultSummary, ts, } parentClineMessages.push(subtaskUiMessage) @@ -3237,6 +3310,8 @@ export class ClineProvider 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\n${formattedSummary}` alreadyHasToolResult = true break } @@ -3252,6 +3327,7 @@ export class ClineProvider type: "tool_result" as const, tool_use_id: toolUseId, content: apiResultText, + content: `Subtask ${childTaskId} completed.\n\n${formattedSummary}`, }, ], ts, @@ -3316,7 +3392,9 @@ export class ClineProvider ...historyItem, status: "active", completedByChildId: childTaskId, + completionResultSummary: effectiveSummary, completionResultSummary, + contextHandoffSummary: contextSummary, awaitingChildId: undefined, childIds, } @@ -3324,7 +3402,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 } @@ -3358,6 +3436,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/shared/tools.ts b/src/shared/tools.ts index 4cac8335ea7..c7569e00d2c 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -56,6 +56,8 @@ export const toolParamNames = [ "start_line", "end_line", "todos", + "task_queue", + "permissions", // new_task parameter for subtask permission boundaries "prompt", "image", // read_file parameters (native protocol) @@ -102,7 +104,7 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string } + new_task: { mode: string; message: string; todos?: string; task_queue?: string; permissions?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -240,7 +242,8 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" - params: Partial, "mode" | "message" | "todos">> + params: Partial, "mode" | "message" | "todos" | "task_queue">> + params: Partial, "mode" | "message" | "todos" | "permissions">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ec02fda04cd..11cb3c9cdda 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 && (