diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index 446582a5d7..d2e9359946 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -29,6 +29,7 @@ "@roo-code/evals": "workspace:^", "@roo-code/types": "workspace:^", "@tanstack/react-query": "^5.69.0", + "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.0", @@ -52,6 +53,7 @@ "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@tailwindcss/postcss": "^4", + "@types/archiver": "^7.0.0", "@types/ps-tree": "^1.1.6", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.5", diff --git a/apps/web-evals/src/actions/runs.ts b/apps/web-evals/src/actions/runs.ts index 82a7ebfcbe..e07bf34211 100644 --- a/apps/web-evals/src/actions/runs.ts +++ b/apps/web-evals/src/actions/runs.ts @@ -21,7 +21,7 @@ import { CreateRun } from "@/lib/schemas" const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals") -export async function createRun({ suite, exercises = [], timeout, ...values }: CreateRun) { +export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) { const run = await _createRun({ ...values, timeout, @@ -36,15 +36,34 @@ export async function createRun({ suite, exercises = [], timeout, ...values }: C throw new Error("Invalid exercise path: " + path) } - await createTask({ ...values, runId: run.id, language: language as ExerciseLanguage, exercise }) + // Create multiple tasks for each iteration + for (let iteration = 1; iteration <= iterations; iteration++) { + await createTask({ + ...values, + runId: run.id, + language: language as ExerciseLanguage, + exercise, + iteration, + }) + } } } else { for (const language of exerciseLanguages) { - const exercises = await getExercisesForLanguage(EVALS_REPO_PATH, language) + const languageExercises = await getExercisesForLanguage(EVALS_REPO_PATH, language) + + // Create tasks for all iterations of each exercise + const tasksToCreate: Array<{ language: ExerciseLanguage; exercise: string; iteration: number }> = [] + for (const exercise of languageExercises) { + for (let iteration = 1; iteration <= iterations; iteration++) { + tasksToCreate.push({ language, exercise, iteration }) + } + } - await pMap(exercises, (exercise) => createTask({ runId: run.id, language, exercise }), { - concurrency: 10, - }) + await pMap( + tasksToCreate, + ({ language, exercise, iteration }) => createTask({ runId: run.id, language, exercise, iteration }), + { concurrency: 10 }, + ) } } diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts new file mode 100644 index 0000000000..e5ec8751ab --- /dev/null +++ b/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import * as fs from "node:fs/promises" +import * as path from "node:path" + +import { findTask, findRun } from "@roo-code/evals" + +export const dynamic = "force-dynamic" + +const LOG_BASE_PATH = "/tmp/evals/runs" + +// Sanitize path components to prevent path traversal attacks +function sanitizePathComponent(component: string): string { + // Remove any path separators, null bytes, and other dangerous characters + return component.replace(/[/\\:\0*?"<>|]/g, "_") +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) { + const { id, taskId } = await params + + try { + const runId = Number(id) + const taskIdNum = Number(taskId) + + if (isNaN(runId) || isNaN(taskIdNum)) { + return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 }) + } + + // Verify the run exists + await findRun(runId) + + // Get the task to find its language and exercise + const task = await findTask(taskIdNum) + + // Verify the task belongs to this run + if (task.runId !== runId) { + return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 }) + } + + // Sanitize language and exercise to prevent path traversal + const safeLanguage = sanitizePathComponent(task.language) + const safeExercise = sanitizePathComponent(task.exercise) + + // Construct the log file path + const logFileName = `${safeLanguage}-${safeExercise}.log` + const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName) + + // Verify the resolved path is within the expected directory (defense in depth) + const resolvedPath = path.resolve(logFilePath) + const expectedBase = path.resolve(LOG_BASE_PATH) + if (!resolvedPath.startsWith(expectedBase)) { + return NextResponse.json({ error: "Invalid log path" }, { status: 400 }) + } + + // Check if the log file exists and read it (async) + try { + const logContent = await fs.readFile(logFilePath, "utf-8") + return NextResponse.json({ logContent }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 }) + } + throw err + } + } catch (error) { + console.error("Error reading task log:", error) + + if (error instanceof Error && error.name === "RecordNotFoundError") { + return NextResponse.json({ error: "Task or run not found" }, { status: 404 }) + } + + return NextResponse.json({ error: "Failed to read log file" }, { status: 500 }) + } +} diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts new file mode 100644 index 0000000000..f8c6cec06b --- /dev/null +++ b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import * as fs from "node:fs" +import * as path from "node:path" +import archiver from "archiver" + +import { findRun, getTasks } from "@roo-code/evals" + +export const dynamic = "force-dynamic" + +const LOG_BASE_PATH = "/tmp/evals/runs" + +// Sanitize path components to prevent path traversal attacks +function sanitizePathComponent(component: string): string { + // Remove any path separators, null bytes, and other dangerous characters + return component.replace(/[/\\:\0*?"<>|]/g, "_") +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + try { + const runId = Number(id) + + if (isNaN(runId)) { + return NextResponse.json({ error: "Invalid run ID" }, { status: 400 }) + } + + // Verify the run exists + await findRun(runId) + + // Get all tasks for this run + const tasks = await getTasks(runId) + + // Filter for failed tasks only + const failedTasks = tasks.filter((task) => task.passed === false) + + if (failedTasks.length === 0) { + return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 }) + } + + // Create a zip archive + const archive = archiver("zip", { zlib: { level: 9 } }) + + // Collect chunks to build the response + const chunks: Buffer[] = [] + + archive.on("data", (chunk: Buffer) => { + chunks.push(chunk) + }) + + // Track archive errors + let archiveError: Error | null = null + archive.on("error", (err: Error) => { + archiveError = err + }) + + // Set up the end promise before finalizing (proper event listener ordering) + const archiveEndPromise = new Promise((resolve, reject) => { + archive.on("end", resolve) + archive.on("error", reject) + }) + + // Add each failed task's log file to the archive + const logDir = path.join(LOG_BASE_PATH, String(runId)) + let filesAdded = 0 + + for (const task of failedTasks) { + // Sanitize language and exercise to prevent path traversal + const safeLanguage = sanitizePathComponent(task.language) + const safeExercise = sanitizePathComponent(task.exercise) + const logFileName = `${safeLanguage}-${safeExercise}.log` + const logFilePath = path.join(logDir, logFileName) + + // Verify the resolved path is within the expected directory (defense in depth) + const resolvedPath = path.resolve(logFilePath) + const expectedBase = path.resolve(LOG_BASE_PATH) + if (!resolvedPath.startsWith(expectedBase)) { + continue // Skip files with suspicious paths + } + + if (fs.existsSync(logFilePath)) { + archive.file(logFilePath, { name: logFileName }) + filesAdded++ + } + } + + // Check if any files were actually added + if (filesAdded === 0) { + archive.abort() + return NextResponse.json( + { error: "No log files found - they may have been cleared from disk" }, + { status: 404 }, + ) + } + + // Finalize the archive + await archive.finalize() + + // Wait for all data to be collected + await archiveEndPromise + + // Check for archive errors + if (archiveError) { + throw archiveError + } + + // Combine all chunks into a single buffer + const zipBuffer = Buffer.concat(chunks) + + // Return the zip file + return new NextResponse(zipBuffer, { + status: 200, + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`, + "Content-Length": String(zipBuffer.length), + }, + }) + } catch (error) { + console.error("Error exporting failed logs:", error) + + if (error instanceof Error && error.name === "RecordNotFoundError") { + return NextResponse.json({ error: "Run not found" }, { status: 404 }) + } + + return NextResponse.json({ error: "Failed to export logs" }, { status: 500 }) + } +} diff --git a/apps/web-evals/src/app/runs/[id]/run.tsx b/apps/web-evals/src/app/runs/[id]/run.tsx index a8ff1484fe..bd52888479 100644 --- a/apps/web-evals/src/app/runs/[id]/run.tsx +++ b/apps/web-evals/src/app/runs/[id]/run.tsx @@ -1,9 +1,10 @@ "use client" -import { useMemo } from "react" -import { LoaderCircle } from "lucide-react" +import { useMemo, useState, useCallback, useEffect } from "react" +import { toast } from "sonner" +import { LoaderCircle, FileText, Copy, Check } from "lucide-react" -import type { Run, TaskMetrics as _TaskMetrics } from "@roo-code/evals" +import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals" import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters" import { useRunStatus } from "@/hooks/use-run-status" @@ -17,6 +18,12 @@ import { Tooltip, TooltipContent, TooltipTrigger, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + ScrollArea, + Button, } from "@/components/ui" import { TaskStatus } from "./task-status" @@ -35,10 +42,169 @@ function getToolAbbreviation(toolName: string): string { .join("") } +// Pattern definitions for syntax highlighting +type HighlightPattern = { + pattern: RegExp + className: string + // If true, wraps the entire match; if a number, wraps that capture group + wrapGroup?: number +} + +const HIGHLIGHT_PATTERNS: HighlightPattern[] = [ + // Timestamps [YYYY-MM-DDTHH:MM:SS.sssZ] + { pattern: /\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\]/g, className: "text-blue-400" }, + // Log levels + { pattern: /\|\s*(INFO)\s*\|/g, className: "text-green-400", wrapGroup: 1 }, + { pattern: /\|\s*(WARN|WARNING)\s*\|/g, className: "text-yellow-400", wrapGroup: 1 }, + { pattern: /\|\s*(ERROR)\s*\|/g, className: "text-red-400", wrapGroup: 1 }, + { pattern: /\|\s*(DEBUG)\s*\|/g, className: "text-gray-400", wrapGroup: 1 }, + // Task identifiers + { pattern: /(taskCreated|taskFocused|taskStarted|taskCompleted|EvalPass|EvalFail)/g, className: "text-purple-400" }, + // Message arrows + { pattern: /→/g, className: "text-cyan-400" }, +] + +// Format a single line with syntax highlighting using React elements (XSS-safe) +function formatLine(line: string): React.ReactNode[] { + // Find all matches with their positions + type Match = { start: number; end: number; text: string; className: string } + const matches: Match[] = [] + + for (const { pattern, className, wrapGroup } of HIGHLIGHT_PATTERNS) { + // Reset regex state + pattern.lastIndex = 0 + let regexMatch + while ((regexMatch = pattern.exec(line)) !== null) { + const capturedText = wrapGroup !== undefined ? regexMatch[wrapGroup] : regexMatch[0] + // Skip if capture group didn't match + if (!capturedText) continue + const start = + wrapGroup !== undefined ? regexMatch.index + regexMatch[0].indexOf(capturedText) : regexMatch.index + matches.push({ + start, + end: start + capturedText.length, + text: capturedText, + className, + }) + } + } + + // Sort matches by position and filter overlapping ones + matches.sort((a, b) => a.start - b.start) + const filteredMatches: Match[] = [] + for (const m of matches) { + const lastMatch = filteredMatches[filteredMatches.length - 1] + if (!lastMatch || m.start >= lastMatch.end) { + filteredMatches.push(m) + } + } + + // Build result with highlighted spans + const result: React.ReactNode[] = [] + let currentPos = 0 + + for (const [i, m] of filteredMatches.entries()) { + // Add text before this match + if (m.start > currentPos) { + result.push(line.slice(currentPos, m.start)) + } + // Add highlighted match + result.push( + + {m.text} + , + ) + currentPos = m.end + } + + // Add remaining text + if (currentPos < line.length) { + result.push(line.slice(currentPos)) + } + + return result.length > 0 ? result : [line] +} + +// Format log content with basic highlighting (XSS-safe - no dangerouslySetInnerHTML) +function formatLogContent(log: string): React.ReactNode[] { + const lines = log.split("\n") + return lines.map((line, index) => ( +
+ {line ? formatLine(line) : " "} +
+ )) +} + export function Run({ run }: { run: Run }) { const runStatus = useRunStatus(run) const { tasks, tokenUsage, usageUpdatedAt } = runStatus + const [selectedTask, setSelectedTask] = useState(null) + const [taskLog, setTaskLog] = useState(null) + const [isLoadingLog, setIsLoadingLog] = useState(false) + const [copied, setCopied] = useState(false) + + const onCopyLog = useCallback(async () => { + if (!taskLog) return + + try { + await navigator.clipboard.writeText(taskLog) + setCopied(true) + toast.success("Log copied to clipboard") + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error("Failed to copy log:", error) + toast.error("Failed to copy log") + } + }, [taskLog]) + + // Handle ESC key to close the dialog + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && selectedTask) { + setSelectedTask(null) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [selectedTask]) + + const onViewTaskLog = useCallback( + async (task: Task) => { + // Only allow viewing logs for completed tasks + if (task.passed === null || task.passed === undefined) { + toast.error("Task is still running") + return + } + + setSelectedTask(task) + setIsLoadingLog(true) + setTaskLog(null) + + try { + const response = await fetch(`/api/runs/${run.id}/logs/${task.id}`) + + if (!response.ok) { + const error = await response.json() + toast.error(error.error || "Failed to load log") + setSelectedTask(null) + return + } + + const data = await response.json() + setTaskLog(data.logContent) + } catch (error) { + console.error("Error loading task log:", error) + toast.error("Failed to load log") + setSelectedTask(null) + } finally { + setIsLoadingLog(false) + } + }, + [run.id], + ) + const taskMetrics: Record = useMemo(() => { const metrics: Record = {} @@ -241,15 +407,33 @@ export function Run({ run }: { run: Run }) { {tasks.map((task) => ( - + task.finishedAt && onViewTaskLog(task)}>
-
- {task.language}/{task.exercise} +
+ + {task.language}/{task.exercise} + {task.iteration > 1 && ( + + (#{task.iteration}) + + )} + + {task.finishedAt && ( + + + + + Click to view log + + )}
@@ -282,6 +466,63 @@ export function Run({ run }: { run: Run }) { )}
+ + {/* Task Log Dialog - Full Screen */} + setSelectedTask(null)}> + + +
+ + + {selectedTask?.language}/{selectedTask?.exercise} + {selectedTask?.iteration && selectedTask.iteration > 1 && ( + (#{selectedTask.iteration}) + )} + + ({selectedTask?.passed ? "Passed" : "Failed"}) + + + {taskLog && ( + + )} +
+
+
+ {isLoadingLog ? ( +
+ +
+ ) : taskLog ? ( + +
+ {formatLogContent(taskLog)} +
+
+ ) : ( +
+ Log file not available (may have been cleared) +
+ )} +
+
+
) } diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx index 3782f29a36..cb7dafd992 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal } from "lucide-react" +import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info } from "lucide-react" import { globalSettingsSchema, @@ -16,6 +16,7 @@ import { getModelId, type ProviderSettings, type GlobalSettings, + type ReasoningEffort, } from "@roo-code/types" import { createRun } from "@/actions/runs" @@ -30,6 +31,9 @@ import { TIMEOUT_MIN, TIMEOUT_MAX, TIMEOUT_DEFAULT, + ITERATIONS_MIN, + ITERATIONS_MAX, + ITERATIONS_DEFAULT, } from "@/lib/schemas" import { cn } from "@/lib/utils" @@ -40,6 +44,7 @@ import { Button, Checkbox, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -61,7 +66,14 @@ import { PopoverTrigger, Slider, Label, - FormDescription, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui" import { SettingsDiff } from "./settings-diff" @@ -78,6 +90,8 @@ export function NewRun() { const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("roo") const [modelPopoverOpen, setModelPopoverOpen] = useState(false) const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true) + const [useMultipleNativeToolCalls, setUseMultipleNativeToolCalls] = useState(true) + const [reasoningEffort, setReasoningEffort] = useState("") // State for imported settings with config selection const [importedSettings, setImportedSettings] = useState(null) @@ -106,6 +120,7 @@ export function NewRun() { settings: undefined, concurrency: CONCURRENCY_DEFAULT, timeout: TIMEOUT_DEFAULT, + iterations: ITERATIONS_DEFAULT, jobToken: "", }, }) @@ -204,12 +219,24 @@ export function NewRun() { const onSubmit = useCallback( async (values: CreateRun) => { try { + // Validate jobToken for Roo Code Cloud provider + if (provider === "roo" && !values.jobToken?.trim()) { + toast.error("Roo Code Cloud Token is required") + return + } + + // Build experiments settings + const experimentsSettings = useMultipleNativeToolCalls + ? { experiments: { multipleNativeToolCalls: true } } + : {} + if (provider === "openrouter") { values.settings = { ...(values.settings || {}), apiProvider: "openrouter", openRouterModelId: model, toolProtocol: useNativeToolProtocol ? "native" : "xml", + ...experimentsSettings, } } else if (provider === "roo") { values.settings = { @@ -217,6 +244,20 @@ export function NewRun() { apiProvider: "roo", apiModelId: model, toolProtocol: useNativeToolProtocol ? "native" : "xml", + ...experimentsSettings, + ...(reasoningEffort + ? { + enableReasoningEffort: true, + reasoningEffort: reasoningEffort as ReasoningEffort, + } + : {}), + } + } else if (provider === "other" && values.settings) { + // For imported settings, merge in experiments and tool protocol + values.settings = { + ...values.settings, + toolProtocol: useNativeToolProtocol ? "native" : "xml", + ...experimentsSettings, } } @@ -226,7 +267,7 @@ export function NewRun() { toast.error(e instanceof Error ? e.message : "An unknown error occurred.") } }, - [provider, model, router, useNativeToolProtocol], + [provider, model, router, useNativeToolProtocol, useMultipleNativeToolCalls, reasoningEffort], ) const onSelectModel = useCallback( @@ -394,6 +435,38 @@ export function NewRun() { )} +
+ +
+ + +
+
+ {settings && ( )} @@ -444,15 +517,66 @@ export function NewRun() { -
- - setUseNativeToolProtocol(checked === true) - } - /> - +
+
+ +
+ + +
+
+ + {provider === "roo" && ( +
+ + +

+ When set, enableReasoningEffort will be automatically enabled +

+
+ )}
)} @@ -468,20 +592,28 @@ export function NewRun() { name="jobToken" render={({ field }) => ( - Roo Code Cloud Token +
+ Roo Code Cloud Token + + + + + +

+ If you have access to the Roo Code Cloud repository, generate a + token with: +

+ + pnpm --filter @roo-code-cloud/auth production:create-job-token [org] + [timeout] + +
+
+
- + - - If you have access to the Roo Code Cloud repository then you can generate a - token with: -
- - pnpm --filter @roo-code-cloud/auth production:create-job-token [org] - [timeout] - -
)} /> @@ -600,6 +732,32 @@ export function NewRun() { )} /> + ( + + Iterations per Exercise + +
+ { + field.onChange(value[0]) + }} + /> +
{field.value}
+
+
+ Run each exercise multiple times to compare results + +
+ )} + /> + () const [showSettings, setShowSettings] = useState(false) + const [isExportingLogs, setIsExportingLogs] = useState(false) const continueRef = useRef(null) const { isPending, copyRun, copied } = useCopyRun(run.id) + const onExportFailedLogs = useCallback(async () => { + if (run.failed === 0) { + toast.error("No failed tasks to export") + return + } + + setIsExportingLogs(true) + try { + const response = await fetch(`/api/runs/${run.id}/logs/failed`) + + if (!response.ok) { + const error = await response.json() + toast.error(error.error || "Failed to export logs") + return + } + + // Download the zip file + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `run-${run.id}-failed-logs.zip` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + + toast.success("Failed logs exported successfully") + } catch (error) { + console.error("Error exporting logs:", error) + toast.error("Failed to export logs") + } finally { + setIsExportingLogs(false) + } + }, [run.id, run.failed]) + const onConfirmDelete = useCallback(async () => { if (!deleteRunId) { return @@ -161,6 +199,23 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
)} + {run.failed > 0 && ( + +
+ {isExportingLogs ? ( + <> + + Exporting... + + ) : ( + <> + + Export Failed Logs + + )} +
+
+ )} { setDeleteRunId(run.id) diff --git a/apps/web-evals/src/lib/schemas.ts b/apps/web-evals/src/lib/schemas.ts index 63c5fa7de5..478c328aa2 100644 --- a/apps/web-evals/src/lib/schemas.ts +++ b/apps/web-evals/src/lib/schemas.ts @@ -14,6 +14,10 @@ export const TIMEOUT_MIN = 5 export const TIMEOUT_MAX = 10 export const TIMEOUT_DEFAULT = 5 +export const ITERATIONS_MIN = 1 +export const ITERATIONS_MAX = 10 +export const ITERATIONS_DEFAULT = 1 + export const createRunSchema = z .object({ model: z.string().min(1, { message: "Model is required." }), @@ -23,6 +27,7 @@ export const createRunSchema = z settings: rooCodeSettingsSchema.optional(), concurrency: z.number().int().min(CONCURRENCY_MIN).max(CONCURRENCY_MAX), timeout: z.number().int().min(TIMEOUT_MIN).max(TIMEOUT_MAX), + iterations: z.number().int().min(ITERATIONS_MIN).max(ITERATIONS_MAX), jobToken: z.string().optional(), }) .refine((data) => data.suite === "full" || (data.exercises || []).length > 0, { diff --git a/apps/web-roo-code/src/lib/stats.ts b/apps/web-roo-code/src/lib/stats.ts index 07dfcf0af2..e479ce5010 100644 --- a/apps/web-roo-code/src/lib/stats.ts +++ b/apps/web-roo-code/src/lib/stats.ts @@ -104,13 +104,19 @@ export async function getVSCodeDownloads() { } function formatNumber(num: number): string { - // divide by 1000 to convert to "thousands" format, - // multiply by 10, floor the result, then divide by 10 to keep one decimal place. + // if number is 1 million or more, format as millions + if (num >= 1000000) { + const truncated = Math.floor((num / 1000000) * 10) / 10 + return truncated.toFixed(1) + "M" + } + + // otherwise, format as thousands const truncated = Math.floor((num / 1000) * 10) / 10 - // ensure one decimal is always shown and append "k" return truncated.toFixed(1) + "k" // examples: + // console.log(formatNumber(1033400)) -> "1.0M" + // console.log(formatNumber(2500000)) -> "2.5M" // console.log(formatNumber(337231)) -> "337.2k" // console.log(formatNumber(23233)) -> "23.2k" // console.log(formatNumber(2322)) -> "2.3k" diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 91371551df..26fce96228 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -188,18 +188,46 @@ export class ExtensionChannel extends BaseChannel< { from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused }, { from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused }, { from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned }, + { from: RooCodeEventName.TaskDelegated, to: ExtensionBridgeEventName.TaskDelegated }, + { from: RooCodeEventName.TaskDelegationCompleted, to: ExtensionBridgeEventName.TaskDelegationCompleted }, + { from: RooCodeEventName.TaskDelegationResumed, to: ExtensionBridgeEventName.TaskDelegationResumed }, { from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage }, { from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated }, ] as const eventMapping.forEach(({ from, to }) => { // Create and store the listener function for cleanup. - const listener = async (..._args: unknown[]) => { - this.publish(ExtensionSocketEvents.EVENT, { + const listener = async (...args: unknown[]) => { + const baseEvent: { + type: ExtensionBridgeEventName + instance: ExtensionInstance + timestamp: number + } = { type: to, instance: await this.updateInstance(), timestamp: Date.now(), - }) + } + + let eventToPublish: ExtensionBridgeEvent + + // Add payload for delegation events while avoiding `any` + if (to === ExtensionBridgeEventName.TaskDelegationCompleted) { + const [parentTaskId, childTaskId, summary] = args as [string, string, string] + eventToPublish = { + ...(baseEvent as unknown as ExtensionBridgeEvent), + payload: { parentTaskId, childTaskId, summary }, + } as unknown as ExtensionBridgeEvent + } else if (to === ExtensionBridgeEventName.TaskDelegationResumed) { + const [parentTaskId, childTaskId] = args as [string, string] + eventToPublish = { + ...(baseEvent as unknown as ExtensionBridgeEvent), + payload: { parentTaskId, childTaskId }, + } as unknown as ExtensionBridgeEvent + } else { + eventToPublish = baseEvent as unknown as ExtensionBridgeEvent + } + + this.publish(ExtensionSocketEvents.EVENT, eventToPublish) } this.eventListeners.set(from, listener) diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 3cccdc7844..188e2cc029 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -120,6 +120,10 @@ describe("ExtensionChannel", () => { RooCodeEventName.TaskPaused, RooCodeEventName.TaskUnpaused, RooCodeEventName.TaskSpawned, + RooCodeEventName.TaskDelegated, + RooCodeEventName.TaskDelegationCompleted, + RooCodeEventName.TaskDelegationResumed, + RooCodeEventName.TaskUserMessage, RooCodeEventName.TaskTokenUsageUpdated, ] @@ -246,6 +250,116 @@ describe("ExtensionChannel", () => { undefined, ) }) + + it("should forward delegation events to socket", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + const delegatedListeners = eventListeners.get(RooCodeEventName.TaskDelegated) + expect(delegatedListeners).toBeDefined() + expect(delegatedListeners!.size).toBe(1) + + const listener = Array.from(delegatedListeners!)[0] + if (listener) { + await (listener as any)("parent-id", "child-id") + } + + expect(mockSocket.emit).toHaveBeenCalledWith( + ExtensionSocketEvents.EVENT, + expect.objectContaining({ + type: ExtensionBridgeEventName.TaskDelegated, + instance: expect.any(Object), + timestamp: expect.any(Number), + }), + undefined, + ) + }) + + it("should forward TaskDelegationCompleted with correct payload", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + const completedListeners = eventListeners.get(RooCodeEventName.TaskDelegationCompleted) + expect(completedListeners).toBeDefined() + + const listener = Array.from(completedListeners!)[0] + if (listener) { + await (listener as any)("parent-1", "child-1", "Summary text") + } + + expect(mockSocket.emit).toHaveBeenCalledWith( + ExtensionSocketEvents.EVENT, + expect.objectContaining({ + type: ExtensionBridgeEventName.TaskDelegationCompleted, + instance: expect.any(Object), + timestamp: expect.any(Number), + payload: expect.objectContaining({ + parentTaskId: "parent-1", + childTaskId: "child-1", + summary: "Summary text", + }), + }), + undefined, + ) + }) + + it("should forward TaskDelegationResumed with correct payload", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + const resumedListeners = eventListeners.get(RooCodeEventName.TaskDelegationResumed) + expect(resumedListeners).toBeDefined() + + const listener = Array.from(resumedListeners!)[0] + if (listener) { + await (listener as any)("parent-2", "child-2") + } + + expect(mockSocket.emit).toHaveBeenCalledWith( + ExtensionSocketEvents.EVENT, + expect.objectContaining({ + type: ExtensionBridgeEventName.TaskDelegationResumed, + instance: expect.any(Object), + timestamp: expect.any(Number), + payload: expect.objectContaining({ + parentTaskId: "parent-2", + childTaskId: "child-2", + }), + }), + undefined, + ) + }) + + it("should propagate all three delegation events in order", async () => { + await extensionChannel.onConnect(mockSocket) + ;(mockSocket.emit as any).mockClear() + + // Trigger TaskDelegated + const delegatedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegated)!)[0] + await (delegatedListener as any)("p1", "c1") + + // Trigger TaskDelegationCompleted + const completedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationCompleted)!)[0] + await (completedListener as any)("p1", "c1", "result") + + // Trigger TaskDelegationResumed + const resumedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationResumed)!)[0] + await (resumedListener as any)("p1", "c1") + + // Verify all three events were emitted + const emittedEvents = (mockSocket.emit as any).mock.calls.map((call: any[]) => call[1]?.type) + expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegated) + expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationCompleted) + expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationResumed) + + // Verify correct order: Delegated → Completed → Resumed + const delegatedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegated) + const completedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationCompleted) + const resumedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationResumed) + + expect(delegatedIdx).toBeLessThan(completedIdx) + expect(completedIdx).toBeLessThan(resumedIdx) + }) }) describe("Memory Leak Prevention", () => { @@ -257,7 +371,7 @@ describe("ExtensionChannel", () => { } // Listeners should still be the same count (not accumulated) - expect(eventListeners.size).toBe(15) + expect(eventListeners.size).toBe(18) // Each event should have exactly 1 listener eventListeners.forEach((listeners) => { diff --git a/packages/evals/docker-compose.override.yml b/packages/evals/docker-compose.override.yml new file mode 100644 index 0000000000..7ffc4d8826 --- /dev/null +++ b/packages/evals/docker-compose.override.yml @@ -0,0 +1,45 @@ +# Development overrides - automatically loaded by docker compose +# These settings only apply when running locally for development +# +# For production, use: docker compose -f docker-compose.yml up +# (explicitly exclude override file) + +services: + web: + environment: + - NODE_ENV=development + volumes: + # Mount log files so web can access task logs + - /tmp/evals:/tmp/evals:ro + # Mount source code for hot reload in development + - ../../apps/web-evals:/roo/repo/apps/web-evals:delegated + - ../../packages/evals:/roo/repo/packages/evals:delegated + - ../../packages/types:/roo/repo/packages/types:delegated + - ../../packages/ipc:/roo/repo/packages/ipc:delegated + - ../../packages/cloud:/roo/repo/packages/cloud:delegated + # Exclude node_modules from being overwritten + - /roo/repo/node_modules + - /roo/repo/apps/web-evals/node_modules + - /roo/repo/packages/evals/node_modules + - /roo/repo/packages/types/node_modules + - /roo/repo/packages/ipc/node_modules + - /roo/repo/packages/cloud/node_modules + entrypoint: [] + command: + - sh + - -c + - | + echo '🚀 Starting evals web service in development mode...' + wait_for_db() { + echo '⏳ Waiting for database...' + until pg_isready -h db -p 5432 -U postgres -d evals_development > /dev/null 2>&1; do + echo '⏳ Database not ready yet, waiting 2 seconds...' + sleep 2 + done + echo '✅ Database is ready' + } + wait_for_db + echo '🔄 Running database migrations...' + pnpm --filter @roo-code/evals db:migrate + echo '🌐 Starting Next.js dev server...' + cd /roo/repo/apps/web-evals && npx next dev -p 3446 diff --git a/packages/evals/docker-compose.yml b/packages/evals/docker-compose.yml index 5928b53114..43594639f1 100644 --- a/packages/evals/docker-compose.yml +++ b/packages/evals/docker-compose.yml @@ -55,8 +55,11 @@ services: - "${EVALS_WEB_PORT:-3446}:3446" environment: - HOST_EXECUTION_METHOD=docker + - PRODUCTION_DATABASE_URL volumes: - /var/run/docker.sock:/var/run/docker.sock + # Mount log files so web can access task logs + - /tmp/evals:/tmp/evals:ro depends_on: db: condition: service_healthy diff --git a/packages/evals/src/db/migrations/0004_sloppy_black_knight.sql b/packages/evals/src/db/migrations/0004_sloppy_black_knight.sql new file mode 100644 index 0000000000..f643305225 --- /dev/null +++ b/packages/evals/src/db/migrations/0004_sloppy_black_knight.sql @@ -0,0 +1,3 @@ +DROP INDEX "tasks_language_exercise_idx";--> statement-breakpoint +ALTER TABLE "tasks" ADD COLUMN "iteration" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_language_exercise_iteration_idx" ON "tasks" USING btree ("run_id","language","exercise","iteration"); \ No newline at end of file diff --git a/packages/evals/src/db/migrations/meta/0004_snapshot.json b/packages/evals/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000000..6aef6954e5 --- /dev/null +++ b/packages/evals/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,472 @@ +{ + "id": "9caa4487-e146-4084-907d-fbf9cc3e03b9", + "prevId": "853d308a-3946-4ea8-9039-236bfce3c6c0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_metrics_id": { + "name": "task_metrics_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contextWindow": { + "name": "contextWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inputPrice": { + "name": "inputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "outputPrice": { + "name": "outputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheWritesPrice": { + "name": "cacheWritesPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheReadsPrice": { + "name": "cacheReadsPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "jobToken": { + "name": "jobToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "concurrency": { + "name": "concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "runs_task_metrics_id_taskMetrics_id_fk": { + "name": "runs_task_metrics_id_taskMetrics_id_fk", + "tableFrom": "runs", + "tableTo": "taskMetrics", + "columnsFrom": ["task_metrics_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.taskMetrics": { + "name": "taskMetrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "taskMetrics_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tokens_context": { + "name": "tokens_context", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cache_writes": { + "name": "cache_writes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cache_reads": { + "name": "cache_reads", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tool_usage": { + "name": "tool_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tasks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_metrics_id": { + "name": "task_metrics_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exercise": { + "name": "exercise", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tasks_language_exercise_iteration_idx": { + "name": "tasks_language_exercise_iteration_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "exercise", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_run_id_runs_id_fk": { + "name": "tasks_run_id_runs_id_fk", + "tableFrom": "tasks", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_task_metrics_id_taskMetrics_id_fk": { + "name": "tasks_task_metrics_id_taskMetrics_id_fk", + "tableFrom": "tasks", + "tableTo": "taskMetrics", + "columnsFrom": ["task_metrics_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.toolErrors": { + "name": "toolErrors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "toolErrors_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "toolErrors_run_id_runs_id_fk": { + "name": "toolErrors_run_id_runs_id_fk", + "tableFrom": "toolErrors", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "toolErrors_task_id_tasks_id_fk": { + "name": "toolErrors_task_id_tasks_id_fk", + "tableFrom": "toolErrors", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/evals/src/db/migrations/meta/_journal.json b/packages/evals/src/db/migrations/meta/_journal.json index 9be55aecb8..813667c637 100644 --- a/packages/evals/src/db/migrations/meta/_journal.json +++ b/packages/evals/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1763797232454, "tag": "0003_simple_retro_girl", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1764201678953, + "tag": "0004_sloppy_black_knight", + "breakpoints": true } ] } diff --git a/packages/evals/src/db/schema.ts b/packages/evals/src/db/schema.ts index d8d4c3ea0a..638aae0eee 100644 --- a/packages/evals/src/db/schema.ts +++ b/packages/evals/src/db/schema.ts @@ -55,12 +55,20 @@ export const tasks = pgTable( taskMetricsId: integer("task_metrics_id").references(() => taskMetrics.id), language: text().notNull().$type(), exercise: text().notNull(), + iteration: integer().default(1).notNull(), passed: boolean(), startedAt: timestamp("started_at"), finishedAt: timestamp("finished_at"), createdAt: timestamp("created_at").notNull(), }, - (table) => [uniqueIndex("tasks_language_exercise_idx").on(table.runId, table.language, table.exercise)], + (table) => [ + uniqueIndex("tasks_language_exercise_iteration_idx").on( + table.runId, + table.language, + table.exercise, + table.iteration, + ), + ], ) export const tasksRelations = relations(tasks, ({ one }) => ({ diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 53870f34d4..a90d342c35 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -440,6 +440,9 @@ export enum ExtensionBridgeEventName { TaskPaused = RooCodeEventName.TaskPaused, TaskUnpaused = RooCodeEventName.TaskUnpaused, TaskSpawned = RooCodeEventName.TaskSpawned, + TaskDelegated = RooCodeEventName.TaskDelegated, + TaskDelegationCompleted = RooCodeEventName.TaskDelegationCompleted, + TaskDelegationResumed = RooCodeEventName.TaskDelegationResumed, TaskUserMessage = RooCodeEventName.TaskUserMessage, @@ -520,6 +523,21 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskDelegated), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskDelegationCompleted), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskDelegationResumed), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), z.object({ type: z.literal(ExtensionBridgeEventName.TaskUserMessage), diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index b33320c7be..5e4415db20 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -26,6 +26,9 @@ export enum RooCodeEventName { TaskPaused = "taskPaused", TaskUnpaused = "taskUnpaused", TaskSpawned = "taskSpawned", + TaskDelegated = "taskDelegated", + TaskDelegationCompleted = "taskDelegationCompleted", + TaskDelegationResumed = "taskDelegationResumed", // Task Execution Message = "message", @@ -73,6 +76,19 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.TaskPaused]: z.tuple([z.string()]), [RooCodeEventName.TaskUnpaused]: z.tuple([z.string()]), [RooCodeEventName.TaskSpawned]: z.tuple([z.string(), z.string()]), + [RooCodeEventName.TaskDelegated]: z.tuple([ + z.string(), // parentTaskId + z.string(), // childTaskId + ]), + [RooCodeEventName.TaskDelegationCompleted]: z.tuple([ + z.string(), // parentTaskId + z.string(), // childTaskId + z.string(), // completionResultSummary + ]), + [RooCodeEventName.TaskDelegationResumed]: z.tuple([ + z.string(), // parentTaskId + z.string(), // childTaskId + ]), [RooCodeEventName.Message]: z.tuple([ z.object({ @@ -169,6 +185,21 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskSpawned], taskId: z.number().optional(), }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskDelegated), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegated], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskDelegationCompleted), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegationCompleted], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskDelegationResumed), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegationResumed], + taskId: z.number().optional(), + }), // Task Execution z.object({ diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 395ec5986f..e5b6f5418f 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -19,6 +19,12 @@ export const historyItemSchema = z.object({ size: z.number().optional(), workspace: z.string().optional(), mode: z.string().optional(), + status: z.enum(["active", "completed", "delegated"]).optional(), + delegatedToId: z.string().optional(), // Last child this parent delegated to + childIds: z.array(z.string()).optional(), // All children spawned by this task + awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) + completedByChildId: z.string().optional(), // Child that completed and resumed this parent + completionResultSummary: z.string().optional(), // Summary from completed child }) export type HistoryItem = z.infer diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index ddfa14871d..e908886515 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -118,6 +118,13 @@ export const modelInfoSchema = z.object({ supportsNativeTools: z.boolean().optional(), // Default tool protocol preferred by this model (if not specified, falls back to capability/provider defaults) defaultToolProtocol: z.enum(["xml", "native"]).optional(), + // Exclude specific native tools from being available (only applies to native protocol) + // These tools will be removed from the set of tools available to the model + excludedTools: z.array(z.string()).optional(), + // Include specific native tools (only applies to native protocol) + // These tools will be added if they belong to an allowed group in the current mode + // Cannot force-add tools from groups the mode doesn't allow + includedTools: z.array(z.string()).optional(), /** * Service tiers with pricing information. * Each tier can have a name (for OpenAI service tiers) and pricing overrides. diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index f916557235..c5c297cdb9 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -11,6 +11,7 @@ export const deepSeekModels = { contextWindow: 128_000, supportsImages: false, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 outputPrice: 1.68, // $1.68 per million tokens - Updated Sept 5, 2025 cacheWritesPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 @@ -22,6 +23,7 @@ export const deepSeekModels = { contextWindow: 128_000, supportsImages: false, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 outputPrice: 1.68, // $1.68 per million tokens - Updated Sept 5, 2025 cacheWritesPrice: 0.56, // $0.56 per million tokens (cache miss) - Updated Sept 5, 2025 diff --git a/packages/types/src/providers/doubao.ts b/packages/types/src/providers/doubao.ts index f948450bc4..c822d69f0b 100644 --- a/packages/types/src/providers/doubao.ts +++ b/packages/types/src/providers/doubao.ts @@ -8,6 +8,7 @@ export const doubaoModels = { contextWindow: 128_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.0001, // $0.0001 per million tokens (cache miss) outputPrice: 0.0004, // $0.0004 per million tokens cacheWritesPrice: 0.0001, // $0.0001 per million tokens (cache miss) @@ -19,6 +20,7 @@ export const doubaoModels = { contextWindow: 128_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.0002, // $0.0002 per million tokens outputPrice: 0.0008, // $0.0008 per million tokens cacheWritesPrice: 0.0002, // $0.0002 per million @@ -30,6 +32,7 @@ export const doubaoModels = { contextWindow: 128_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.00015, // $0.00015 per million tokens outputPrice: 0.0006, // $0.0006 per million tokens cacheWritesPrice: 0.00015, // $0.00015 per million diff --git a/packages/types/src/providers/featherless.ts b/packages/types/src/providers/featherless.ts index d24f1fd882..8d9f7ae4e6 100644 --- a/packages/types/src/providers/featherless.ts +++ b/packages/types/src/providers/featherless.ts @@ -31,6 +31,7 @@ export const featherlessModels = { contextWindow: 32678, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, description: "Kimi K2 Instruct model.", @@ -49,6 +50,7 @@ export const featherlessModels = { contextWindow: 32678, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, description: "Qwen3 Coder 480B A35B Instruct model.", diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts index 660e568075..4e4f90dc72 100644 --- a/packages/types/src/providers/fireworks.ts +++ b/packages/types/src/providers/fireworks.ts @@ -23,6 +23,7 @@ export const fireworksModels = { contextWindow: 262144, supportsImages: false, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.6, outputPrice: 2.5, cacheReadsPrice: 0.15, @@ -34,6 +35,7 @@ export const fireworksModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.6, outputPrice: 2.5, description: @@ -44,6 +46,7 @@ export const fireworksModels = { contextWindow: 204800, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.3, outputPrice: 1.2, description: @@ -54,6 +57,7 @@ export const fireworksModels = { contextWindow: 256000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.22, outputPrice: 0.88, description: "Latest Qwen3 thinking model, competitive against the best closed source models in Jul 2025.", @@ -63,6 +67,7 @@ export const fireworksModels = { contextWindow: 256000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.45, outputPrice: 1.8, description: "Qwen3's most agentic code model to date.", @@ -72,6 +77,7 @@ export const fireworksModels = { contextWindow: 160000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 3, outputPrice: 8, description: @@ -82,6 +88,7 @@ export const fireworksModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.9, outputPrice: 0.9, description: @@ -92,6 +99,7 @@ export const fireworksModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.56, outputPrice: 1.68, description: @@ -102,6 +110,7 @@ export const fireworksModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.55, outputPrice: 2.19, description: @@ -112,6 +121,7 @@ export const fireworksModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.55, outputPrice: 2.19, description: @@ -122,6 +132,7 @@ export const fireworksModels = { contextWindow: 198000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.55, outputPrice: 2.19, description: @@ -132,6 +143,7 @@ export const fireworksModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.07, outputPrice: 0.3, description: @@ -142,6 +154,7 @@ export const fireworksModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.15, outputPrice: 0.6, description: diff --git a/packages/types/src/providers/groq.ts b/packages/types/src/providers/groq.ts index c264572af8..99b8ee427a 100644 --- a/packages/types/src/providers/groq.ts +++ b/packages/types/src/providers/groq.ts @@ -24,6 +24,7 @@ export const groqModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.05, outputPrice: 0.08, description: "Meta Llama 3.1 8B Instant model, 128K context.", @@ -33,6 +34,7 @@ export const groqModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.59, outputPrice: 0.79, description: "Meta Llama 3.3 70B Versatile model, 128K context.", @@ -42,6 +44,7 @@ export const groqModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.11, outputPrice: 0.34, description: "Meta Llama 4 Scout 17B Instruct model, 128K context.", @@ -78,6 +81,7 @@ export const groqModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.29, outputPrice: 0.59, description: "Alibaba Qwen 3 32B model, 128K context.", @@ -106,6 +110,7 @@ export const groqModels = { contextWindow: 262144, supportsImages: false, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 0.6, outputPrice: 2.5, cacheReadsPrice: 0.15, @@ -117,6 +122,7 @@ export const groqModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.15, outputPrice: 0.75, description: @@ -127,6 +133,7 @@ export const groqModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.1, outputPrice: 0.5, description: diff --git a/packages/types/src/providers/io-intelligence.ts b/packages/types/src/providers/io-intelligence.ts index a9b845393f..573db6b97a 100644 --- a/packages/types/src/providers/io-intelligence.ts +++ b/packages/types/src/providers/io-intelligence.ts @@ -18,6 +18,7 @@ export const ioIntelligenceModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, description: "DeepSeek R1 reasoning model", }, "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { @@ -25,6 +26,7 @@ export const ioIntelligenceModels = { contextWindow: 430000, supportsImages: true, supportsPromptCache: false, + supportsNativeTools: true, description: "Llama 4 Maverick 17B model", }, "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": { @@ -32,6 +34,7 @@ export const ioIntelligenceModels = { contextWindow: 106000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, description: "Qwen3 Coder 480B specialized for coding", }, "openai/gpt-oss-120b": { @@ -39,6 +42,7 @@ export const ioIntelligenceModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, description: "OpenAI GPT-OSS 120B model", }, } as const satisfies Record diff --git a/packages/types/src/providers/sambanova.ts b/packages/types/src/providers/sambanova.ts index f339d8bcab..6ca04f48e3 100644 --- a/packages/types/src/providers/sambanova.ts +++ b/packages/types/src/providers/sambanova.ts @@ -21,6 +21,7 @@ export const sambaNovaModels = { contextWindow: 16384, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.1, outputPrice: 0.2, description: "Meta Llama 3.1 8B Instruct model with 16K context window.", @@ -30,6 +31,7 @@ export const sambaNovaModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.6, outputPrice: 1.2, description: "Meta Llama 3.3 70B Instruct model with 128K context window.", @@ -40,6 +42,7 @@ export const sambaNovaModels = { supportsImages: false, supportsPromptCache: false, supportsReasoningBudget: true, + supportsNativeTools: true, inputPrice: 5.0, outputPrice: 7.0, description: "DeepSeek R1 reasoning model with 32K context window.", @@ -49,6 +52,7 @@ export const sambaNovaModels = { contextWindow: 32768, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 3.0, outputPrice: 4.5, description: "DeepSeek V3 model with 32K context window.", @@ -58,6 +62,7 @@ export const sambaNovaModels = { contextWindow: 32768, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 3.0, outputPrice: 4.5, description: "DeepSeek V3.1 model with 32K context window.", @@ -76,6 +81,7 @@ export const sambaNovaModels = { contextWindow: 131072, supportsImages: true, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.63, outputPrice: 1.8, description: "Meta Llama 4 Maverick 17B 128E Instruct model with 128K context window.", @@ -94,6 +100,7 @@ export const sambaNovaModels = { contextWindow: 8192, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.4, outputPrice: 0.8, description: "Alibaba Qwen 3 32B model with 8K context window.", @@ -103,6 +110,7 @@ export const sambaNovaModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.22, outputPrice: 0.59, description: "OpenAI gpt oss 120b model with 128k context window.", diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index 6805f17d89..53b67418cf 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -10,6 +10,7 @@ export const vertexModels = { maxTokens: 65_536, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, supportsReasoningEffort: ["low", "high"], reasoningEffort: "low", @@ -34,6 +35,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.15, outputPrice: 3.5, @@ -45,6 +47,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.15, outputPrice: 0.6, @@ -53,6 +56,7 @@ export const vertexModels = { maxTokens: 64_000, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.3, outputPrice: 2.5, @@ -65,6 +69,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 0.15, outputPrice: 3.5, @@ -76,6 +81,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 0.15, outputPrice: 0.6, @@ -84,6 +90,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, outputPrice: 15, @@ -92,6 +99,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, outputPrice: 15, @@ -100,6 +108,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, outputPrice: 15, @@ -110,6 +119,7 @@ export const vertexModels = { maxTokens: 64_000, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, outputPrice: 15, @@ -135,6 +145,7 @@ export const vertexModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 0, outputPrice: 0, @@ -143,6 +154,7 @@ export const vertexModels = { maxTokens: 8192, contextWindow: 2_097_152, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 0, outputPrice: 0, @@ -151,6 +163,7 @@ export const vertexModels = { maxTokens: 8192, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.15, outputPrice: 0.6, @@ -159,6 +172,7 @@ export const vertexModels = { maxTokens: 8192, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 0.075, outputPrice: 0.3, @@ -167,6 +181,7 @@ export const vertexModels = { maxTokens: 8192, contextWindow: 32_768, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 0, outputPrice: 0, @@ -175,6 +190,7 @@ export const vertexModels = { maxTokens: 8192, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.075, outputPrice: 0.3, @@ -183,6 +199,7 @@ export const vertexModels = { maxTokens: 8192, contextWindow: 2_097_152, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: false, inputPrice: 1.25, outputPrice: 5, @@ -328,6 +345,7 @@ export const vertexModels = { maxTokens: 64_000, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.1, outputPrice: 0.4, diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 2b49238100..319ead3573 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -72,6 +72,9 @@ export type TaskProviderEvents = { [RooCodeEventName.TaskPaused]: [taskId: string] [RooCodeEventName.TaskUnpaused]: [taskId: string] [RooCodeEventName.TaskSpawned]: [taskId: string] + [RooCodeEventName.TaskDelegated]: [parentTaskId: string, childTaskId: string] + [RooCodeEventName.TaskDelegationCompleted]: [parentTaskId: string, childTaskId: string, summary: string] + [RooCodeEventName.TaskDelegationResumed]: [parentTaskId: string, childTaskId: string] [RooCodeEventName.TaskUserMessage]: [taskId: string] @@ -95,6 +98,8 @@ export interface CreateTaskOptions { useZgsmCustomConfig?: boolean zgsmCodebaseIndexEnabled?: boolean zgsmWorkflowMode?: string + /** Initial status for the task's history item (e.g., "active" for child tasks) */ + initialStatus?: "active" | "delegated" | "completed" } export enum TaskStatus { diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 62129c2a03..e6649e0820 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -20,6 +20,8 @@ export const toolNames = [ "write_to_file", "apply_diff", "insert_content", + "search_and_replace", + "apply_patch", "search_files", "list_files", "list_code_definition_names", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de1a1120f..fdbbea4231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: '@tanstack/react-query': specifier: ^5.69.0 version: 5.90.10(react@18.3.1) + archiver: + specifier: ^7.0.1 + version: 7.0.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -241,6 +244,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.17 + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 '@types/ps-tree': specifier: ^1.1.6 version: 1.1.6 @@ -3715,6 +3721,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/archiver@7.0.0': + resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3956,6 +3965,9 @@ packages: '@types/react@18.3.27': resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} @@ -4289,10 +4301,18 @@ packages: resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} engines: {node: '>= 10'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + archiver@5.3.2: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -4515,6 +4535,10 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -4528,6 +4552,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} engines: {node: '>=0.2.0'} @@ -4802,6 +4829,10 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -4863,6 +4894,10 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -5716,6 +5751,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -8057,6 +8096,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -8318,6 +8361,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} @@ -9910,6 +9957,10 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod-to-json-schema@3.25.0: resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} peerDependencies: @@ -12917,6 +12968,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/archiver@7.0.0': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/aria-query@5.0.4': {} '@types/async-retry@1.4.9': @@ -13196,6 +13251,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 24.10.1 + '@types/retry@0.12.5': {} '@types/shell-quote@1.7.5': {} @@ -13244,7 +13303,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.19.25 + '@types/node': 24.10.1 optional: true '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3)': @@ -13632,6 +13691,16 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + archiver-utils@5.0.2: + dependencies: + glob: 13.0.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.21 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + archiver@5.3.2: dependencies: archiver-utils: 2.1.0 @@ -13642,6 +13711,19 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.7 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + arg@5.0.2: {} argparse@1.0.10: @@ -13893,6 +13975,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -13904,6 +13988,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffers@0.1.1: {} bundle-name@4.1.0: @@ -14199,6 +14288,14 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + confbox@0.1.8: {} confbox@0.2.2: {} @@ -14253,6 +14350,11 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -15136,6 +15238,8 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + events@3.3.0: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -17944,6 +18048,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + progress@2.0.3: {} prom-client@15.1.3: @@ -18284,6 +18390,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdir-glob@1.1.3: dependencies: minimatch: 5.1.6 @@ -20197,6 +20311,12 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod-to-json-schema@3.25.0(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/__tests__/delegation-events.spec.ts b/src/__tests__/delegation-events.spec.ts new file mode 100644 index 0000000000..15ea5b7c46 --- /dev/null +++ b/src/__tests__/delegation-events.spec.ts @@ -0,0 +1,48 @@ +// npx vitest run __tests__/delegation-events.spec.ts + +import { RooCodeEventName, rooCodeEventsSchema, taskEventSchema } from "@roo-code/types" + +describe("delegation event schemas", () => { + test("rooCodeEventsSchema validates tuples", () => { + expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegated].parse(["p", "c"])).not.toThrow() + expect(() => + (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationCompleted].parse(["p", "c", "s"]), + ).not.toThrow() + expect(() => + (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationResumed].parse(["p", "c"]), + ).not.toThrow() + + // invalid shapes + expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegated].parse(["p"])).toThrow() + expect(() => + (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationCompleted].parse(["p", "c"]), + ).toThrow() + expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationResumed].parse(["p"])).toThrow() + }) + + test("taskEventSchema discriminated union includes delegation events", () => { + expect(() => + taskEventSchema.parse({ + eventName: RooCodeEventName.TaskDelegated, + payload: ["p", "c"], + taskId: 1, + }), + ).not.toThrow() + + expect(() => + taskEventSchema.parse({ + eventName: RooCodeEventName.TaskDelegationCompleted, + payload: ["p", "c", "s"], + taskId: 1, + }), + ).not.toThrow() + + expect(() => + taskEventSchema.parse({ + eventName: RooCodeEventName.TaskDelegationResumed, + payload: ["p", "c"], + taskId: 1, + }), + ).not.toThrow() + }) +}) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts new file mode 100644 index 0000000000..66982cab85 --- /dev/null +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -0,0 +1,501 @@ +// npx vitest run __tests__/history-resume-delegation.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { RooCodeEventName } from "@roo-code/types" + +/* vscode mock for Task/Provider imports */ +vi.mock("vscode", () => { + const window = { + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + })), + } + const workspace = { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + update: vi.fn(), + })), + workspaceFolders: [], + } + const env = { machineId: "test-machine", uriScheme: "vscode", appName: "VSCode", language: "en", sessionId: "sess" } + const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) } + const commands = { executeCommand: vi.fn() } + const ExtensionMode = { Development: 2 } + const version = "1.0.0-test" + return { window, workspace, env, Uri, commands, ExtensionMode, version } +}) + +// Mock persistence BEFORE importing provider +vi.mock("../core/task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) +vi.mock("../core/task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), +})) + +import { ClineProvider } from "../core/webview/ClineProvider" +import { readTaskMessages } from "../core/task-persistence/taskMessages" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" + +describe("History resume delegation - parent metadata transitions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("reopenParentFromDelegation persists parent metadata (delegated → active) before reopen", async () => { + const providerEmit = vi.fn() + const getTaskWithId = vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-1", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + ts: Date.now(), + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + }) + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTaskWithHistoryItem = vi.fn().mockResolvedValue({ + taskId: "parent-1", + skipPrevResponseIdOnce: false, + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + }) + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId, + emit: providerEmit, + getCurrentTask: vi.fn(() => ({ taskId: "child-1" })), + removeClineFromStack, + createTaskWithHistoryItem, + updateTaskHistory, + } as unknown as ClineProvider + + // Mock persistence reads to return empty arrays + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-1", + childTaskId: "child-1", + completionResultSummary: "Child done", + }) + + // Assert: metadata updated BEFORE createTaskWithHistoryItem + expect(updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-1", + status: "active", + completedByChildId: "child-1", + completionResultSummary: "Child done", + awaitingChildId: undefined, + childIds: ["child-1"], + }), + ) + + // Verify call ordering: updateTaskHistory before createTaskWithHistoryItem + const updateCall = updateTaskHistory.mock.invocationCallOrder[0] + const createCall = createTaskWithHistoryItem.mock.invocationCallOrder[0] + expect(updateCall).toBeLessThan(createCall) + + // Verify child closed and parent reopened with updated metadata + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(createTaskWithHistoryItem).toHaveBeenCalledWith( + expect.objectContaining({ + status: "active", + completedByChildId: "child-1", + }), + { startTask: false }, + ) + }) + + it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p1", + status: "delegated", + awaitingChildId: "c1", + childIds: [], + ts: 100, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c1" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: "p1", + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Start with existing messages in history + const existingUiMessages = [{ type: "ask", ask: "tool", text: "Old tool", ts: 50 }] + const existingApiMessages = [{ role: "user", content: [{ type: "text", text: "Old request" }], ts: 50 }] + + vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any) + vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p1", + childTaskId: "c1", + completionResultSummary: "Subtask completed successfully", + }) + + // Verify UI history injection (say: subtask_result) + expect(saveTaskMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + type: "say", + say: "subtask_result", + text: "Subtask completed successfully", + }), + ]), + taskId: "p1", + globalStoragePath: "/storage", + }), + ) + + // Verify API history injection (user role message) + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Subtask c1 completed"), + }), + ]), + }), + ]), + taskId: "p1", + globalStoragePath: "/storage", + }), + ) + + // Verify both include original messages + const uiCall = vi.mocked(saveTaskMessages).mock.calls[0][0] + expect(uiCall.messages).toHaveLength(2) // 1 original + 1 injected + + const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0] + expect(apiCall.messages).toHaveLength(2) // 1 original + 1 injected + }) + + it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p-tool", + status: "delegated", + awaitingChildId: "c-tool", + childIds: [], + ts: 100, + task: "Parent with tool_use", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c-tool" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: "p-tool", + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Include an assistant message with new_task tool_use to exercise the tool_result path + const existingUiMessages = [{ type: "ask", ask: "tool", text: "new_task request", ts: 50 }] + const existingApiMessages = [ + { role: "user", content: [{ type: "text", text: "Create a subtask" }], ts: 40 }, + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "new_task", + id: "toolu_abc123", + input: { mode: "code", message: "Do something" }, + }, + ], + ts: 50, + }, + ] + + vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any) + vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p-tool", + childTaskId: "c-tool", + completionResultSummary: "Subtask completed via tool_result", + }) + + // Verify API history injection uses tool_result (not text fallback) + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "toolu_abc123", + content: expect.stringContaining("Subtask c-tool completed"), + }), + ]), + }), + ]), + taskId: "p-tool", + globalStoragePath: "/storage", + }), + ) + + // Verify total message count: 2 original + 1 injected user message with tool_result + const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0] + expect(apiCall.messages).toHaveLength(3) + + // Verify the injected message is a user message with tool_result type + const injectedMsg = apiCall.messages[2] + expect(injectedMsg.role).toBe("user") + expect((injectedMsg.content[0] as any).type).toBe("tool_result") + expect((injectedMsg.content[0] as any).tool_use_id).toBe("toolu_abc123") + }) + + it("reopenParentFromDelegation sets skipPrevResponseIdOnce via resumeAfterDelegation", async () => { + const parentInstance: any = { + skipPrevResponseIdOnce: false, + resumeAfterDelegation: vi.fn().mockImplementation(async function (this: any) { + // Simulate what the real resumeAfterDelegation does + this.skipPrevResponseIdOnce = true + }), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "parent-2", + status: "delegated", + awaitingChildId: "child-2", + childIds: [], + ts: 200, + task: "P", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-2" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-2", + childTaskId: "child-2", + completionResultSummary: "Done", + }) + + // Critical: verify skipPrevResponseIdOnce set to true by resumeAfterDelegation + expect(parentInstance.skipPrevResponseIdOnce).toBe(true) + expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1) + }) + + it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => { + const emitSpy = vi.fn() + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p3", + status: "delegated", + awaitingChildId: "c3", + childIds: [], + ts: 300, + task: "P3", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: emitSpy, + getCurrentTask: vi.fn(() => ({ taskId: "c3" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p3", + childTaskId: "c3", + completionResultSummary: "Summary", + }) + + // Verify both events emitted + const eventNames = emitSpy.mock.calls.map((c) => c[0]) + expect(eventNames).toContain(RooCodeEventName.TaskDelegationCompleted) + expect(eventNames).toContain(RooCodeEventName.TaskDelegationResumed) + + // CRITICAL: verify ordering (TaskDelegationCompleted before TaskDelegationResumed) + const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted) + const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed) + expect(completedIdx).toBeGreaterThanOrEqual(0) + expect(resumedIdx).toBeGreaterThan(completedIdx) + }) + + it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => { + const emitSpy = vi.fn() + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p4", + status: "delegated", + awaitingChildId: "c4", + childIds: [], + ts: 400, + task: "P4", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: emitSpy, + getCurrentTask: vi.fn(() => ({ taskId: "c4" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p4", + childTaskId: "c4", + completionResultSummary: "S", + }) + + // CRITICAL: verify legacy pause/unpause events NOT emitted + const eventNames = emitSpy.mock.calls.map((c) => c[0]) + expect(eventNames).not.toContain(RooCodeEventName.TaskPaused) + expect(eventNames).not.toContain(RooCodeEventName.TaskUnpaused) + expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned) + }) + + it("handles empty history gracefully when injecting synthetic messages", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { + id: "p5", + status: "delegated", + awaitingChildId: "c5", + childIds: [], + ts: 500, + task: "P5", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "c5" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Mock read failures or empty returns + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await expect( + (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "p5", + childTaskId: "c5", + completionResultSummary: "Result", + }), + ).resolves.toBeUndefined() + + // Verify saves still occurred with just the injected message + expect(saveTaskMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + type: "say", + say: "subtask_result", + }), + ], + }), + ) + + expect(saveApiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + role: "user", + }), + ], + }), + ) + }) +}) diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts new file mode 100644 index 0000000000..067a3d3bf8 --- /dev/null +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -0,0 +1,271 @@ +// npx vitest run __tests__/nested-delegation-resume.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { RooCodeEventName } from "@roo-code/types" + +// Mock safe-stable-stringify to avoid runtime error +vi.mock("safe-stable-stringify", () => ({ + default: (obj: any) => JSON.stringify(obj), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCompleted: vi.fn(), + }, + }, +})) + +// vscode mock for Task/Provider imports +vi.mock("vscode", () => { + const window = { + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + })), + } + const workspace = { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + update: vi.fn(), + })), + workspaceFolders: [], + } + const env = { machineId: "test-machine", uriScheme: "vscode", appName: "VSCode", language: "en", sessionId: "sess" } + const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) } + const commands = { executeCommand: vi.fn() } + const ExtensionMode = { Development: 2 } + const version = "1.0.0-test" + return { window, workspace, env, Uri, commands, ExtensionMode, version } +}) + +// Mock persistence helpers used by provider reopen flow BEFORE importing provider +vi.mock("../core/task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) +vi.mock("../core/task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), +})) + +import { attemptCompletionTool } from "../core/tools/AttemptCompletionTool" +import { ClineProvider } from "../core/webview/ClineProvider" +import type { Task } from "../core/task/Task" +import { readTaskMessages } from "../core/task-persistence/taskMessages" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence" + +describe("Nested delegation resume (A → B → C)", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("C completes → reopens B; then B completes → reopens A; emits correct events; no resume_task asks", async () => { + // Track which task is "current" to satisfy provider.reopenParentFromDelegation() child-close logic + let currentActiveId: string | undefined = "C" + + // History index: A is parent of B, B is parent of C + const historyIndex: Record = { + A: { + id: "A", + status: "delegated", + delegatedToId: "B", + awaitingChildId: "B", + childIds: ["B"], + parentTaskId: undefined, + ts: 1, + task: "Task A", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + B: { + id: "B", + status: "delegated", + delegatedToId: "C", + awaitingChildId: "C", + childIds: ["C"], + parentTaskId: "A", + ts: 2, + task: "Task B", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + C: { + id: "C", + status: "active", + parentTaskId: "B", + ts: 3, + task: "Task C", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + workspace: "/tmp", + }, + } + + const emitSpy = vi.fn() + const removeClineFromStack = vi.fn().mockImplementation(async () => { + // Simulate closing current child + currentActiveId = undefined + }) + const createTaskWithHistoryItem = vi + .fn() + .mockImplementation(async (historyItem: any, opts?: { startTask?: boolean }) => { + // Assert startTask:false to avoid resume asks + expect(opts).toEqual(expect.objectContaining({ startTask: false })) + // Reopen the parent + currentActiveId = historyItem.id + // Return minimal parent instance with resumeAfterDelegation + return { + taskId: historyItem.id, + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + } + }) + + const getTaskWithId = vi.fn(async (id: string) => { + if (!historyIndex[id]) throw new Error("Task not found") + return { + historyItem: historyIndex[id], + apiConversationHistory: [], + taskDirPath: "/tmp", + apiConversationHistoryFilePath: "/tmp/api.json", + uiMessagesFilePath: "/tmp/ui.json", + } + }) + + const updateTaskHistory = vi.fn(async (updated: any) => { + // Persist updated history back into index (simulate) + historyIndex[updated.id] = updated + return Object.values(historyIndex) + }) + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + getTaskWithId, + emit: emitSpy, + getCurrentTask: vi.fn(() => (currentActiveId ? ({ taskId: currentActiveId } as any) : undefined)), + removeClineFromStack, + createTaskWithHistoryItem, + updateTaskHistory, + // Wire through provider method so attemptCompletionTool can call it + reopenParentFromDelegation: vi.fn(async (params: any) => { + return await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, params) + }), + } as unknown as ClineProvider + + // Empty histories for simplicity + vi.mocked(readTaskMessages).mockResolvedValue([]) + vi.mocked(readApiMessages).mockResolvedValue([]) + + // Step 1: C completes -> should reopen B automatically + const clineC = { + taskId: "C", + parentTask: undefined, // parent ref may or may not exist; metadata path should still work + parentTaskId: "B", + historyItem: { parentTaskId: "B" }, + providerRef: { deref: () => provider }, + say: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + getTokenUsage: vi.fn(() => ({})), + toolUsage: {}, + clineMessages: [], + userMessageContent: [], + consecutiveMistakeCount: 0, + } as unknown as Task + + const blockC = { + type: "tool_use", + name: "attempt_completion", + params: { result: "C finished" }, + partial: false, + } as any + + const askFinishSubTaskApproval = vi.fn(async () => true) + + await attemptCompletionTool.handle(clineC, blockC, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((_, v?: string) => v ?? ""), + askFinishSubTaskApproval, + toolProtocol: "xml", + toolDescription: () => "desc", + } as any) + + // After C completes, B must be current + expect(currentActiveId).toBe("B") + + // Events emitted: C -> B hop + const eventNamesAfterC = emitSpy.mock.calls.map((c: any[]) => c[0]) + expect(eventNamesAfterC).toContain(RooCodeEventName.TaskDelegationCompleted) + expect(eventNamesAfterC).toContain(RooCodeEventName.TaskDelegationResumed) + + // Step 2: B completes -> should reopen A automatically (parent reference missing, must use parentTaskId path) + const clineB = { + taskId: "B", + parentTask: undefined, // simulate missing live parent reference + parentTaskId: "A", // persisted parent id + historyItem: { parentTaskId: "A" }, + providerRef: { deref: () => provider }, + say: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + getTokenUsage: vi.fn(() => ({})), + toolUsage: {}, + clineMessages: [], + userMessageContent: [], + consecutiveMistakeCount: 0, + } as unknown as Task + + const blockB = { + type: "tool_use", + name: "attempt_completion", + params: { result: "B finished" }, + partial: false, + } as any + + await attemptCompletionTool.handle(clineB, blockB, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((_, v?: string) => v ?? ""), + askFinishSubTaskApproval, + toolProtocol: "xml", + toolDescription: () => "desc", + } as any) + + // After B completes, A must be current + expect(currentActiveId).toBe("A") + + // Ensure no resume_task asks were scheduled: verified indirectly by startTask:false on both hops + // (asserted in createTaskWithHistoryItem mock) + + // Provider emitted TaskDelegationCompleted/Resumed twice across both hops + const completedEvents = emitSpy.mock.calls.filter( + (c: any[]) => c[0] === RooCodeEventName.TaskDelegationCompleted, + ) + const resumedEvents = emitSpy.mock.calls.filter((c: any[]) => c[0] === RooCodeEventName.TaskDelegationResumed) + expect(completedEvents.length).toBeGreaterThanOrEqual(2) + expect(resumedEvents.length).toBeGreaterThanOrEqual(2) + + // Verify second hop used parentId = A + // Find a TaskDelegationCompleted matching A <- B + const hasAfromB = completedEvents.some(([, parentId, childId]: any[]) => parentId === "A" && childId === "B") + expect(hasAfromB).toBe(true) + }) +}) diff --git a/src/__tests__/new-task-delegation.spec.ts b/src/__tests__/new-task-delegation.spec.ts new file mode 100644 index 0000000000..b6f6d4d36c --- /dev/null +++ b/src/__tests__/new-task-delegation.spec.ts @@ -0,0 +1,44 @@ +// npx vitest run __tests__/new-task-delegation.spec.ts + +import { describe, it, expect, vi } from "vitest" +import { RooCodeEventName } from "@roo-code/types" +import { Task } from "../core/task/Task" + +describe("Task.startSubtask() metadata-driven delegation", () => { + it("Routes to provider.delegateParentAndOpenChild without pausing parent", async () => { + const provider = { + getState: vi.fn().mockResolvedValue({ + experiments: {}, + }), + delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }), + createTask: vi.fn(), + handleModeSwitch: vi.fn(), + } as any + + // Create a minimal Task-like instance with only fields used by startSubtask + const parent = Object.create(Task.prototype) as Task + ;(parent as any).taskId = "parent-1" + ;(parent as any).providerRef = { deref: () => provider } + ;(parent as any).emit = vi.fn() + + const child = await (Task.prototype as any).startSubtask.call(parent, "Do something", [], "code") + + expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + expect(child.taskId).toBe("child-1") + + // Parent should not be paused and no paused/unpaused events should be emitted + expect((parent as any).isPaused).not.toBe(true) + expect((parent as any).childTaskId).toBeUndefined() + const emittedEvents = (parent.emit as any).mock.calls.map((c: any[]) => c[0]) + expect(emittedEvents).not.toContain(RooCodeEventName.TaskPaused) + expect(emittedEvents).not.toContain(RooCodeEventName.TaskUnpaused) + + // Legacy path not used + expect(provider.createTask).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts new file mode 100644 index 0000000000..76cde6d386 --- /dev/null +++ b/src/__tests__/provider-delegation.spec.ts @@ -0,0 +1,92 @@ +// npx vitest run __tests__/provider-delegation.spec.ts + +import { describe, it, expect, vi } from "vitest" +import { RooCodeEventName } from "@roo-code/types" +import { ClineProvider } from "../core/webview/ClineProvider" + +describe("ClineProvider.delegateParentAndOpenChild()", () => { + it("persists parent delegation metadata and emits TaskDelegated", async () => { + const providerEmit = vi.fn() + const parentTask = { taskId: "parent-1", emit: vi.fn() } as any + + const updateTaskHistory = vi.fn() + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "parent-1") { + return { + historyItem: { + id: "parent-1", + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds: [], + }, + } + } + // child-1 + return { + historyItem: { + id: "child-1", + task: "Do something", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + }) + + const provider = { + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack, + createTask, + getTaskWithId, + updateTaskHistory, + handleModeSwitch, + log: vi.fn(), + } as unknown as ClineProvider + + const params = { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + } + + const child = await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, params) + + expect(child.taskId).toBe("child-1") + + // Invariant: parent closed before child creation + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + // Child task is created with initialStatus: "active" to avoid race conditions + expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, { + initialTodos: [], + initialStatus: "active", + }) + + // Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus) + expect(updateTaskHistory).toHaveBeenCalledTimes(1) + + // Parent set to "delegated" + const parentSaved = updateTaskHistory.mock.calls[0][0] + expect(parentSaved).toEqual( + expect.objectContaining({ + id: "parent-1", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: expect.arrayContaining(["child-1"]), + }), + ) + + // Event emission (provider-level) + expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") + + // Mode switch + expect(handleModeSwitch).toHaveBeenCalledWith("code") + }) +}) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts new file mode 100644 index 0000000000..3284ae7629 --- /dev/null +++ b/src/__tests__/single-open-invariant.spec.ts @@ -0,0 +1,169 @@ +// npx vitest run __tests__/single-open-invariant.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ClineProvider } from "../core/webview/ClineProvider" +import { API } from "../extension/api" +import * as ProfileValidatorMod from "../shared/ProfileValidator" + +// Mock Task class used by ClineProvider to avoid heavy startup +vi.mock("../core/task/Task", () => { + class TaskStub { + public taskId: string + public instanceId = "inst" + public parentTask?: any + public apiConfiguration: any + public rootTask?: any + public enableBridge?: boolean + constructor(opts: any) { + this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}` + this.parentTask = opts.parentTask + this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" } + opts.onCreated?.(this) + } + on() {} + off() {} + emit() {} + } + return { Task: TaskStub } +}) + +describe("Single-open-task invariant", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("User-initiated create: closes existing before opening new", async () => { + // Allow profile + vi.spyOn(ProfileValidatorMod.ProfileValidator, "isProfileAllowed").mockReturnValue(true) + + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const addClineToStack = vi.fn().mockResolvedValue(undefined) + + const provider = { + // Simulate an existing task present in stack + clineStack: [{ taskId: "existing-1" }], + setValues: vi.fn(), + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + organizationAllowList: "*", + diffEnabled: false, + enableCheckpoints: true, + checkpointTimeout: 60, + fuzzyMatchThreshold: 1.0, + cloudUserInfo: null, + remoteControlEnabled: false, + }), + removeClineFromStack, + addClineToStack, + setProviderProfile: vi.fn(), + log: vi.fn(), + getStateToPostToWebview: vi.fn(), + providerSettingsManager: { getModeConfigId: vi.fn(), listConfig: vi.fn() }, + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + taskCreationCallback: vi.fn(), + contextProxy: { + extensionUri: {}, + setValue: vi.fn(), + getValue: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({})), + }, + } as unknown as ClineProvider + + await (ClineProvider.prototype as any).createTask.call(provider, "New task") + + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(addClineToStack).toHaveBeenCalledTimes(1) + }) + + it("History resume path always closes current before rehydration (non-rehydrating case)", async () => { + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const addClineToStack = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) + + const provider = { + getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating + removeClineFromStack, + addClineToStack, + updateGlobalState, + log: vi.fn(), + customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) }, + providerSettingsManager: { + getModeConfigId: vi.fn().mockResolvedValue(undefined), + listConfig: vi.fn().mockResolvedValue([]), + }, + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 }, + diffEnabled: false, + enableCheckpoints: true, + checkpointTimeout: 60, + fuzzyMatchThreshold: 1.0, + experiments: {}, + cloudUserInfo: null, + taskSyncEnabled: false, + }), + // Methods used by createTaskWithHistoryItem for pending edit cleanup + getPendingEditOperation: vi.fn().mockReturnValue(undefined), + clearPendingEditOperation: vi.fn(), + context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, + contextProxy: { + extensionUri: {}, + getValue: vi.fn(), + setValue: vi.fn(), + setProviderSettings: vi.fn(), + getProviderSettings: vi.fn(() => ({})), + }, + postStateToWebview: vi.fn(), + } as unknown as ClineProvider + + const historyItem = { + id: "hist-1", + number: 1, + ts: Date.now(), + task: "Task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/tmp", + } + + const task = await (ClineProvider.prototype as any).createTaskWithHistoryItem.call(provider, historyItem) + expect(task).toBeTruthy() + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(addClineToStack).toHaveBeenCalledTimes(1) + }) + + it("IPC StartNewTask path closes current before new task", async () => { + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ taskId: "ipc-1" }) + const provider = { + context: {} as any, + removeClineFromStack, + postStateToWebview: vi.fn(), + postMessageToWebview: vi.fn(), + createTask, + getValues: vi.fn(() => ({})), + providerSettingsManager: { saveConfig: vi.fn() }, + on: vi.fn((ev: any, cb: any) => { + if (ev === "taskCreated") { + // no-op for this test + } + return provider + }), + } as unknown as ClineProvider + + const output = { appendLine: vi.fn() } as any + const api = new API(output, provider, undefined, false) + + const taskId = await api.startNewTask({ + configuration: {}, + text: "hello", + images: undefined, + newTab: false, + }) + + expect(taskId).toBe("ipc-1") + expect(removeClineFromStack).toHaveBeenCalledTimes(1) + expect(createTask).toHaveBeenCalled() + }) +}) diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts index b5c59119d9..4ab1a47851 100644 --- a/src/api/providers/__tests__/io-intelligence.spec.ts +++ b/src/api/providers/__tests__/io-intelligence.spec.ts @@ -259,6 +259,7 @@ describe("IOIntelligenceHandler", () => { description: "Llama 4 Maverick 17B model", supportsImages: true, supportsPromptCache: false, + supportsNativeTools: true, }) }) @@ -275,6 +276,7 @@ describe("IOIntelligenceHandler", () => { description: "Llama 4 Maverick 17B model", supportsImages: true, supportsPromptCache: false, + supportsNativeTools: true, }) }) diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 93ad828d0a..af018af995 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -3,11 +3,15 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import { TOOL_PROTOCOL } from "@roo-code/types" + import { RequestyHandler } from "../requesty" import { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" +import { ApiHandlerCreateMessageMetadata } from "../../index" const mockCreate = vitest.fn() +const mockResolveToolProtocol = vitest.fn() vitest.mock("openai", () => { return { @@ -23,6 +27,10 @@ vitest.mock("openai", () => { vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) +vitest.mock("../../../utils/resolveToolProtocol", () => ({ + resolveToolProtocol: (...args: any[]) => mockResolveToolProtocol(...args), +})) + vitest.mock("../fetchers/modelCache", () => ({ getModels: vitest.fn().mockImplementation(() => { return Promise.resolve({ @@ -200,6 +208,176 @@ describe("RequestyHandler", () => { const generator = handler.createMessage("test", []) await expect(generator.next()).rejects.toThrow("API Error") }) + + describe("native tool support", () => { + const systemPrompt = "test system prompt" + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user" as const, content: "What's the weather?" }, + ] + + const mockTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + beforeEach(() => { + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + }, + } + mockCreate.mockResolvedValue(mockStream) + }) + + it("should include tools in request when toolProtocol is native", async () => { + mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE) + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + tool_choice: "auto", + } + + const handler = new RequestyHandler(mockOptions) + const iterator = handler.createMessage(systemPrompt, messages, metadata) + await iterator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "get_weather", + description: "Get the current weather", + }), + }), + ]), + tool_choice: "auto", + }), + ) + }) + + it("should not include tools when toolProtocol is not native", async () => { + mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.XML) + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + tool_choice: "auto", + } + + const handler = new RequestyHandler(mockOptions) + const iterator = handler.createMessage(systemPrompt, messages, metadata) + await iterator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ + tools: expect.anything(), + tool_choice: expect.anything(), + }), + ) + }) + + it("should handle tool_call_partial chunks in streaming response", async () => { + mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE) + + const mockStreamWithToolCalls = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { + name: "get_weather", + arguments: '{"location":', + }, + }, + ], + }, + }, + ], + } + yield { + id: "test-id", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '"New York"}', + }, + }, + ], + }, + }, + ], + } + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20 }, + } + }, + } + mockCreate.mockResolvedValue(mockStreamWithToolCalls) + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mockTools, + } + + const handler = new RequestyHandler(mockOptions) + const chunks = [] + for await (const chunk of handler.createMessage(systemPrompt, messages, metadata)) { + chunks.push(chunk) + } + + // Expect two tool_call_partial chunks and one usage chunk + expect(chunks).toHaveLength(3) + expect(chunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: "call_123", + name: "get_weather", + arguments: '{"location":', + }) + expect(chunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"New York"}', + }) + expect(chunks[2]).toMatchObject({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + }) + }) + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts index 2a985e2a87..3dcd0821b8 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -13,6 +13,7 @@ import { convertToR1Format } from "../transform/r1-format" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" +import type { ApiHandlerCreateMessageMetadata } from "../index" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" export class FeatherlessHandler extends BaseOpenAiCompatibleProvider { @@ -49,7 +50,11 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider return models } } - // switch (provider) { - // case "zgsm": { - // const _models = await getZgsmModels( - // options.baseUrl || ZgsmAuthConfig.getInstance().getDefaultApiBaseUrl(), - // options.apiKey || clineProvider?.getValue("zgsmAccessToken"), - // options.openAiHeaders, - // ) - // models = _models.reduce((acc, model: IZgsmModelResponseData) => { - // if (!model.id) { - // return acc - // } - - // acc[model.id] = model - // return acc - // }, {} as ModelRecord) - // break - // } - // case "openrouter": - // models = await getOpenRouterModels() - // break - // case "requesty": - // // Requesty models endpoint requires an API key for per-user custom policies. - // models = await getRequestyModels(options.baseUrl, options.apiKey) - // break - // case "glama": - // models = await getGlamaModels() - // break - // case "unbound": - // // Unbound models endpoint requires an API key to fetch application specific models. - // models = await getUnboundModels(options.apiKey) - // break - // case "litellm": - // // Type safety ensures apiKey and baseUrl are always provided for LiteLLM. - // models = await getLiteLLMModels(options.apiKey, options.baseUrl) - // break - // case "ollama": - // models = await getOllamaModels(options.baseUrl, options.apiKey) - // break - // case "lmstudio": - // models = await getLMStudioModels(options.baseUrl) - // break - // case "deepinfra": - // models = await getDeepInfraModels(options.apiKey, options.baseUrl) - // break - // case "io-intelligence": - // models = await getIOIntelligenceModels(options.apiKey) - // break - // case "vercel-ai-gateway": - // models = await getVercelAiGatewayModels() - // break - // case "huggingface": - // models = await getHuggingFaceModels() - // break - // case "roo": { - // // Roo Code Cloud provider requires baseUrl and optional apiKey - // // const rooBaseUrl = - // // options.baseUrl ?? process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" - // // models = await getRooModels(rooBaseUrl, options.apiKey) - // models = {} - // break - // } - // case "chutes": - // models = await getChutesModels(options.apiKey) - // break - // default: { - // // Ensures router is exhaustively checked if RouterName is a strict union. - // const exhaustiveCheck: never = provider - // throw new Error(`Unknown provider: ${exhaustiveCheck}`) - // } - // } models = await fetchModelsFromProvider(options) const modelCount = Object.keys(models).length diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 9f30d76077..5a2b526e5f 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,9 +1,10 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types" +import { type ModelInfo, requestyDefaultModelId, requestyDefaultModelInfo, TOOL_PROTOCOL } from "@roo-code/types" import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -133,6 +134,10 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan ? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"]) : undefined + // Check if native tool protocol is enabled + const toolProtocol = resolveToolProtocol(this.options, info) + const useNativeTools = toolProtocol === TOOL_PROTOCOL.NATIVE + const completionParams: RequestyChatCompletionParamsStreaming = { messages: openAiMessages, model, @@ -143,6 +148,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan stream: true, stream_options: { include_usage: true }, requesty: { trace_id: metadata?.taskId, extra: { mode: metadata?.mode } }, + ...(useNativeTools && metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(useNativeTools && metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } let stream @@ -165,6 +172,19 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } } + // Handle native tool calls + if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + if (chunk.usage) { lastUsage = chunk.usage } diff --git a/src/api/providers/utils/response-render-config.ts b/src/api/providers/utils/response-render-config.ts index 3c4ec8d925..09cf6de96f 100644 --- a/src/api/providers/utils/response-render-config.ts +++ b/src/api/providers/utils/response-render-config.ts @@ -6,7 +6,7 @@ export const renderModes = { interval: 16, }, fast: { - limit: 2, + limit: 1, interval: 50, }, medium: { diff --git a/src/core/assistant-message/AssistantMessageParser.ts b/src/core/assistant-message/AssistantMessageParser.ts index 9a549a09d8..d7af914170 100644 --- a/src/core/assistant-message/AssistantMessageParser.ts +++ b/src/core/assistant-message/AssistantMessageParser.ts @@ -99,14 +99,14 @@ export class AssistantMessageParser { const toolUseClosingTag = `` if (currentToolValue.endsWith(toolUseClosingTag)) { // End of a tool use. - - // Special handling for attempt_completion tool without result tag - if (this.currentToolUse.name === "attempt_completion") { + this.currentToolUse.partial = false + if ( + this.currentToolUse.name === "attempt_completion" && + !this.currentToolUse?.params?.result && + currentToolValue.trim() + ) { this.currentToolUse.params.result = currentToolValueExtract(currentToolValue.trim()) } - - this.currentToolUse.partial = false - this.currentToolUse = undefined continue } else { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c33bf8a5ee..f8d781e2f6 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -520,6 +520,14 @@ export class NativeToolCallParser { } break + case "apply_patch": + if (partialArgs.patch !== undefined) { + nativeArgs = { + patch: partialArgs.patch, + } + } + break + // Add other tools as needed default: break @@ -636,6 +644,15 @@ export class NativeToolCallParser { } break + case "search_and_replace": + if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) { + nativeArgs = { + path: args.path, + operations: args.operations, + } as NativeArgsFor + } + break + case "ask_followup_question": if (args.question !== undefined && args.follow_up !== undefined) { nativeArgs = { @@ -760,6 +777,14 @@ export class NativeToolCallParser { } break + case "apply_patch": + if (args.patch !== undefined) { + nativeArgs = { + patch: args.patch, + } as NativeArgsFor + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 362c0ba458..80d0605123 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,6 +17,8 @@ import { shouldUseSingleFileRead, TOOL_PROTOCOL } from "@roo-code/types" import { writeToFileTool } from "../tools/WriteToFileTool" import { applyDiffTool } from "../tools/MultiApplyDiffTool" import { insertContentTool } from "../tools/InsertContentTool" +import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool" +import { applyPatchTool } from "../tools/ApplyPatchTool" import { listCodeDefinitionNamesTool } from "../tools/ListCodeDefinitionNamesTool" import { searchFilesTool } from "../tools/SearchFilesTool" import { browserActionTool } from "../tools/BrowserActionTool" @@ -40,6 +42,8 @@ import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { updateCospecMetadata } from "../checkpoints" import { fixBrowserLaunchAction } from "../../utils/fixbrowserLaunchAction" +import { isNativeProtocol } from "@roo-code/types" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" /** * Processes and presents assistant message content to the user interface. @@ -379,6 +383,10 @@ export async function presentAssistantMessage(cline: Task) { }]` case "insert_content": return `[${block.name} for '${block.params.path}']` + case "search_and_replace": + return `[${block.name} for '${block.params.path}']` + case "apply_patch": + return `[${block.name}]` case "list_files": return `[${block.name} for '${block.params.path}']` case "list_code_definition_names": @@ -689,8 +697,14 @@ export async function presentAssistantMessage(cline: Task) { } // Validate tool use before execution. - const { mode, customModes, terminalShellIntegrationDisabled } = - (await cline.providerRef.deref()?.getState()) ?? {} + const { + mode, + customModes, + experiments: stateExperiments, + apiConfiguration, + } = (await cline.providerRef.deref()?.getState()) ?? {} + const modelInfo = cline.api.getModel() + const includedTools = modelInfo?.info?.includedTools try { validateToolUse( @@ -699,6 +713,8 @@ export async function presentAssistantMessage(cline: Task) { customModes ?? [], { apply_diff: cline.diffEnabled }, block.params, + stateExperiments, + includedTools, ) } catch (error) { cline.consecutiveMistakeCount++ @@ -819,6 +835,26 @@ export async function presentAssistantMessage(cline: Task) { toolProtocol, }) break + case "search_and_replace": + await checkpointSaveAndMark(cline) + await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "apply_patch": + await checkpointSaveAndMark(cline) + await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break case "read_file": // Check if this model should use the simplified single-file read tool // Only use simplified tool for XML protocol - native protocol works with standard tool diff --git a/src/core/costrict/workflow/__tests__/commands.spec.ts b/src/core/costrict/workflow/__tests__/commands.spec.ts index 8d92226d88..64959df4cd 100644 --- a/src/core/costrict/workflow/__tests__/commands.spec.ts +++ b/src/core/costrict/workflow/__tests__/commands.spec.ts @@ -177,8 +177,10 @@ describe("Coworkflow Commands", () => { it("should register all coworkflow commands", () => { const disposables = registerCoworkflowCommands(mockContext) - expect(disposables).toHaveLength(7) - expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(7) + // expect(disposables).toHaveLength(7) + expect(disposables).toHaveLength(6) + // expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(7) + expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(6) // Verify all commands are registered expect(vscode.commands.registerCommand).toHaveBeenCalledWith( diff --git a/src/core/costrict/workflow/__tests__/run-test-codelens.spec.ts b/src/core/costrict/workflow/__tests__/run-test-codelens.spec.ts index d41c03e0be..45e49f063b 100644 --- a/src/core/costrict/workflow/__tests__/run-test-codelens.spec.ts +++ b/src/core/costrict/workflow/__tests__/run-test-codelens.spec.ts @@ -101,11 +101,11 @@ describe("Run test CodeLens 功能测试", () => { }) describe("CoworkflowActionType 类型定义", () => { - it("应该包含 'run_test' 动作类型", () => { - // 验证 CoworkflowActionType 包含 run_test - const actionType: CoworkflowActionType = "run_test" - expect(actionType).toBe("run_test") - }) + // it("应该包含 'run_test' 动作类型", () => { + // // 验证 CoworkflowActionType 包含 run_test + // const actionType: CoworkflowActionType = "run_test" + // expect(actionType).toBe("run_test") + // }) it("应该支持所有已知的动作类型", () => { const validActionTypes: CoworkflowActionType[] = [ @@ -114,7 +114,7 @@ describe("Run test CodeLens 功能测试", () => { "retry", "loading", "run_all", - "run_test", + // "run_test", ] validActionTypes.forEach((actionType) => { @@ -123,73 +123,73 @@ describe("Run test CodeLens 功能测试", () => { }) }) - describe("CoworkflowCodeLensProvider", () => { - it("应该为任务生成包含 'run_test' 的 CodeLens", async () => { - // 检查文档类型 - const documentType = (codeLensProvider as any).getDocumentType(mockDocument.uri) - console.log("Document type:", documentType) - expect(documentType).toBe("tasks") - - // 检查文档是否有效 - const isValid = (codeLensProvider as any).isValidDocument(mockDocument) - console.log("Is valid document:", isValid) - expect(isValid).toBe(true) - - // 检查文档内容 - const text = mockDocument.getText() - console.log("Document text:", text) - console.log("Document lines:", text.split("\n")) - - // 直接调用 provideTasksCodeLenses 方法 - const tasksCodeLenses = (codeLensProvider as any).provideTasksCodeLenses(mockDocument) - console.log("Tasks CodeLenses returned:", JSON.stringify(tasksCodeLenses, null, 2)) - console.log("Tasks CodeLenses length:", tasksCodeLenses?.length) - - const codeLenses = await codeLensProvider.provideCodeLenses(mockDocument, {} as any) - console.log("CodeLenses returned:", JSON.stringify(codeLenses, null, 2)) - console.log("CodeLenses length:", codeLenses?.length) - - // 验证返回了 CodeLens - expect(codeLenses).toBeDefined() - expect(codeLenses?.length).toBeGreaterThan(0) - - // 验证至少有一个 CodeLens 包含 run_test 动作 - const hasRunTestCodeLens = codeLenses?.some((codeLens: any) => codeLens.actionType === "run_test") - expect(hasRunTestCodeLens).toBe(true) - }) - - it("应该为第一个任务生成 'run_test' CodeLens", async () => { - const codeLenses = await codeLensProvider.provideCodeLenses(mockDocument, {} as any) - - // 找到第一个任务的 run_test CodeLens - const firstTaskRunTestCodeLens = codeLenses?.find((codeLens: any) => { - const context = codeLens.context - return context?.lineNumber === 2 && codeLens.actionType === "run_test" // 第一个任务在第3行(0-based) - }) - - expect(firstTaskRunTestCodeLens).toBeDefined() - expect((firstTaskRunTestCodeLens as any).actionType).toBe("run_test") - }) - - it("应该正确解析 'run_test' CodeLens", async () => { - const codeLenses = await codeLensProvider.provideCodeLenses(mockDocument, {} as any) - const runTestCodeLens = codeLenses?.find((codeLens: any) => codeLens.actionType === "run_test") - - if (runTestCodeLens) { - const resolvedCodeLens = await codeLensProvider.resolveCodeLens(runTestCodeLens, {} as any) - - expect(resolvedCodeLens).toBeDefined() - expect(resolvedCodeLens?.command).toBeDefined() - expect(resolvedCodeLens?.command?.title).toContain("$(beaker)") - expect(resolvedCodeLens?.command?.command).toContain("coworkflow.runTest") - } - }) - }) + // describe("CoworkflowCodeLensProvider", () => { + // it("应该为任务生成包含 'run_test' 的 CodeLens", async () => { + // // 检查文档类型 + // const documentType = (codeLensProvider as any).getDocumentType(mockDocument.uri) + // console.log("Document type:", documentType) + // expect(documentType).toBe("tasks") + + // // 检查文档是否有效 + // const isValid = (codeLensProvider as any).isValidDocument(mockDocument) + // console.log("Is valid document:", isValid) + // expect(isValid).toBe(true) + + // // 检查文档内容 + // const text = mockDocument.getText() + // console.log("Document text:", text) + // console.log("Document lines:", text.split("\n")) + + // // 直接调用 provideTasksCodeLenses 方法 + // const tasksCodeLenses = (codeLensProvider as any).provideTasksCodeLenses(mockDocument) + // console.log("Tasks CodeLenses returned:", JSON.stringify(tasksCodeLenses, null, 2)) + // console.log("Tasks CodeLenses length:", tasksCodeLenses?.length) + + // const codeLenses = await codeLensProvider.provideCodeLenses(mockDocument, {} as any) + // console.log("CodeLenses returned:", JSON.stringify(codeLenses, null, 2)) + // console.log("CodeLenses length:", codeLenses?.length) + + // // 验证返回了 CodeLens + // expect(codeLenses).toBeDefined() + // expect(codeLenses?.length).toBeGreaterThan(0) + + // // 验证至少有一个 CodeLens 包含 run_test 动作 + // const hasRunTestCodeLens = codeLenses?.some((codeLens: any) => codeLens.actionType === "run_test") + // expect(hasRunTestCodeLens).toBe(true) + // }) + + // it("应该为第一个任务生成 'run_test' CodeLens", async () => { + // const codeLenses = await codeLensProvider.provideCodeLenses(mockDocument, {} as any) + + // // 找到第一个任务的 run_test CodeLens + // const firstTaskRunTestCodeLens = codeLenses?.find((codeLens: any) => { + // const context = codeLens.context + // return context?.lineNumber === 2 && codeLens.actionType === "run_test" // 第一个任务在第3行(0-based) + // }) + + // expect(firstTaskRunTestCodeLens).toBeDefined() + // expect((firstTaskRunTestCodeLens as any).actionType).toBe("run_test") + // }) + + // it("应该正确解析 'run_test' CodeLens", async () => { + // const codeLenses = await codeLensProvider.provideCodeLenses(mockDocument, {} as any) + // const runTestCodeLens = codeLenses?.find((codeLens: any) => codeLens.actionType === "run_test") + + // if (runTestCodeLens) { + // const resolvedCodeLens = await codeLensProvider.resolveCodeLens(runTestCodeLens, {} as any) + + // expect(resolvedCodeLens).toBeDefined() + // expect(resolvedCodeLens?.command).toBeDefined() + // expect(resolvedCodeLens?.command?.title).toContain("$(beaker)") + // expect(resolvedCodeLens?.command?.command).toContain("coworkflow.runTest") + // } + // }) + // }) describe("命令注册", () => { - it("应该注册 RUN_TEST 命令", () => { - expect(COWORKFLOW_COMMANDS.RUN_TEST).toBe("coworkflow.runTest") - }) + // it("应该注册 RUN_TEST 命令", () => { + // expect(COWORKFLOW_COMMANDS.RUN_TEST).toBe("coworkflow.runTest") + // }) it("命令ID 应该是唯一的", () => { const commandValues = Object.values(COWORKFLOW_COMMANDS) @@ -199,23 +199,23 @@ describe("Run test CodeLens 功能测试", () => { }) }) - describe("CodeLens 命令处理", () => { - it("应该为 'run_test' 动作返回正确的命令ID", () => { - // 使用反射访问私有方法进行测试 - const getCommandId = (codeLensProvider as any).getCommandId.bind(codeLensProvider) + // describe("CodeLens 命令处理", () => { + // it("应该为 'run_test' 动作返回正确的命令ID", () => { + // // 使用反射访问私有方法进行测试 + // const getCommandId = (codeLensProvider as any).getCommandId.bind(codeLensProvider) - const commandId = getCommandId("run_test" as CoworkflowActionType) - expect(commandId).toContain("coworkflow.runTest") - }) + // const commandId = getCommandId("run_test" as CoworkflowActionType) + // expect(commandId).toContain("coworkflow.runTest") + // }) - it("应该为 'run_test' 动作返回正确的标题", () => { - // 使用反射访问私有方法进行测试 - const getActionTitle = (codeLensProvider as any).getActionTitle.bind(codeLensProvider) + // it("应该为 'run_test' 动作返回正确的标题", () => { + // // 使用反射访问私有方法进行测试 + // const getActionTitle = (codeLensProvider as any).getActionTitle.bind(codeLensProvider) - const title = getActionTitle("run_test" as CoworkflowActionType) - expect(title).toContain("$(beaker)") - }) - }) + // const title = getActionTitle("run_test" as CoworkflowActionType) + // expect(title).toContain("$(beaker)") + // }) + // }) describe("错误处理", () => { it("应该处理无效的文档类型", async () => { diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 30a4dc358c..f2411edf7d 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -172,6 +172,8 @@ describe("getEnvironmentDetails", () => { false, mockCline.rooIgnoreController, false, + undefined, + {}, ) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 068653e7be..0a699ed58e 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -9,6 +9,7 @@ import delay from "delay" import type { ExperimentId } from "@roo-code/types" import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, MAX_WORKSPACE_FILES } from "@roo-code/types" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { defaultModeSlug, getFullModeDetails } from "../../shared/modes" @@ -278,11 +279,15 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo details += `\n\n## Shell Support Syntax\n${formatUnsupport(features)}` } } + // Resolve and add tool protocol information + const modelInfo = cline.api.getModel().info + const toolProtocol = resolveToolProtocol(state?.apiConfiguration ?? {}, modelInfo) details += `\n\n# Current Mode\n` details += `${currentMode}\n` details += `${modeDetails.name}\n` details += `${modelId}\n` + details += `${toolProtocol}\n` if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) { details += `${modeDetails.roleDefinition}\n` @@ -320,13 +325,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo details += `\n# Browser Session Status\nActive - A browser session is currently open and ready for browser_action commands${viewportInfo}\n` } - - if ( - includeFileDetails || - (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.ALWAYS_INCLUDE_FILE_DETAILS) ?? - apiConfiguration?.apiProvider === "zgsm") - ) { - details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n` + const alwaysIncludeFileDetails = + Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.ALWAYS_INCLUDE_FILE_DETAILS) ?? + apiConfiguration?.apiProvider === "zgsm" + if (includeFileDetails || alwaysIncludeFileDetails) { + details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files${alwaysIncludeFileDetails ? " (Directory Tree KPT Format: Use 1 to represent files and objects to represent directories)" : ""}\n` const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop")) if (isDesktop) { @@ -340,7 +343,12 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo if (maxFiles === 0) { details += "(Workspace files context disabled. Use list_files to explore if needed.)" } else { - const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) + const [files, didHitLimit] = await listFiles( + cline.cwd, + true, + (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.ALWAYS_INCLUDE_FILE_DETAILS) ? 4 : 1) * + maxFiles, + ) const { showRooIgnoredFiles = false } = state ?? {} const result = formatResponse.formatFilesList( @@ -349,6 +357,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo didHitLimit, cline.rooIgnoreController, showRooIgnoredFiles, + undefined, + experiments, ) details += result diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index c207673d55..52ef4bdc28 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -5,6 +5,7 @@ import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreContro import { RooProtectedController } from "../protect/RooProtectedController" import * as vscode from "vscode" import { ToolProtocol, isNativeProtocol, TOOL_PROTOCOL } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments" export const formatResponse = { toolDenied: (protocol?: ToolProtocol) => { @@ -203,6 +204,7 @@ Otherwise, if you have not completed the task and do not need additional informa rooIgnoreController: RooIgnoreController | undefined, showRooIgnoredFiles: boolean, rooProtectedController?: RooProtectedController, + experiments?: any, ): string => { const sorted = files .map((file) => { @@ -261,14 +263,16 @@ Otherwise, if you have not completed the task and do not need additional informa } } } + const _filesInfo = Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.ALWAYS_INCLUDE_FILE_DETAILS) + ? `${pathsToTree(rooIgnoreParsed)}\n` + : rooIgnoreParsed.join("\n") + // pathsToTree if (didHitLimit) { - return `${rooIgnoreParsed.join( - "\n", - )}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)` + return `${_filesInfo}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)` } else if (rooIgnoreParsed.length === 0 || (rooIgnoreParsed.length === 1 && rooIgnoreParsed[0] === "")) { return "No files found." } else { - return rooIgnoreParsed.join("\n") + return _filesInfo } }, @@ -334,3 +338,26 @@ function getToolInstructionsReminder(protocol?: ToolProtocol): string { const effectiveProtocol = protocol ?? TOOL_PROTOCOL.XML return isNativeProtocol(effectiveProtocol) ? toolUseInstructionsReminderNative : toolUseInstructionsReminder } + +export function pathsToTree(paths: string[]) { + const root: any = {} + + for (const p of paths) { + const parts = p.split("/") + let node = root + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + + if (i === parts.length - 1 && part) { + // 文件 + node[part] = 1 + } else if (part) { + // 目录 + node = node[part] ??= {} + } + } + } + + return JSON.stringify(root) +} diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index 0eb7d506e8..60aaf14b21 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "vitest" +import { describe, it, expect, beforeEach, afterEach } from "vitest" import type OpenAI from "openai" -import type { ModeConfig } from "@roo-code/types" -import { filterNativeToolsForMode, filterMcpToolsForMode } from "../filter-tools-for-mode" +import type { ModeConfig, ModelInfo } from "@roo-code/types" +import { filterNativeToolsForMode, filterMcpToolsForMode, applyModelToolCustomization } from "../filter-tools-for-mode" +import * as toolsModule from "../../../../shared/tools" describe("filterNativeToolsForMode", () => { const mockNativeTools: OpenAI.Chat.ChatCompletionTool[] = [ @@ -467,4 +468,360 @@ describe("filterMcpToolsForMode", () => { // Should include MCP tools since default mode has mcp group expect(filtered.length).toBeGreaterThan(0) }) + + describe("applyModelToolCustomization", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const architectMode: ModeConfig = { + slug: "architect", + name: "Architect", + roleDefinition: "Test", + groups: ["read", "browser", "mcp"] as const, + } + + it("should return original tools when modelInfo is undefined", () => { + const tools = new Set(["read_file", "write_to_file", "apply_diff"]) + const result = applyModelToolCustomization(tools, codeMode, undefined) + expect(result).toEqual(tools) + }) + + it("should exclude tools specified in excludedTools", () => { + const tools = new Set(["read_file", "write_to_file", "apply_diff"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["apply_diff"], + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.has("apply_diff")).toBe(false) + }) + + it("should exclude multiple tools", () => { + const tools = new Set(["read_file", "write_to_file", "apply_diff", "execute_command"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["apply_diff", "write_to_file"], + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("execute_command")).toBe(true) + expect(result.has("write_to_file")).toBe(false) + expect(result.has("apply_diff")).toBe(false) + }) + + it("should include tools only if they belong to allowed groups", () => { + const tools = new Set(["read_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["write_to_file", "apply_diff"], // Both in edit group + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.has("apply_diff")).toBe(true) + }) + + it("should NOT include tools from groups not allowed by mode", () => { + const tools = new Set(["read_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["write_to_file", "apply_diff"], // Edit group tools + } + // Architect mode doesn't have edit group + const result = applyModelToolCustomization(tools, architectMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(false) // Not in allowed groups + expect(result.has("apply_diff")).toBe(false) // Not in allowed groups + }) + + it("should apply both exclude and include operations", () => { + const tools = new Set(["read_file", "write_to_file", "apply_diff"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["apply_diff"], + includedTools: ["insert_content"], // Another edit tool + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.has("apply_diff")).toBe(false) // Excluded + expect(result.has("insert_content")).toBe(true) // Included + }) + + it("should handle empty excludedTools and includedTools arrays", () => { + const tools = new Set(["read_file", "write_to_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: [], + includedTools: [], + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result).toEqual(tools) + }) + + it("should ignore excluded tools that are not in the original set", () => { + const tools = new Set(["read_file", "write_to_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["apply_diff", "nonexistent_tool"], + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.size).toBe(2) + }) + + it("should NOT include customTools by default", () => { + const tools = new Set(["read_file", "write_to_file"]) + // Assume 'edit' group has a customTool defined in TOOL_GROUPS + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + // No includedTools specified + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + // customTools should not be in the result unless explicitly included + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + }) + + it("should NOT include tools that are not in any TOOL_GROUPS", () => { + const tools = new Set(["read_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["my_custom_tool"], // Not in any tool group + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("my_custom_tool")).toBe(false) + }) + + it("should NOT include undefined tools even with allowed groups", () => { + const tools = new Set(["read_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["custom_edit_tool"], // Not in any tool group + } + // Even though architect mode has read group, undefined tools are not added + const result = applyModelToolCustomization(tools, architectMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("custom_edit_tool")).toBe(false) + }) + + describe("with customTools defined in TOOL_GROUPS", () => { + const originalToolGroups = { ...toolsModule.TOOL_GROUPS } + + beforeEach(() => { + // Add a customTool to the edit group + ;(toolsModule.TOOL_GROUPS as any).edit = { + ...originalToolGroups.edit, + customTools: ["special_edit_tool"], + } + }) + + afterEach(() => { + // Restore original TOOL_GROUPS + ;(toolsModule.TOOL_GROUPS as any).edit = originalToolGroups.edit + }) + + it("should include customTools when explicitly specified in includedTools", () => { + const tools = new Set(["read_file", "write_to_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["special_edit_tool"], // customTool from edit group + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.has("special_edit_tool")).toBe(true) // customTool should be included + }) + + it("should NOT include customTools when not specified in includedTools", () => { + const tools = new Set(["read_file", "write_to_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + // No includedTools specified + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.has("special_edit_tool")).toBe(false) // customTool should NOT be included by default + }) + + it("should NOT include customTools from groups not allowed by mode", () => { + const tools = new Set(["read_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["special_edit_tool"], // customTool from edit group + } + // Architect mode doesn't have edit group + const result = applyModelToolCustomization(tools, architectMode, modelInfo) + expect(result.has("read_file")).toBe(true) + expect(result.has("special_edit_tool")).toBe(false) // customTool should NOT be included + }) + }) + }) + + describe("filterNativeToolsForMode with model customization", () => { + const mockNativeTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read files", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "write_to_file", + description: "Write files", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "apply_diff", + description: "Apply diff", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "insert_content", + description: "Insert content", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "execute_command", + description: "Execute command", + parameters: {}, + }, + }, + ] + + it("should exclude tools when model specifies excludedTools", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["apply_diff"], + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, { + modelInfo, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + expect(toolNames).toContain("read_file") + expect(toolNames).toContain("write_to_file") + expect(toolNames).toContain("insert_content") + expect(toolNames).not.toContain("apply_diff") // Excluded by model + }) + + it("should include tools when model specifies includedTools from allowed groups", () => { + const modeWithOnlyRead: ModeConfig = { + slug: "limited", + name: "Limited", + roleDefinition: "Test", + groups: ["read", "edit"] as const, + } + + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["insert_content"], // Edit group tool + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "limited", [modeWithOnlyRead], {}, undefined, { + modelInfo, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + expect(toolNames).toContain("insert_content") // Included by model + }) + + it("should NOT include tools from groups not allowed by mode", () => { + const architectMode: ModeConfig = { + slug: "architect", + name: "Architect", + roleDefinition: "Test", + groups: ["read", "browser"] as const, // No edit group + } + + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["write_to_file", "apply_diff"], // Edit group tools + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "architect", [architectMode], {}, undefined, { + modelInfo, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + expect(toolNames).toContain("read_file") + expect(toolNames).not.toContain("write_to_file") // Not in mode's allowed groups + expect(toolNames).not.toContain("apply_diff") // Not in mode's allowed groups + }) + + it("should combine excludedTools and includedTools", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["apply_diff"], + includedTools: ["insert_content"], + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, { + modelInfo, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + expect(toolNames).toContain("write_to_file") + expect(toolNames).toContain("insert_content") // Included + expect(toolNames).not.toContain("apply_diff") // Excluded + }) + }) }) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index c386240ddd..3eec44c643 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,5 +1,5 @@ import type OpenAI from "openai" -import type { ModeConfig, ToolName, ToolGroup } from "@roo-code/types" +import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" import { getModeBySlug, getToolsForMode, isToolAllowedForMode } from "../../../shared/modes" import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" @@ -7,7 +7,72 @@ import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" /** - * Filters native tools based on mode restrictions. + * Apply model-specific tool customization to a set of allowed tools. + * + * This function filters tools based on model configuration: + * 1. Removes tools specified in modelInfo.excludedTools + * 2. Adds tools from modelInfo.includedTools (only if they belong to allowed groups) + * + * @param allowedTools - Set of tools already allowed by mode configuration + * @param modeConfig - Current mode configuration to check tool groups + * @param modelInfo - Model configuration with tool customization + * @returns Modified set of tools after applying model customization + */ +export function applyModelToolCustomization( + allowedTools: Set, + modeConfig: ModeConfig, + modelInfo?: ModelInfo, +): Set { + if (!modelInfo) { + return allowedTools + } + + const result = new Set(allowedTools) + + // Apply excluded tools (remove from allowed set) + if (modelInfo.excludedTools && modelInfo.excludedTools.length > 0) { + modelInfo.excludedTools.forEach((tool) => { + result.delete(tool) + }) + } + + // Apply included tools (add to allowed set, but only if they belong to an allowed group) + if (modelInfo.includedTools && modelInfo.includedTools.length > 0) { + // Build a map of tool -> group for all tools in TOOL_GROUPS (including customTools) + const toolToGroup = new Map() + for (const [groupName, groupConfig] of Object.entries(TOOL_GROUPS)) { + // Add regular tools + groupConfig.tools.forEach((tool) => { + toolToGroup.set(tool, groupName as ToolGroup) + }) + // Add customTools (opt-in only tools) + if (groupConfig.customTools) { + groupConfig.customTools.forEach((tool) => { + toolToGroup.set(tool, groupName as ToolGroup) + }) + } + } + + // Get the list of allowed groups for this mode + const allowedGroups = new Set( + modeConfig.groups.map((groupEntry) => (Array.isArray(groupEntry) ? groupEntry[0] : groupEntry)), + ) + + // Add included tools only if they belong to an allowed group + // This includes both regular tools and customTools + modelInfo.includedTools.forEach((tool) => { + const toolGroup = toolToGroup.get(tool) + if (toolGroup && allowedGroups.has(toolGroup)) { + result.add(tool) + } + }) + } + + return result +} + +/** + * Filters native tools based on mode restrictions and model customization. * This ensures native tools are filtered the same way XML tools are filtered in the system prompt. * * @param nativeTools - Array of all available native tools @@ -15,7 +80,7 @@ import type { McpHub } from "../../../services/mcp/McpHub" * @param customModes - Custom mode configurations * @param experiments - Experiment flags * @param codeIndexManager - Code index manager for codebase_search feature check - * @param settings - Additional settings for tool filtering + * @param settings - Additional settings for tool filtering (includes modelInfo for model-specific customization) * @param mcpHub - MCP hub for checking available resources * @returns Filtered array of tools allowed for the mode */ @@ -43,7 +108,7 @@ export function filterNativeToolsForMode( const allToolsForMode = getToolsForMode(modeConfig.groups) // Filter to only tools that pass permission checks - const allowedToolNames = new Set( + let allowedToolNames = new Set( allToolsForMode.filter((tool) => isToolAllowedForMode( tool as ToolName, @@ -56,6 +121,10 @@ export function filterNativeToolsForMode( ), ) + // Apply model-specific tool customization + const modelInfo = settings?.modelInfo as ModelInfo | undefined + allowedToolNames = applyModelToolCustomization(allowedToolNames, modeConfig, modelInfo) + // Conditionally exclude codebase_search if feature is disabled or not configured if ( !codeIndexManager || diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/apply_diff.ts index faa65291bf..3938e4886a 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/apply_diff.ts @@ -11,7 +11,7 @@ const DIFF_PARAMETER_DESCRIPTION = `A string containing one or more search/repla [new content to replace with] >>>>>>> REPLACE` -export const apply_diff_single_file = { +export const apply_diff = { type: "function", function: { name: "apply_diff", diff --git a/src/core/prompts/tools/native-tools/apply_patch.ts b/src/core/prompts/tools/native-tools/apply_patch.ts new file mode 100644 index 0000000000..47ba60400d --- /dev/null +++ b/src/core/prompts/tools/native-tools/apply_patch.ts @@ -0,0 +1,61 @@ +import type OpenAI from "openai" + +const apply_patch_DESCRIPTION = `Apply patches to files using a stripped-down, file-oriented diff format. This tool supports creating new files, deleting files, and updating existing files with precise changes. + +The patch format uses a simple, human-readable structure: + +*** Begin Patch +[ one or more file sections ] +*** End Patch + +Each file section starts with one of three headers: +- *** Add File: - Create a new file. Every following line is a + line (the initial contents). +- *** Delete File: - Remove an existing file. Nothing follows. +- *** Update File: - Patch an existing file in place. + +For Update File operations: +- May be immediately followed by *** Move to: if you want to rename the file. +- Then one or more "hunks", each introduced by @@ (optionally followed by context like a class or function name). +- Within a hunk each line starts with: + - ' ' (space) for context lines (unchanged) + - '-' for lines to remove + - '+' for lines to add + +Context guidelines: +- Show 3 lines of code above and below each change. +- Use @@ with a class/function name if 3 lines of context is insufficient to uniquely identify the location. +- Multiple @@ statements can be used for deeply nested code. + +Example patch: +*** Begin Patch +*** Add File: hello.txt ++Hello world +*** Update File: src/app.py +*** Move to: src/main.py +@@ def greet(): +-print("Hi") ++print("Hello, world!") +*** Delete File: obsolete.txt +*** End Patch` + +const apply_patch = { + type: "function", + function: { + name: "apply_patch", + description: apply_patch_DESCRIPTION, + parameters: { + type: "object", + properties: { + patch: { + type: "string", + description: + "The complete patch text in the apply_patch format, starting with '*** Begin Patch' and ending with '*** End Patch'.", + }, + }, + required: ["patch"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool + +export default apply_patch diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index bb0d50da88..1ffd9b8c93 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -1,5 +1,7 @@ import type OpenAI from "openai" import accessMcpResource from "./access_mcp_resource" +import { apply_diff } from "./apply_diff" +import applyPatch from "./apply_patch" import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" @@ -13,11 +15,11 @@ import listFiles from "./list_files" import newTask from "./new_task" import { createReadFileTool } from "./read_file" import runSlashCommand from "./run_slash_command" +import searchAndReplace from "./search_and_replace" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" -import { apply_diff_single_file } from "./apply_diff" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -31,7 +33,8 @@ export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./c export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] { return [ accessMcpResource, - apply_diff_single_file, + apply_diff, + applyPatch, askFollowupQuestion, attemptCompletion, browserAction, @@ -45,6 +48,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat newTask, createReadFileTool(partialReadsEnabled), runSlashCommand, + searchAndReplace, searchFiles, switchMode, updateTodoList, diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts new file mode 100644 index 0000000000..ce785b6a16 --- /dev/null +++ b/src/core/prompts/tools/native-tools/search_and_replace.ts @@ -0,0 +1,44 @@ +import type OpenAI from "openai" + +const SEARCH_AND_REPLACE_DESCRIPTION = `Apply precise, targeted modifications to an existing file using search and replace operations. This tool is for surgical edits only; provide an array of operations where each operation specifies the exact text to search for and what to replace it with. The search text must exactly match the existing content, including whitespace and indentation.` + +const search_and_replace = { + type: "function", + function: { + name: "search_and_replace", + description: SEARCH_AND_REPLACE_DESCRIPTION, + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The path of the file to modify, relative to the current workspace directory.", + }, + operations: { + type: "array", + description: "Array of search and replace operations to perform on the file.", + items: { + type: "object", + properties: { + search: { + type: "string", + description: + "The exact text to find in the file. Must match exactly, including whitespace.", + }, + replace: { + type: "string", + description: "The text to replace the search text with.", + }, + }, + required: ["search", "replace"], + }, + minItems: 1, + }, + }, + required: ["path", "operations"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool + +export default search_and_replace diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index f6b9575be3..25a548b6e2 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -21,6 +21,8 @@ export type TaskMetadataOptions = { globalStoragePath: string workspace: string mode?: string + /** Initial status for the task (e.g., "active" for child tasks) */ + initialStatus?: "active" | "delegated" | "completed" } export async function taskMetadata({ @@ -32,6 +34,7 @@ export async function taskMetadata({ globalStoragePath, workspace, mode, + initialStatus, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -84,6 +87,9 @@ export async function taskMetadata({ } // Create historyItem once with pre-calculated values. + // initialStatus is included when provided (e.g., "active" for child tasks) + // to ensure the status is set from the very first save, avoiding race conditions + // where attempt_completion might run before a separate status update. const historyItem: HistoryItem = { id, rootTaskId, @@ -101,6 +107,7 @@ export async function taskMetadata({ size: taskDirSize, workspace, mode, + ...(initialStatus && { status: initialStatus }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cc6852e609..5d4ab1e4b5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -156,6 +156,8 @@ export interface TaskOptions extends CreateTaskOptions { onCreated?: (task: Task) => void initialTodos?: TodoItem[] workspacePath?: string + /** Initial status for the task's history item (e.g., "active" for child tasks) */ + initialStatus?: "active" | "delegated" | "completed" } export class Task extends EventEmitter implements TaskLike { @@ -227,6 +229,7 @@ export class Task extends EventEmitter implements TaskLike { private readonly globalStoragePath: string abort: boolean = false currentRequestAbortController?: AbortController + skipPrevResponseIdOnce: boolean = false // TaskStatus idleAsk?: ClineMessage @@ -238,8 +241,6 @@ export class Task extends EventEmitter implements TaskLike { abortReason?: ClineApiReqCancelReason isInitialized = false isPaused: boolean = false - pausedModeSlug: string = defaultModeSlug - private pauseInterval: NodeJS.Timeout | undefined // API api: ApiHandler & { @@ -338,6 +339,9 @@ export class Task extends EventEmitter implements TaskLike { // Cloud Sync Tracking private cloudSyncedMessageTimestamps: Set = new Set() + // Initial status for the task's history item (set at creation time to avoid race conditions) + private readonly initialStatus?: "active" | "delegated" | "completed" + constructor({ provider, apiConfiguration, @@ -359,6 +363,7 @@ export class Task extends EventEmitter implements TaskLike { initialTodos, workspacePath, zgsmWorkflowMode, + initialStatus, }: TaskOptions) { super() @@ -446,6 +451,7 @@ export class Task extends EventEmitter implements TaskLike { this.parentTask = parentTask this.taskNumber = taskNumber + this.initialStatus = initialStatus // Store the task's mode when it's created. // For history items, use the stored mode; for new tasks, we'll set it @@ -826,16 +832,16 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() - // const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + // const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() - // if (shouldCaptureMessage) { - // CloudService.instance.captureEvent({ - // event: TelemetryEventName.TASK_MESSAGE, - // properties: { taskId: this.taskId, message }, - // }) - // // Track that this message has been synced to cloud - // this.cloudSyncedMessageTimestamps.add(message.ts) - // } + // if (shouldCaptureMessage) { + // CloudService.instance.captureEvent({ + // event: TelemetryEventName.TASK_MESSAGE, + // properties: { taskId: this.taskId, message }, + // }) + // // Track that this message has been synced to cloud + // this.cloudSyncedMessageTimestamps.add(message.ts) + // } } public async overwriteClineMessages(newMessages: ClineMessage[]) { @@ -889,6 +895,7 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. + initialStatus: this.initialStatus, }) if (hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) { @@ -1594,7 +1601,14 @@ export class Task extends EventEmitter implements TaskLike { text: `\n${task}\n`, }, ...imageBlocks, - ]) + ]).catch((error) => { + // Swallow loop rejection when the task was intentionally abandoned/aborted + // during delegation or user cancellation to prevent unhandled rejections. + if (this.abandoned === true || this.abortReason === "user_cancelled") { + return + } + throw error + }) } private async resumeTaskFromHistory() { @@ -1923,12 +1937,6 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error removing event listeners:", error) } - // Stop waiting for child task completion. - if (this.pauseInterval) { - clearInterval(this.pauseInterval) - this.pauseInterval = undefined - } - // if (this.enableBridge) { // BridgeOrchestrator.getInstance() // ?.unsubscribeFromTask(this.taskId) @@ -2005,88 +2013,87 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Provider not available") } - const newTask = await provider.createTask(message, undefined, this, { initialTodos }) - - if (newTask) { - this.isPaused = true // Pause parent. - this.childTaskId = newTask.taskId - - await provider.handleModeSwitch(mode) // Set child's mode. - await delay(500) // Allow mode change to take effect. - - this.emit(RooCodeEventName.TaskPaused, this.taskId) - this.emit(RooCodeEventName.TaskSpawned, newTask.taskId) - } - - return newTask - } - - // Used when a sub-task is launched and the parent task is waiting for it to - // finish. - // TBD: Add a timeout to prevent infinite waiting. - public async waitForSubtask() { - await new Promise((resolve) => { - this.pauseInterval = setInterval(() => { - if (!this.isPaused) { - clearInterval(this.pauseInterval) - this.pauseInterval = undefined - resolve() - } - }, 1000) + const child = await (provider as any).delegateParentAndOpenChild({ + parentTaskId: this.taskId, + message, + initialTodos, + mode, }) + return child } - public async completeSubtask(lastMessage: string, subtaskId?: string) { - this.isPaused = false - this.childTaskId = undefined - - this.emit(RooCodeEventName.TaskUnpaused, this.taskId) + /** + * Resume parent task after delegation completion without showing resume ask. + * Used in metadata-driven subtask flow. + * + * This method: + * - Clears any pending ask states + * - Resets abort and streaming flags + * - Ensures next API call includes full context + * - Immediately continues task loop without user interaction + */ + public async resumeAfterDelegation(): Promise { + // Clear any ask states that might have been set during history load + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined + + // Reset abort and streaming state to ensure clean continuation + this.abort = false + this.abandoned = false + this.abortReason = undefined + this.didFinishAbortingStream = false + this.isStreaming = false + this.isWaitingForFirstChunk = false + + // Ensure next API call includes full context after delegation + this.skipPrevResponseIdOnce = true + + // Mark as initialized and active + this.isInitialized = true + this.emit(RooCodeEventName.TaskActive, this.taskId) - // Fake an answer from the subtask that it has completed running and - // this is the result of what it has done add the message to the chat - // history and to the webview ui. - try { - await this.say( - "subtask_result", - lastMessage, - undefined, - undefined, - undefined, - undefined, - { subtaskId }, - undefined, - ) + // Load conversation history if not already loaded + if (this.apiConversationHistory.length === 0) { + this.apiConversationHistory = await this.getSavedApiConversationHistory() + } - // Check if using native protocol to determine how to add the subtask result - const modelInfo = this.api.getModel().info - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - - if (toolProtocol === "native" && this.pendingNewTaskToolCallId) { - // For native protocol, push the actual tool_result with the subtask's real result. - // NewTaskTool deferred pushing the tool_result until now so that the parent task - // gets useful information about what the subtask actually accomplished. - this.userMessageContent.push({ - type: "tool_result", - tool_use_id: this.pendingNewTaskToolCallId, - content: `[new_task completed] Result: ${lastMessage}`, - } as Anthropic.ToolResultBlockParam) - - // Clear the pending tool call ID - this.pendingNewTaskToolCallId = undefined - } else { - // For XML protocol (or if no pending tool call ID), add as a separate user message - await this.addToApiConversationHistory({ - role: "user", - content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }], - }) + // Add environment details to the existing last user message (which contains the tool_result) + // This avoids creating a new user message which would cause consecutive user messages + const environmentDetails = await getEnvironmentDetails(this, true) + let lastUserMsgIndex = -1 + for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { + if (this.apiConversationHistory[i].role === "user") { + lastUserMsgIndex = i + break + } + } + if (lastUserMsgIndex >= 0) { + const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] + if (Array.isArray(lastUserMsg.content)) { + // Remove any existing environment_details blocks before adding fresh ones + const contentWithoutEnvDetails = lastUserMsg.content.filter( + (block: Anthropic.Messages.ContentBlockParam) => { + if (block.type === "text" && typeof block.text === "string") { + const isEnvironmentDetailsBlock = + block.text.trim().startsWith("") && + block.text.trim().endsWith("") + return !isEnvironmentDetailsBlock + } + return true + }, + ) + // Add fresh environment details + lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] } - } catch (error) { - this.providerRef - .deref() - ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) - - throw error } + + // Save the updated history + await this.saveApiConversationHistory() + + // Continue task loop - pass empty array to signal no new user content needed + // The initiateTaskLoop will handle this by skipping user message addition + await this.initiateTaskLoop([]) } // Task Loop @@ -2174,37 +2181,6 @@ export class Task extends EventEmitter implements TaskLike { this.consecutiveMistakeCount = 0 } - // In this Cline request loop, we need to check if this task instance - // has been asked to wait for a subtask to finish before continuing. - const provider = this.providerRef.deref() - - if (this.isPaused && provider) { - provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`) - await this.waitForSubtask() - provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`) - - // After subtask completes, completeSubtask has pushed content to userMessageContent. - // Copy it to currentUserContent so it gets sent to the API in this iteration. - if (this.userMessageContent.length > 0) { - currentUserContent.push(...this.userMessageContent) - this.userMessageContent = [] - } - - const currentMode = (await provider.getState())?.mode ?? defaultModeSlug - - if (currentMode !== this.pausedModeSlug) { - // The mode has changed, we need to switch back to the paused mode. - await provider.handleModeSwitch(this.pausedModeSlug) - - // Delay to allow mode change to take effect before next tool is executed. - await delay(500) - - provider.log( - `[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`, - ) - } - } - // Getting verbose details is an expensive operation, it uses ripgrep to // top-down build file structure of project which for large projects can // take a few seconds. For the best UX we show a placeholder api_req_started @@ -2274,10 +2250,15 @@ export class Task extends EventEmitter implements TaskLike { const finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] // Only add user message to conversation history if: - // 1. This is the first attempt (retryAttempt === 0), OR - // 2. The message was removed in a previous iteration (userMessageWasRemoved === true) + // 1. This is the first attempt (retryAttempt === 0), AND + // 2. The original userContent was not empty (empty signals delegation resume where + // the user message with tool_result and env details is already in history), OR + // 3. The message was removed in a previous iteration (userMessageWasRemoved === true) // This prevents consecutive user messages while allowing re-add when needed - if ((currentItem.retryAttempt ?? 0) === 0 || currentItem.userMessageWasRemoved) { + const isEmptyUserContent = currentUserContent.length === 0 + const shouldAddUserMessage = + ((currentItem.retryAttempt ?? 0) === 0 && !isEmptyUserContent) || currentItem.userMessageWasRemoved + if (shouldAddUserMessage) { await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) TelemetryService.instance.captureConversationMessage(this.taskId, "user") } @@ -2300,7 +2281,7 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await provider?.postStateToWebview() + await this.providerRef.deref()?.postStateToWebview() try { let cacheWriteTokens = 0 @@ -2383,8 +2364,6 @@ export class Task extends EventEmitter implements TaskLike { // Signals to provider that it can retrieve the saved messages // from disk, as abortTask can not be awaited on in nature. this.didFinishAbortingStream = true - - this?.api?.cancelChat?.(cancelReason) } // Reset streaming state for each new API request @@ -3619,6 +3598,7 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, maxReadFileLine: state?.maxReadFileLine ?? -1, browserToolEnabled: state?.browserToolEnabled ?? true, + modelInfo, }) } @@ -3637,6 +3617,7 @@ export class Task extends EventEmitter implements TaskLike { rooTaskMode: this?.rootTask?._taskMode, parentTaskMode: this?.parentTask?._taskMode, taskId: this.taskId, + suppressPreviousResponseId: this.skipPrevResponseIdOnce, language: state?.language, instanceId: this.instanceId, userId: id, @@ -3649,6 +3630,8 @@ export class Task extends EventEmitter implements TaskLike { // Create an AbortController to allow cancelling the request mid-stream this.currentRequestAbortController = new AbortController() const abortSignal = this.currentRequestAbortController.signal + // Reset the flag after using it + this.skipPrevResponseIdOnce = false // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. const stream = this.api.createMessage( @@ -3788,7 +3771,7 @@ export class Task extends EventEmitter implements TaskLike { private async backoffAndAnnounce(retryAttempt: number, error: any, header?: string): Promise { try { const state = await this.providerRef.deref()?.getState() - const baseDelay = state?.requestDelaySeconds || 3 + const baseDelay = state?.requestDelaySeconds || 5 let exponentialDelay = Math.min( Math.ceil(baseDelay * Math.pow(2, retryAttempt)), diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index d54387fbf4..64b9289b96 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1981,153 +1981,4 @@ describe("Queued message processing after condense", () => { expect(spyB).toHaveBeenCalledWith("B message", undefined) expect(taskB.messageQueueService.isEmpty()).toBe(true) }) - - describe("completeSubtask native protocol handling", () => { - let mockProvider: any - let mockApiConfig: any - - beforeEach(() => { - vi.clearAllMocks() - - if (!TelemetryService.hasInstance()) { - TelemetryService.createInstance([]) - } - - mockApiConfig = { - apiProvider: "anthropic", - apiKey: "test-key", - } - - mockProvider = { - context: { - globalStorageUri: { fsPath: "/test/storage" }, - }, - getState: vi.fn().mockResolvedValue({ - apiConfiguration: mockApiConfig, - }), - say: vi.fn(), - postStateToWebview: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - updateTaskHistory: vi.fn().mockResolvedValue(undefined), - log: vi.fn(), - } - }) - - it("should push tool_result to userMessageContent for native protocol with pending tool call ID", async () => { - // Create a task with a model that supports native tools - const task = new Task({ - provider: mockProvider, - apiConfiguration: { - ...mockApiConfig, - apiProvider: "anthropic", - toolProtocol: "native", // Explicitly set native protocol - }, - task: "parent task", - startTask: false, - }) - - // Mock the API to return a native protocol model - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "claude-3-5-sonnet-20241022", - info: { - contextWindow: 200000, - maxTokens: 8192, - supportsPromptCache: true, - supportsNativeTools: true, - defaultToolProtocol: "native", - } as ModelInfo, - }) - - // For native protocol, NewTaskTool does NOT push tool_result immediately. - // It only sets the pending tool call ID. The actual tool_result is pushed by completeSubtask. - task.pendingNewTaskToolCallId = "test-tool-call-id" - - // Call completeSubtask - await task.completeSubtask("Subtask completed successfully") - - // For native protocol, should push the actual tool_result with the subtask's result - expect(task.userMessageContent).toHaveLength(1) - expect(task.userMessageContent[0]).toEqual({ - type: "tool_result", - tool_use_id: "test-tool-call-id", - content: "[new_task completed] Result: Subtask completed successfully", - }) - - // Should NOT have added a user message to apiConversationHistory - expect(task.apiConversationHistory).toHaveLength(0) - - // pending tool call ID should be cleared - expect(task.pendingNewTaskToolCallId).toBeUndefined() - }) - - it("should add user message to apiConversationHistory for XML protocol", async () => { - // Create a task with a model that doesn't support native tools - const task = new Task({ - provider: mockProvider, - apiConfiguration: { - ...mockApiConfig, - apiProvider: "anthropic", - }, - task: "parent task", - startTask: false, - }) - - // Mock the API to return an XML protocol model (no native tool support) - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "claude-2", - info: { - contextWindow: 100000, - maxTokens: 4096, - supportsPromptCache: false, - supportsNativeTools: false, - } as ModelInfo, - }) - - // Call completeSubtask - await task.completeSubtask("Subtask completed successfully") - - // For XML protocol, should add to apiConversationHistory - expect(task.apiConversationHistory).toHaveLength(1) - expect(task.apiConversationHistory[0]).toEqual( - expect.objectContaining({ - role: "user", - content: [{ type: "text", text: "[new_task completed] Result: Subtask completed successfully" }], - }), - ) - - // Should NOT have added to userMessageContent - expect(task.userMessageContent).toHaveLength(0) - }) - - it("should set isPaused to false after completeSubtask", async () => { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "parent task", - startTask: false, - }) - - // Mock the API to return an XML protocol model - vi.spyOn(task.api, "getModel").mockReturnValue({ - id: "claude-2", - info: { - contextWindow: 100000, - maxTokens: 4096, - supportsPromptCache: false, - supportsNativeTools: false, - } as ModelInfo, - }) - - // Set isPaused to true (simulating waiting for subtask) - task.isPaused = true - task.childTaskId = "child-task-id" - - // Call completeSubtask - await task.completeSubtask("Subtask completed") - - // Should reset paused state - expect(task.isPaused).toBe(false) - expect(task.childTaskId).toBeUndefined() - }) - }) }) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index a9f02005f0..4586e4b546 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -1,5 +1,5 @@ import type OpenAI from "openai" -import type { ProviderSettings, ModeConfig } from "@roo-code/types" +import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types" import type { ClineProvider } from "../webview/ClineProvider" import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools" import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode" @@ -13,6 +13,7 @@ interface BuildToolsOptions { apiConfiguration: ProviderSettings | undefined maxReadFileLine: number browserToolEnabled: boolean + modelInfo?: ModelInfo } /** @@ -23,8 +24,17 @@ interface BuildToolsOptions { * @returns Array of filtered native and MCP tools */ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise { - const { provider, cwd, mode, customModes, experiments, apiConfiguration, maxReadFileLine, browserToolEnabled } = - options + const { + provider, + cwd, + mode, + customModes, + experiments, + apiConfiguration, + maxReadFileLine, + browserToolEnabled, + modelInfo, + } = options const mcpHub = provider.getMcpHub() @@ -36,6 +46,7 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise const filterSettings = { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, browserToolEnabled: browserToolEnabled ?? true, + modelInfo, } // Determine if partial reads are enabled based on maxReadFileLine setting diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 8e325e46da..a7819d8b7a 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -261,6 +261,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } captureCodeAccept(fileLanguage, changedLines) const codeReviewService = CodeReviewService.getInstance() + // todo: this should be moved to a more appropriate place codeReviewService.checkAndAcceptIssueByTaskId(task.taskId) // Used to determine if we should wait for busy terminal to update before sending api request diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts new file mode 100644 index 0000000000..000bc14729 --- /dev/null +++ b/src/core/tools/ApplyPatchTool.ts @@ -0,0 +1,450 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { fileExistsAtPath } from "../../utils/fs" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" +import { parsePatch, ParseError, processAllHunks } from "./apply-patch" +import type { ApplyPatchFileChange } from "./apply-patch" + +interface ApplyPatchParams { + patch: string +} + +export class ApplyPatchTool extends BaseTool<"apply_patch"> { + readonly name = "apply_patch" as const + + parseLegacy(params: Partial>): ApplyPatchParams { + return { + patch: params.patch || "", + } + } + + async execute(params: ApplyPatchParams, task: Task, callbacks: ToolCallbacks): Promise { + const { patch } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + // Validate required parameters + if (!patch) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + pushToolResult(await task.sayAndCreateMissingParamError("apply_patch", "patch")) + return + } + + // Parse the patch + let parsedPatch + try { + parsedPatch = parsePatch(patch) + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = + error instanceof ParseError + ? `Invalid patch format: ${error.message}` + : `Failed to parse patch: ${error instanceof Error ? error.message : String(error)}` + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + if (parsedPatch.hunks.length === 0) { + pushToolResult("No file operations found in patch.") + return + } + + // Process each hunk + const readFile = async (filePath: string): Promise => { + const absolutePath = path.resolve(task.cwd, filePath) + return await fs.readFile(absolutePath, "utf8") + } + + let changes: ApplyPatchFileChange[] + try { + changes = await processAllHunks(parsedPatch.hunks, readFile) + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Failed to process patch: ${error instanceof Error ? error.message : String(error)}` + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + // Process each file change + for (const change of changes) { + const relPath = change.path + const absolutePath = path.resolve(task.cwd, relPath) + + // Check access permissions + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + return + } + + // Check if file is write-protected + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + if (change.type === "add") { + // Create new file + await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + } else if (change.type === "delete") { + // Delete file + await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + } else if (change.type === "update") { + // Update file + await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + } + } + + task.consecutiveMistakeCount = 0 + task.recordToolUsage("apply_patch") + } catch (error) { + await handleError("apply patch", error as Error) + await task.diffViewProvider.reset() + } + } + + private async handleAddFile( + change: ApplyPatchFileChange, + absolutePath: string, + relPath: string, + task: Task, + callbacks: ToolCallbacks, + isWriteProtected: boolean, + ): Promise { + const { askApproval, pushToolResult } = callbacks + + // Check if file already exists + const fileExists = await fileExistsAtPath(absolutePath) + if (fileExists) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `File already exists: ${relPath}. Use Update File instead.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + const newContent = change.newContent || "" + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + // Initialize diff view for new file + task.diffViewProvider.editType = "create" + task.diffViewProvider.originalContent = undefined + + const diff = formatResponse.createPrettyPatch(relPath, "", newContent) + + // Check experiment settings + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + const sanitizedDiff = sanitizeUnifiedDiff(diff || "") + const diffStats = computeDiffStats(sanitizedDiff) || undefined + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: sanitizedDiff, + isOutsideWorkspace, + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: sanitizedDiff, + isProtected: isWriteProtected, + diffStats, + } satisfies ClineSayTool) + + // Show diff view if focus disruption prevention is disabled + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(newContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.revertChanges() + } + pushToolResult("Changes were rejected by the user.") + await task.diffViewProvider.reset() + return + } + + // Save the changes + if (isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + } else { + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + // Track file edit operation + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + task.didEditFile = true + + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) + pushToolResult(message) + await task.diffViewProvider.reset() + task.processQueuedMessages() + } + + private async handleDeleteFile( + absolutePath: string, + relPath: string, + task: Task, + callbacks: ToolCallbacks, + isWriteProtected: boolean, + ): Promise { + const { askApproval, pushToolResult } = callbacks + + // Check if file exists + const fileExists = await fileExistsAtPath(absolutePath) + if (!fileExists) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: `File will be deleted: ${relPath}`, + isOutsideWorkspace, + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: `Delete file: ${relPath}`, + isProtected: isWriteProtected, + } satisfies ClineSayTool) + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + pushToolResult("Delete operation was rejected by the user.") + return + } + + // Delete the file + try { + await fs.unlink(absolutePath) + } catch (error) { + const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + task.didEditFile = true + pushToolResult(`Successfully deleted ${relPath}`) + task.processQueuedMessages() + } + + private async handleUpdateFile( + change: ApplyPatchFileChange, + absolutePath: string, + relPath: string, + task: Task, + callbacks: ToolCallbacks, + isWriteProtected: boolean, + ): Promise { + const { askApproval, pushToolResult } = callbacks + + // Check if file exists + const fileExists = await fileExistsAtPath(absolutePath) + if (!fileExists) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + const originalContent = change.originalContent || "" + const newContent = change.newContent || "" + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + // Initialize diff view + task.diffViewProvider.editType = "modify" + task.diffViewProvider.originalContent = originalContent + + // Generate and validate diff + const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent) + if (!diff) { + pushToolResult(`No changes needed for '${relPath}'`) + await task.diffViewProvider.reset() + return + } + + // Check experiment settings + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + const sanitizedDiff = sanitizeUnifiedDiff(diff) + const diffStats = computeDiffStats(sanitizedDiff) || undefined + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: sanitizedDiff, + isOutsideWorkspace, + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: sanitizedDiff, + isProtected: isWriteProtected, + diffStats, + } satisfies ClineSayTool) + + // Show diff view if focus disruption prevention is disabled + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(newContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.revertChanges() + } + pushToolResult("Changes were rejected by the user.") + await task.diffViewProvider.reset() + return + } + + // Handle file move if specified + if (change.movePath) { + const moveAbsolutePath = path.resolve(task.cwd, change.movePath) + + // Validate destination path access permissions + const moveAccessAllowed = task.rooIgnoreController?.validateAccess(change.movePath) + if (!moveAccessAllowed) { + await task.say("rooignore_error", change.movePath) + pushToolResult(formatResponse.rooIgnoreError(change.movePath)) + await task.diffViewProvider.reset() + return + } + + // Check if destination path is write-protected + const isMovePathWriteProtected = task.rooProtectedController?.isWriteProtected(change.movePath) || false + if (isMovePathWriteProtected) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Cannot move file to write-protected path: ${change.movePath}` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + await task.diffViewProvider.reset() + return + } + + // Check if destination path is outside workspace + const isMoveOutsideWorkspace = isPathOutsideWorkspace(moveAbsolutePath) + if (isMoveOutsideWorkspace) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Cannot move file to path outside workspace: ${change.movePath}` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + await task.diffViewProvider.reset() + return + } + + // Save new content to the new path + if (isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.saveDirectly( + change.movePath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + ) + } else { + // Write to new path and delete old file + const parentDir = path.dirname(moveAbsolutePath) + await fs.mkdir(parentDir, { recursive: true }) + await fs.writeFile(moveAbsolutePath, newContent, "utf8") + } + + // Delete the original file + try { + await fs.unlink(absolutePath) + } catch (error) { + console.error(`Failed to delete original file after move: ${error}`) + } + + await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) + } else { + // Save changes to the same file + if (isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + } else { + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + task.didEditFile = true + + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) + pushToolResult(message) + await task.diffViewProvider.reset() + task.processQueuedMessages() + } + + override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { + const patch: string | undefined = block.params.patch + + let patchPreview: string | undefined + if (patch) { + // Show first few lines of the patch + const lines = patch.split("\n").slice(0, 5) + patchPreview = lines.join("\n") + (patch.split("\n").length > 5 ? "\n..." : "") + } + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: "", + diff: patchPreview || "Parsing patch...", + isOutsideWorkspace: false, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +export const applyPatchTool = new ApplyPatchTool() diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 60eb1dbecc..e21f3b7188 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -1,7 +1,6 @@ -import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" -import { RooCodeEventName } from "@roo-code/types" +import { RooCodeEventName, type HistoryItem } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" @@ -21,6 +20,18 @@ export interface AttemptCompletionCallbacks extends ToolCallbacks { toolDescription: () => string } +/** + * Interface for provider methods needed by AttemptCompletionTool for delegation handling. + */ +interface DelegationProvider { + getTaskWithId(id: string): Promise<{ historyItem: HistoryItem }> + reopenParentFromDelegation(params: { + parentTaskId: string + childTaskId: string + completionResultSummary: string + }): Promise +} + export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { readonly name = "attempt_completion" as const @@ -33,7 +44,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { async execute(params: AttemptCompletionParams, task: Task, callbacks: AttemptCompletionCallbacks): Promise { const { result } = params - const { handleError, pushToolResult, askFinishSubTaskApproval, toolDescription, toolProtocol } = callbacks + const { handleError, pushToolResult, askFinishSubTaskApproval } = callbacks // Prevent attempt_completion if any tool failed in the current turn if (task.didToolFailInCurrentTurn) { @@ -77,17 +88,50 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { TelemetryService.instance.captureTaskCompleted(task.taskId) task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) - if (task.parentTask) { - const didApprove = await askFinishSubTaskApproval() - - if (!didApprove) { - pushToolResult(formatResponse.toolDenied()) - return + // Check for subtask using parentTaskId (metadata-driven delegation) + if (task.parentTaskId) { + // Check if this subtask has already completed and returned to parent + // to prevent duplicate tool_results when user revisits from history + const provider = task.providerRef.deref() as DelegationProvider | undefined + if (provider) { + try { + const { historyItem } = await provider.getTaskWithId(task.taskId) + const status = historyItem?.status + + if (status === "completed") { + // Subtask already completed - skip delegation flow entirely + // Fall through to normal completion ask flow below (outside this if block) + // This shows the user the completion result and waits for acceptance + // without injecting another tool_result to the parent + } else if (status === "active") { + // Normal subtask completion - do delegation + const delegated = await this.delegateToParent( + task, + result, + provider, + askFinishSubTaskApproval, + pushToolResult, + ) + if (delegated) return + } else { + // Unexpected status (undefined or "delegated") - log error and skip delegation + // undefined indicates a bug in status persistence during child creation + // "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion) + console.error( + `[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` + + `Expected "active" or "completed". Skipping delegation to prevent data corruption.`, + ) + // Fall through to normal completion ask flow + } + } catch (err) { + // If we can't get the history, log error and skip delegation + console.error( + `[AttemptCompletionTool] Failed to get history for task ${task.taskId}: ${(err as Error)?.message ?? String(err)}. ` + + `Skipping delegation.`, + ) + // Fall through to normal completion ask flow + } } - - pushToolResult("") - await task.providerRef.deref()?.finishSubTask(result, task.taskId) - return } const { response, text, images } = await task.ask("completion_result", "", false) @@ -106,6 +150,35 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } } + /** + * Handles the common delegation flow when a subtask completes. + * Returns true if delegation was performed and the caller should return early. + */ + private async delegateToParent( + task: Task, + result: string, + provider: DelegationProvider, + askFinishSubTaskApproval: () => Promise, + pushToolResult: (result: string) => void, + ): Promise { + const didApprove = await askFinishSubTaskApproval() + + if (!didApprove) { + pushToolResult(formatResponse.toolDenied()) + return true + } + + pushToolResult("") + + await provider.reopenParentFromDelegation({ + parentTaskId: task.parentTaskId!, + childTaskId: task.taskId, + completionResultSummary: result, + }) + + return true + } + override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise { const result: string | undefined = block.params.result const command: string | undefined = block.params.command diff --git a/src/core/tools/ListFilesTool.ts b/src/core/tools/ListFilesTool.ts index 37e3676a03..88f080c4ef 100644 --- a/src/core/tools/ListFilesTool.ts +++ b/src/core/tools/ListFilesTool.ts @@ -8,6 +8,8 @@ import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments" +import { MAX_WORKSPACE_FILES } from "@roo-code/types" interface ListFilesParams { path: string @@ -45,8 +47,17 @@ export class ListFilesTool extends BaseTool<"list_files"> { const absolutePath = path.resolve(task.cwd, relDirPath) const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - const [files, didHitLimit] = await listFiles(absolutePath, recursive || false, 200) - const { showRooIgnoredFiles = false } = (await task.providerRef.deref()?.getState()) ?? {} + const { + showRooIgnoredFiles = false, + experiments, + maxWorkspaceFiles = MAX_WORKSPACE_FILES, + } = (await task.providerRef.deref()?.getState()) ?? {} + const [files, didHitLimit] = await listFiles( + absolutePath, + recursive || false, + (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.ALWAYS_INCLUDE_FILE_DETAILS) ? 4 : 1) * + maxWorkspaceFiles, + ) const result = formatResponse.formatFilesList( absolutePath, @@ -55,6 +66,7 @@ export class ListFilesTool extends BaseTool<"list_files"> { task.rooIgnoreController, showRooIgnoredFiles, task.rooProtectedController, + experiments, ) const sharedMessageProps: ClineSayTool = { diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index 827a63c95c..c5607d2a85 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" import { Task } from "../task/Task" -import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import { parseMarkdownChecklist } from "./UpdateTodoListTool" @@ -123,31 +123,16 @@ export class NewTaskTool extends BaseTool<"new_task"> { task.checkpointSave(true) } - // Preserve the current mode so we can resume with it later. - task.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug - - const newTask = await task.startSubtask(unescapedMessage, todoItems, mode) - - if (!newTask) { - pushToolResult(t("tools:newTask.errors.policy_restriction")) - return - } - - // For native protocol, defer the tool_result until the subtask completes. - // The actual result (including what the subtask accomplished) will be pushed - // by completeSubtask. This gives the parent task useful information about - // what the subtask actually did. - if (toolProtocol === "native" && toolCallId) { - task.pendingNewTaskToolCallId = toolCallId - // Don't push tool_result here - it will come from completeSubtask with the actual result. - // The task loop will stay alive because isPaused is true (see Task.ts stack push condition). - } else { - // For XML protocol, push the result immediately (existing behavior) - pushToolResult( - `Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage} and ${todoItems.length} todo items`, - ) - } + // Delegate parent and open child as sole active task + const child = await (provider as any).delegateParentAndOpenChild({ + parentTaskId: task.taskId, + message: unescapedMessage, + initialTodos: todoItems, + mode, + }) + // Reflect delegation in tool result (no pause/unpause, no wait) + pushToolResult(`Delegated to child task ${child.taskId}`) return } catch (error) { await handleError("creating new task", error) diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts new file mode 100644 index 0000000000..49d159f455 --- /dev/null +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -0,0 +1,306 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { fileExistsAtPath } from "../../utils/fs" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface SearchReplaceOperation { + search: string + replace: string +} + +interface SearchAndReplaceParams { + path: string + operations: SearchReplaceOperation[] +} + +export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { + readonly name = "search_and_replace" as const + + parseLegacy(params: Partial>): SearchAndReplaceParams { + // Parse operations from JSON string if provided + let operations: SearchReplaceOperation[] = [] + if (params.operations) { + try { + operations = JSON.parse(params.operations) + } catch { + operations = [] + } + } + + return { + path: params.path || "", + operations, + } + } + + async execute(params: SearchAndReplaceParams, task: Task, callbacks: ToolCallbacks): Promise { + const { path: relPath, operations } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + // Validate required parameters + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult(await task.sayAndCreateMissingParamError("search_and_replace", "path")) + return + } + + if (!operations || !Array.isArray(operations) || operations.length === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult( + formatResponse.toolError( + "Missing or empty 'operations' parameter. At least one search/replace operation is required.", + ), + ) + return + } + + // Validate each operation has search and replace fields + for (let i = 0; i < operations.length; i++) { + const op = operations[i] + if (!op.search) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'search' field.`)) + return + } + if (op.replace === undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'replace' field.`)) + return + } + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + return + } + + // Check if file is write-protected + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const absolutePath = path.resolve(task.cwd, relPath) + + const fileExists = await fileExistsAtPath(absolutePath) + if (!fileExists) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + const errorMessage = `File not found: ${relPath}. Cannot perform search and replace on a non-existent file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + let fileContent: string + try { + fileContent = await fs.readFile(absolutePath, "utf8") + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + // Apply all operations sequentially + let newContent = fileContent + const errors: string[] = [] + + for (let i = 0; i < operations.length; i++) { + const { search, replace } = operations[i] + const searchPattern = new RegExp(escapeRegExp(search), "g") + + const matchCount = newContent.match(searchPattern)?.length ?? 0 + if (matchCount === 0) { + errors.push(`Operation ${i + 1}: No match found for search text.`) + continue + } + + if (matchCount > 1) { + errors.push( + `Operation ${i + 1}: Found ${matchCount} matches. Please provide more context to make a unique match.`, + ) + continue + } + + // Apply the replacement + newContent = newContent.replace(searchPattern, replace) + } + + // If all operations failed, return error + if (errors.length === operations.length) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace", "no_match") + pushToolResult(formatResponse.toolError(`All operations failed:\n${errors.join("\n")}`)) + return + } + + // Check if any changes were made + if (newContent === fileContent) { + pushToolResult(`No changes needed for '${relPath}'`) + return + } + + task.consecutiveMistakeCount = 0 + + // Initialize diff view + task.diffViewProvider.editType = "modify" + task.diffViewProvider.originalContent = fileContent + + // Generate and validate diff + const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent) + if (!diff) { + pushToolResult(`No changes needed for '${relPath}'`) + await task.diffViewProvider.reset() + return + } + + // Check if preventFocusDisruption experiment is enabled + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + const sanitizedDiff = sanitizeUnifiedDiff(diff) + const diffStats = computeDiffStats(sanitizedDiff) || undefined + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: sanitizedDiff, + isOutsideWorkspace, + } + + // Include any partial errors in the message + let resultMessage = "" + if (errors.length > 0) { + resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: sanitizedDiff, + isProtected: isWriteProtected, + diffStats, + } satisfies ClineSayTool) + + // Show diff view if focus disruption prevention is disabled + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(newContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + // Revert changes if diff view was shown + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.revertChanges() + } + pushToolResult("Changes were rejected by the user.") + await task.diffViewProvider.reset() + return + } + + // Save the changes + if (isPreventFocusDisruptionEnabled) { + // Direct file write without diff view or opening the file + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + } else { + // Call saveChanges to update the DiffViewProvider properties + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + // Track file edit operation + if (relPath) { + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + task.didEditFile = true + + // Get the formatted response message + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) + + // Add error info if some operations failed + if (errors.length > 0) { + pushToolResult(`${resultMessage}${message}`) + } else { + pushToolResult(message) + } + + // Record successful tool usage and cleanup + task.recordToolUsage("search_and_replace") + await task.diffViewProvider.reset() + + // Process any queued messages after file edit completes + task.processQueuedMessages() + } catch (error) { + await handleError("search and replace", error as Error) + await task.diffViewProvider.reset() + } + } + + override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise { + const relPath: string | undefined = block.params.path + const operationsStr: string | undefined = block.params.operations + + let operationsPreview: string | undefined + if (operationsStr) { + try { + const ops = JSON.parse(operationsStr) + if (Array.isArray(ops) && ops.length > 0) { + operationsPreview = `${ops.length} operation(s)` + } + } catch { + operationsPreview = "parsing..." + } + } + + const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" + const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath || ""), + diff: operationsPreview, + isOutsideWorkspace, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +/** + * Escapes special regex characters in a string + * @param input String to escape regex characters in + * @returns Escaped string safe for regex pattern matching + */ +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export const searchAndReplaceTool = new SearchAndReplaceTool() diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 4c05004086..b652dcb410 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -74,6 +74,15 @@ const mockSayAndCreateMissingParamError = vi.fn() const mockStartSubtask = vi .fn<(message: string, todoItems: any[], mode: string) => Promise>() .mockResolvedValue({ taskId: "mock-subtask-id" }) + +// Adapter to satisfy legacy expectations while exercising new delegation path +const mockDelegateParentAndOpenChild = vi.fn( + async (args: { parentTaskId: string; message: string; initialTodos: any[]; mode: string }) => { + // Call legacy spy so existing expectations still pass + await mockStartSubtask(args.message, args.initialTodos, args.mode) + return { taskId: "child-1" } + }, +) const mockCheckpointSave = vi.fn() // Mock the Cline instance and its methods/properties @@ -93,6 +102,7 @@ const mockCline = { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), handleModeSwitch: vi.fn(), + delegateParentAndOpenChild: mockDelegateParentAndOpenChild, })), }, } @@ -157,7 +167,7 @@ describe("newTaskTool", () => { ) // Verify side effects - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should not un-escape single escaped \@", async () => { @@ -270,7 +280,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should work with todos parameter when provided", async () => { @@ -303,7 +313,7 @@ describe("newTaskTool", () => { "code", ) - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should error when mode parameter is missing", async () => { @@ -423,7 +433,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should REQUIRE todos when VSCode setting is enabled", async () => { @@ -505,7 +515,7 @@ describe("newTaskTool", () => { ) // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should work with empty todos string when VSCode setting is enabled", async () => { @@ -542,7 +552,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) }) it("should check VSCode setting with Package.name configuration key", async () => { @@ -613,3 +623,77 @@ describe("newTaskTool", () => { // Add more tests for error handling (invalid mode, approval denied) if needed }) + +describe("newTaskTool delegation flow", () => { + it("delegates to provider and does not call legacy startSubtask", async () => { + // Arrange: stub provider delegation + const providerSpy = { + getState: vi.fn().mockResolvedValue({ + mode: "ask", + experiments: {}, + }), + delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }), + handleModeSwitch: vi.fn(), + } as any + + // Use a fresh local cline instance to avoid cross-test interference + const localStartSubtask = vi.fn() + const localEmit = vi.fn() + const localCline = { + ask: vi.fn(), + sayAndCreateMissingParamError: mockSayAndCreateMissingParamError, + emit: localEmit, + recordToolError: mockRecordToolError, + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + taskId: "mock-parent-task-id", + enableCheckpoints: false, + checkpointSave: mockCheckpointSave, + startSubtask: localStartSubtask, + providerRef: { + deref: vi.fn(() => providerSpy), + }, + } + + const block: ToolUse = { + type: "tool_use", + name: "new_task", + params: { + mode: "code", + message: "Do something", + // no todos -> should default to [] + }, + partial: false, + } + + // Act + await newTaskTool.handle(localCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) + + // Assert: provider method called with correct params + expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "mock-parent-task-id", + message: "Do something", + initialTodos: [], + mode: "code", + }) + + // Assert: legacy path not used + expect(localStartSubtask).not.toHaveBeenCalled() + + // Assert: no pause/unpause events emitted in delegation path + const pauseEvents = (localEmit as any).mock.calls.filter( + (c: any[]) => c[0] === "taskPaused" || c[0] === "taskUnpaused", + ) + expect(pauseEvents.length).toBe(0) + + // Assert: tool result reflects delegation + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1")) + }) +}) diff --git a/src/core/tools/apply-patch/__tests__/apply.spec.ts b/src/core/tools/apply-patch/__tests__/apply.spec.ts new file mode 100644 index 0000000000..2174846226 --- /dev/null +++ b/src/core/tools/apply-patch/__tests__/apply.spec.ts @@ -0,0 +1,189 @@ +import { applyChunksToContent, ApplyPatchError } from "../apply" +import type { UpdateFileChunk } from "../parser" + +describe("apply-patch apply", () => { + describe("applyChunksToContent", () => { + it("should apply simple replacement", () => { + const original = "foo\nbar\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["foo", "bar"], + newLines: ["foo", "baz"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("foo\nbaz\n") + }) + + it("should apply insertion", () => { + const original = "foo\nbar\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["foo"], + newLines: ["foo", "inserted"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("foo\ninserted\nbar\n") + }) + + it("should apply deletion", () => { + const original = "foo\nbar\nbaz\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["foo", "bar"], + newLines: ["foo"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("foo\nbaz\n") + }) + + it("should apply multiple chunks", () => { + const original = "foo\nbar\nbaz\nqux\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["foo", "bar"], + newLines: ["foo", "BAR"], + isEndOfFile: false, + }, + { + changeContext: null, + oldLines: ["baz", "qux"], + newLines: ["baz", "QUX"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("foo\nBAR\nbaz\nQUX\n") + }) + + it("should use context to find location", () => { + const original = "class Foo:\n def bar(self):\n pass\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: "def bar(self):", + oldLines: [" pass"], + newLines: [" return 123"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.py", chunks) + expect(result).toBe("class Foo:\n def bar(self):\n return 123\n") + }) + + it("should throw when context not found", () => { + const original = "foo\nbar\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: "nonexistent", + oldLines: ["foo"], + newLines: ["baz"], + isEndOfFile: false, + }, + ] + expect(() => applyChunksToContent(original, "test.txt", chunks)).toThrow(ApplyPatchError) + expect(() => applyChunksToContent(original, "test.txt", chunks)).toThrow("Failed to find context") + }) + + it("should throw when old lines not found", () => { + const original = "foo\nbar\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["nonexistent"], + newLines: ["baz"], + isEndOfFile: false, + }, + ] + expect(() => applyChunksToContent(original, "test.txt", chunks)).toThrow(ApplyPatchError) + expect(() => applyChunksToContent(original, "test.txt", chunks)).toThrow("Failed to find expected lines") + }) + + it("should handle pure addition (empty oldLines)", () => { + const original = "foo\nbar\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: [], + newLines: ["added"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + // Pure addition goes at the end + expect(result).toBe("foo\nbar\nadded\n") + }) + + it("should handle isEndOfFile flag", () => { + const original = "foo\nbar\nbaz\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["baz"], + newLines: ["BAZ", "qux"], + isEndOfFile: true, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("foo\nbar\nBAZ\nqux\n") + }) + + it("should handle interleaved changes", () => { + const original = "a\nb\nc\nd\ne\nf\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["a", "b"], + newLines: ["a", "B"], + isEndOfFile: false, + }, + { + changeContext: null, + oldLines: ["d", "e"], + newLines: ["d", "E"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("a\nB\nc\nd\nE\nf\n") + }) + + it("should preserve trailing newline in result", () => { + const original = "foo\nbar" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["bar"], + newLines: ["baz"], + isEndOfFile: false, + }, + ] + const result = applyChunksToContent(original, "test.txt", chunks) + // Should add trailing newline + expect(result).toBe("foo\nbaz\n") + }) + + it("should handle trailing empty line in pattern", () => { + const original = "foo\nbar\n" + const chunks: UpdateFileChunk[] = [ + { + changeContext: null, + oldLines: ["foo", "bar", ""], + newLines: ["foo", "baz", ""], + isEndOfFile: false, + }, + ] + // Should still work by stripping trailing empty + const result = applyChunksToContent(original, "test.txt", chunks) + expect(result).toBe("foo\nbaz\n") + }) + }) +}) diff --git a/src/core/tools/apply-patch/__tests__/parser.spec.ts b/src/core/tools/apply-patch/__tests__/parser.spec.ts new file mode 100644 index 0000000000..a512e0d344 --- /dev/null +++ b/src/core/tools/apply-patch/__tests__/parser.spec.ts @@ -0,0 +1,317 @@ +import { parsePatch, ParseError } from "../parser" + +describe("apply_patch parser", () => { + describe("parsePatch", () => { + it("should reject patch without Begin Patch marker", () => { + expect(() => parsePatch("bad")).toThrow(ParseError) + expect(() => parsePatch("bad")).toThrow("The first line of the patch must be '*** Begin Patch'") + }) + + it("should reject patch without End Patch marker", () => { + expect(() => parsePatch("*** Begin Patch\nbad")).toThrow(ParseError) + expect(() => parsePatch("*** Begin Patch\nbad")).toThrow( + "The last line of the patch must be '*** End Patch'", + ) + }) + + it("should parse empty patch", () => { + const result = parsePatch("*** Begin Patch\n*** End Patch") + expect(result.hunks).toEqual([]) + }) + + it("should parse Add File hunk", () => { + const result = parsePatch(`*** Begin Patch +*** Add File: path/add.py ++abc ++def +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "AddFile", + path: "path/add.py", + contents: "abc\ndef\n", + }) + }) + + it("should parse Delete File hunk", () => { + const result = parsePatch(`*** Begin Patch +*** Delete File: path/delete.py +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "DeleteFile", + path: "path/delete.py", + }) + }) + + it("should parse Update File hunk with context", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: path/update.py +@@ def f(): +- pass ++ return 123 +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "path/update.py", + movePath: null, + chunks: [ + { + changeContext: "def f():", + oldLines: [" pass"], + newLines: [" return 123"], + isEndOfFile: false, + }, + ], + }) + }) + + it("should parse Update File hunk with Move to", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: path/update.py +*** Move to: path/update2.py +@@ def f(): +- pass ++ return 123 +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "path/update.py", + movePath: "path/update2.py", + chunks: [ + { + changeContext: "def f():", + oldLines: [" pass"], + newLines: [" return 123"], + isEndOfFile: false, + }, + ], + }) + }) + + it("should parse Update File hunk with empty context marker", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: file.py +@@ ++line +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "file.py", + movePath: null, + chunks: [ + { + changeContext: null, + oldLines: [], + newLines: ["line"], + isEndOfFile: false, + }, + ], + }) + }) + + it("should parse multiple hunks", () => { + const result = parsePatch(`*** Begin Patch +*** Add File: path/add.py ++abc ++def +*** Delete File: path/delete.py +*** Update File: path/update.py +*** Move to: path/update2.py +@@ def f(): +- pass ++ return 123 +*** End Patch`) + + expect(result.hunks).toHaveLength(3) + expect(result.hunks[0]).toEqual({ + type: "AddFile", + path: "path/add.py", + contents: "abc\ndef\n", + }) + expect(result.hunks[1]).toEqual({ + type: "DeleteFile", + path: "path/delete.py", + }) + expect(result.hunks[2]).toEqual({ + type: "UpdateFile", + path: "path/update.py", + movePath: "path/update2.py", + chunks: [ + { + changeContext: "def f():", + oldLines: [" pass"], + newLines: [" return 123"], + isEndOfFile: false, + }, + ], + }) + }) + + it("should parse Update hunk followed by Add hunk", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: file.py +@@ ++line +*** Add File: other.py ++content +*** End Patch`) + + expect(result.hunks).toHaveLength(2) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "file.py", + movePath: null, + chunks: [ + { + changeContext: null, + oldLines: [], + newLines: ["line"], + isEndOfFile: false, + }, + ], + }) + expect(result.hunks[1]).toEqual({ + type: "AddFile", + path: "other.py", + contents: "content\n", + }) + }) + + it("should parse Update hunk without explicit @@ header for first chunk", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: file2.py + import foo ++bar +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "file2.py", + movePath: null, + chunks: [ + { + changeContext: null, + oldLines: ["import foo"], + newLines: ["import foo", "bar"], + isEndOfFile: false, + }, + ], + }) + }) + + it("should reject empty Update File hunk", () => { + expect(() => + parsePatch(`*** Begin Patch +*** Update File: test.py +*** End Patch`), + ).toThrow(ParseError) + }) + + it("should handle heredoc-wrapped patches (lenient mode)", () => { + const result = parsePatch(`< { + const result = parsePatch(`<<'EOF' +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +EOF`) + + expect(result.hunks).toHaveLength(1) + }) + + it("should handle double-quoted heredoc", () => { + const result = parsePatch(`<<"EOF" +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +EOF`) + + expect(result.hunks).toHaveLength(1) + }) + + it("should parse chunk with End of File marker", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: file.py +@@ ++line +*** End of File +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "file.py", + movePath: null, + chunks: [ + { + changeContext: null, + oldLines: [], + newLines: ["line"], + isEndOfFile: true, + }, + ], + }) + }) + + it("should parse multiple chunks in one Update File", () => { + const result = parsePatch(`*** Begin Patch +*** Update File: multi.txt +@@ + foo +-bar ++BAR +@@ + baz +-qux ++QUX +*** End Patch`) + + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toEqual({ + type: "UpdateFile", + path: "multi.txt", + movePath: null, + chunks: [ + { + changeContext: null, + oldLines: ["foo", "bar"], + newLines: ["foo", "BAR"], + isEndOfFile: false, + }, + { + changeContext: null, + oldLines: ["baz", "qux"], + newLines: ["baz", "QUX"], + isEndOfFile: false, + }, + ], + }) + }) + }) +}) diff --git a/src/core/tools/apply-patch/__tests__/seek-sequence.spec.ts b/src/core/tools/apply-patch/__tests__/seek-sequence.spec.ts new file mode 100644 index 0000000000..518cd47b58 --- /dev/null +++ b/src/core/tools/apply-patch/__tests__/seek-sequence.spec.ts @@ -0,0 +1,97 @@ +import { seekSequence } from "../seek-sequence" + +describe("seek-sequence", () => { + describe("seekSequence", () => { + function toVec(strings: string[]): string[] { + return strings + } + + it("should match exact sequence", () => { + const lines = toVec(["foo", "bar", "baz"]) + const pattern = toVec(["bar", "baz"]) + expect(seekSequence(lines, pattern, 0, false)).toBe(1) + }) + + it("should return start for empty pattern", () => { + const lines = toVec(["foo", "bar"]) + const pattern = toVec([]) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + expect(seekSequence(lines, pattern, 5, false)).toBe(5) + }) + + it("should return null when pattern is longer than input", () => { + const lines = toVec(["just one line"]) + const pattern = toVec(["too", "many", "lines"]) + expect(seekSequence(lines, pattern, 0, false)).toBeNull() + }) + + it("should match ignoring trailing whitespace", () => { + const lines = toVec(["foo ", "bar\t\t"]) + const pattern = toVec(["foo", "bar"]) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + }) + + it("should match ignoring leading and trailing whitespace", () => { + const lines = toVec([" foo ", " bar\t"]) + const pattern = toVec(["foo", "bar"]) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + }) + + it("should respect start parameter", () => { + const lines = toVec(["foo", "bar", "foo", "baz"]) + const pattern = toVec(["foo"]) + // Starting at 0 should find first foo + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + // Starting at 1 should find second foo + expect(seekSequence(lines, pattern, 1, false)).toBe(2) + }) + + it("should search from end when eof is true", () => { + const lines = toVec(["foo", "bar", "foo", "baz"]) + const pattern = toVec(["foo", "baz"]) + // With eof=true, should find at the end + expect(seekSequence(lines, pattern, 0, true)).toBe(2) + }) + + it("should handle Unicode normalization - dashes", () => { + // EN DASH (\u2013) and NON-BREAKING HYPHEN (\u2011) → ASCII '-' + const lines = toVec(["hello \u2013 world \u2011 test"]) + const pattern = toVec(["hello - world - test"]) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + }) + + it("should handle Unicode normalization - quotes", () => { + // Fancy single quotes → ASCII '\'' + const lines = toVec(["it\u2019s working"]) // RIGHT SINGLE QUOTATION MARK + const pattern = toVec(["it's working"]) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + }) + + it("should handle Unicode normalization - double quotes", () => { + // Fancy double quotes → ASCII '"' + const lines = toVec(["\u201Chello\u201D"]) // LEFT/RIGHT DOUBLE QUOTATION MARK + const pattern = toVec(['"hello"']) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + }) + + it("should handle Unicode normalization - non-breaking space", () => { + // Non-breaking space (\u00A0) → normal space + const lines = toVec(["hello\u00A0world"]) + const pattern = toVec(["hello world"]) + expect(seekSequence(lines, pattern, 0, false)).toBe(0) + }) + + it("should return null when pattern not found", () => { + const lines = toVec(["foo", "bar", "baz"]) + const pattern = toVec(["qux"]) + expect(seekSequence(lines, pattern, 0, false)).toBeNull() + }) + + it("should return null when start is past possible match", () => { + const lines = toVec(["foo", "bar", "baz"]) + const pattern = toVec(["foo", "bar"]) + // Starting at 2, there's not enough room for a 2-line pattern + expect(seekSequence(lines, pattern, 2, false)).toBeNull() + }) + }) +}) diff --git a/src/core/tools/apply-patch/apply.ts b/src/core/tools/apply-patch/apply.ts new file mode 100644 index 0000000000..4ab377f732 --- /dev/null +++ b/src/core/tools/apply-patch/apply.ts @@ -0,0 +1,205 @@ +/** + * Core patch application logic for the apply_patch tool. + * Transforms file contents using parsed hunks. + */ + +import type { Hunk, UpdateFileChunk } from "./parser" +import { seekSequence } from "./seek-sequence" + +/** + * Error during patch application. + */ +export class ApplyPatchError extends Error { + constructor(message: string) { + super(message) + this.name = "ApplyPatchError" + } +} + +/** + * Result of applying a patch to a file. + */ +export interface ApplyPatchFileChange { + type: "add" | "delete" | "update" + /** Original path of the file */ + path: string + /** New path if the file was moved/renamed */ + movePath?: string + /** Original content (for delete/update) */ + originalContent?: string + /** New content (for add/update) */ + newContent?: string +} + +/** + * Compute the replacements needed to transform originalLines into the new lines. + * Each replacement is [startIndex, oldLength, newLines]. + */ +function computeReplacements( + originalLines: string[], + filePath: string, + chunks: UpdateFileChunk[], +): Array<[number, number, string[]]> { + const replacements: Array<[number, number, string[]]> = [] + let lineIndex = 0 + + for (const chunk of chunks) { + // If a chunk has a change_context, find it first + if (chunk.changeContext !== null) { + const idx = seekSequence(originalLines, [chunk.changeContext], lineIndex, false) + if (idx === null) { + throw new ApplyPatchError(`Failed to find context '${chunk.changeContext}' in ${filePath}`) + } + lineIndex = idx + 1 + } + + if (chunk.oldLines.length === 0) { + // Pure addition (no old lines). Add at the end or before final empty line. + const insertionIdx = + originalLines.length > 0 && originalLines[originalLines.length - 1] === "" + ? originalLines.length - 1 + : originalLines.length + replacements.push([insertionIdx, 0, chunk.newLines]) + continue + } + + // Try to find the old_lines in the file + let pattern = chunk.oldLines + let newSlice = chunk.newLines + let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile) + + // If not found and pattern ends with empty string (trailing newline), + // retry without it + if (found === null && pattern.length > 0 && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1) + if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { + newSlice = newSlice.slice(0, -1) + } + found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile) + } + + if (found !== null) { + replacements.push([found, pattern.length, newSlice]) + lineIndex = found + pattern.length + } else { + throw new ApplyPatchError( + `Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n").substring(0, 200)}${chunk.oldLines.join("\n").length > 200 ? "..." : ""}`, + ) + } + } + + // Sort replacements by start index + replacements.sort((a, b) => a[0] - b[0]) + + return replacements +} + +/** + * Apply replacements to the original lines, returning the modified content. + * Replacements must be applied in reverse order to preserve indices. + */ +function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { + const result = [...lines] + + // Apply in reverse order so earlier replacements don't shift later indices + for (let i = replacements.length - 1; i >= 0; i--) { + const [startIdx, oldLen, newSegment] = replacements[i]! + + // Remove old lines + result.splice(startIdx, oldLen, ...newSegment) + } + + return result +} + +/** + * Apply chunks to file content, returning the new content. + * + * @param originalContent - The original file content + * @param filePath - The file path (for error messages) + * @param chunks - The update chunks to apply + * @returns The new file content + */ +export function applyChunksToContent(originalContent: string, filePath: string, chunks: UpdateFileChunk[]): string { + // Split content into lines + let originalLines = originalContent.split("\n") + + // Drop trailing empty element that results from final newline + // so that line counts match standard diff behavior + if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { + originalLines = originalLines.slice(0, -1) + } + + const replacements = computeReplacements(originalLines, filePath, chunks) + let newLines = applyReplacements(originalLines, replacements) + + // Ensure file ends with newline + if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { + newLines = [...newLines, ""] + } + + return newLines.join("\n") +} + +/** + * Process a single hunk and return the file change. + * + * @param hunk - The hunk to process + * @param readFile - Function to read file contents + * @returns The file change result + */ +export async function processHunk( + hunk: Hunk, + readFile: (path: string) => Promise, +): Promise { + switch (hunk.type) { + case "AddFile": + return { + type: "add", + path: hunk.path, + newContent: hunk.contents, + } + + case "DeleteFile": { + const content = await readFile(hunk.path) + return { + type: "delete", + path: hunk.path, + originalContent: content, + } + } + + case "UpdateFile": { + const originalContent = await readFile(hunk.path) + const newContent = applyChunksToContent(originalContent, hunk.path, hunk.chunks) + return { + type: "update", + path: hunk.path, + movePath: hunk.movePath ?? undefined, + originalContent, + newContent, + } + } + } +} + +/** + * Process all hunks in a patch. + * + * @param hunks - The hunks to process + * @param readFile - Function to read file contents + * @returns Array of file changes + */ +export async function processAllHunks( + hunks: Hunk[], + readFile: (path: string) => Promise, +): Promise { + const changes: ApplyPatchFileChange[] = [] + + for (const hunk of hunks) { + const change = await processHunk(hunk, readFile) + changes.push(change) + } + + return changes +} diff --git a/src/core/tools/apply-patch/index.ts b/src/core/tools/apply-patch/index.ts new file mode 100644 index 0000000000..fb69325104 --- /dev/null +++ b/src/core/tools/apply-patch/index.ts @@ -0,0 +1,14 @@ +/** + * apply_patch tool module + * + * A stripped-down, file-oriented diff format designed to be easy to parse and safe to apply. + * Based on the Codex apply_patch specification. + */ + +export { parsePatch, ParseError } from "./parser" +export type { Hunk, UpdateFileChunk, ApplyPatchArgs } from "./parser" + +export { seekSequence } from "./seek-sequence" + +export { applyChunksToContent, processHunk, processAllHunks, ApplyPatchError } from "./apply" +export type { ApplyPatchFileChange } from "./apply" diff --git a/src/core/tools/apply-patch/parser.ts b/src/core/tools/apply-patch/parser.ts new file mode 100644 index 0000000000..65e4c57f2c --- /dev/null +++ b/src/core/tools/apply-patch/parser.ts @@ -0,0 +1,332 @@ +/** + * Parser for the apply_patch tool format. + * Converts patch text into structured hunks following the Codex apply_patch specification. + * + * Grammar: + * Patch := Begin { FileOp } End + * Begin := "*** Begin Patch" NEWLINE + * End := "*** End Patch" NEWLINE + * FileOp := AddFile | DeleteFile | UpdateFile + * AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE } + * DeleteFile := "*** Delete File: " path NEWLINE + * UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk } + * MoveTo := "*** Move to: " newPath NEWLINE + * Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ] + * HunkLine := (" " | "-" | "+") text NEWLINE + */ + +const BEGIN_PATCH_MARKER = "*** Begin Patch" +const END_PATCH_MARKER = "*** End Patch" +const ADD_FILE_MARKER = "*** Add File: " +const DELETE_FILE_MARKER = "*** Delete File: " +const UPDATE_FILE_MARKER = "*** Update File: " +const MOVE_TO_MARKER = "*** Move to: " +const EOF_MARKER = "*** End of File" +const CHANGE_CONTEXT_MARKER = "@@ " +const EMPTY_CHANGE_CONTEXT_MARKER = "@@" + +/** + * Represents an error during patch parsing. + */ +export class ParseError extends Error { + constructor( + message: string, + public lineNumber?: number, + ) { + super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message) + this.name = "ParseError" + } +} + +/** + * A chunk within an UpdateFile hunk. + */ +export interface UpdateFileChunk { + /** Optional context line (e.g., class or function name) to narrow search */ + changeContext: string | null + /** Lines to find and replace (context + removed lines) */ + oldLines: string[] + /** Lines to replace with (context + added lines) */ + newLines: string[] + /** If true, old_lines must match at end of file */ + isEndOfFile: boolean +} + +/** + * Represents a file operation in a patch. + */ +export type Hunk = + | { + type: "AddFile" + path: string + contents: string + } + | { + type: "DeleteFile" + path: string + } + | { + type: "UpdateFile" + path: string + movePath: string | null + chunks: UpdateFileChunk[] + } + +/** + * Result of parsing a patch. + */ +export interface ApplyPatchArgs { + hunks: Hunk[] + patch: string +} + +/** + * Check if lines start and end with correct patch markers. + */ +function checkPatchBoundaries(lines: string[]): void { + if (lines.length === 0) { + throw new ParseError("Empty patch") + } + + const firstLine = lines[0]?.trim() + const lastLine = lines[lines.length - 1]?.trim() + + if (firstLine !== BEGIN_PATCH_MARKER) { + throw new ParseError("The first line of the patch must be '*** Begin Patch'") + } + + if (lastLine !== END_PATCH_MARKER) { + throw new ParseError("The last line of the patch must be '*** End Patch'") + } +} + +/** + * Parse a single UpdateFileChunk from lines. + * Returns the parsed chunk and number of lines consumed. + */ +function parseUpdateFileChunk( + lines: string[], + lineNumber: number, + allowMissingContext: boolean, +): { chunk: UpdateFileChunk; linesConsumed: number } { + if (lines.length === 0) { + throw new ParseError("Update hunk does not contain any lines", lineNumber) + } + + let changeContext: string | null = null + let startIndex = 0 + + // Check for context marker + if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) { + changeContext = null + startIndex = 1 + } else if (lines[0]?.startsWith(CHANGE_CONTEXT_MARKER)) { + changeContext = lines[0].substring(CHANGE_CONTEXT_MARKER.length) + startIndex = 1 + } else if (!allowMissingContext) { + throw new ParseError(`Expected update hunk to start with a @@ context marker, got: '${lines[0]}'`, lineNumber) + } + + if (startIndex >= lines.length) { + throw new ParseError("Update hunk does not contain any lines", lineNumber + 1) + } + + const chunk: UpdateFileChunk = { + changeContext, + oldLines: [], + newLines: [], + isEndOfFile: false, + } + + let parsedLines = 0 + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i] + + if (line === EOF_MARKER) { + if (parsedLines === 0) { + throw new ParseError("Update hunk does not contain any lines", lineNumber + 1) + } + chunk.isEndOfFile = true + parsedLines++ + break + } + + const firstChar = line.charAt(0) + + // Empty line is treated as context + if (line === "") { + chunk.oldLines.push("") + chunk.newLines.push("") + parsedLines++ + continue + } + + switch (firstChar) { + case " ": + // Context line + chunk.oldLines.push(line.substring(1)) + chunk.newLines.push(line.substring(1)) + parsedLines++ + break + case "+": + // Added line + chunk.newLines.push(line.substring(1)) + parsedLines++ + break + case "-": + // Removed line + chunk.oldLines.push(line.substring(1)) + parsedLines++ + break + default: + // If we haven't parsed any lines yet, it's an error + if (parsedLines === 0) { + throw new ParseError( + `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`, + lineNumber + 1, + ) + } + // Otherwise, assume this is the start of the next hunk + return { chunk, linesConsumed: parsedLines + startIndex } + } + } + + return { chunk, linesConsumed: parsedLines + startIndex } +} + +/** + * Parse a single hunk (file operation) from lines. + * Returns the parsed hunk and number of lines consumed. + */ +function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; linesConsumed: number } { + const firstLine = lines[0]?.trim() + + // Add File + if (firstLine?.startsWith(ADD_FILE_MARKER)) { + const path = firstLine.substring(ADD_FILE_MARKER.length) + let contents = "" + let parsedLines = 1 + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (line?.startsWith("+")) { + contents += line.substring(1) + "\n" + parsedLines++ + } else { + break + } + } + + return { + hunk: { type: "AddFile", path, contents }, + linesConsumed: parsedLines, + } + } + + // Delete File + if (firstLine?.startsWith(DELETE_FILE_MARKER)) { + const path = firstLine.substring(DELETE_FILE_MARKER.length) + return { + hunk: { type: "DeleteFile", path }, + linesConsumed: 1, + } + } + + // Update File + if (firstLine?.startsWith(UPDATE_FILE_MARKER)) { + const path = firstLine.substring(UPDATE_FILE_MARKER.length) + let remainingLines = lines.slice(1) + let parsedLines = 1 + + // Check for optional Move to line + let movePath: string | null = null + if (remainingLines[0]?.startsWith(MOVE_TO_MARKER)) { + movePath = remainingLines[0].substring(MOVE_TO_MARKER.length) + remainingLines = remainingLines.slice(1) + parsedLines++ + } + + const chunks: UpdateFileChunk[] = [] + + while (remainingLines.length > 0) { + // Skip blank lines between chunks + if (remainingLines[0]?.trim() === "") { + parsedLines++ + remainingLines = remainingLines.slice(1) + continue + } + + // Stop if we hit another file operation marker + if (remainingLines[0]?.startsWith("***")) { + break + } + + const { chunk, linesConsumed } = parseUpdateFileChunk( + remainingLines, + lineNumber + parsedLines, + chunks.length === 0, // Allow missing context for first chunk + ) + chunks.push(chunk) + parsedLines += linesConsumed + remainingLines = remainingLines.slice(linesConsumed) + } + + if (chunks.length === 0) { + throw new ParseError(`Update file hunk for path '${path}' is empty`, lineNumber) + } + + return { + hunk: { type: "UpdateFile", path, movePath, chunks }, + linesConsumed: parsedLines, + } + } + + throw new ParseError( + `'${firstLine}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`, + lineNumber, + ) +} + +/** + * Parse a patch string into structured hunks. + * + * @param patch - The patch text to parse + * @returns Parsed patch with hunks + * @throws ParseError if the patch is invalid + */ +export function parsePatch(patch: string): ApplyPatchArgs { + const trimmedPatch = patch.trim() + const lines = trimmedPatch.split("\n") + + // Handle heredoc-wrapped patches (lenient mode) + let effectiveLines = lines + if (lines.length >= 4) { + const firstLine = lines[0] + const lastLine = lines[lines.length - 1] + if ( + (firstLine === "< 0) { + const { hunk, linesConsumed } = parseOneHunk(remainingLines, lineNumber) + hunks.push(hunk) + lineNumber += linesConsumed + remainingLines = remainingLines.slice(linesConsumed) + } + + return { + hunks, + patch: effectiveLines.join("\n"), + } +} diff --git a/src/core/tools/apply-patch/seek-sequence.ts b/src/core/tools/apply-patch/seek-sequence.ts new file mode 100644 index 0000000000..14718e7c0d --- /dev/null +++ b/src/core/tools/apply-patch/seek-sequence.ts @@ -0,0 +1,153 @@ +/** + * Fuzzy sequence matching for the apply_patch tool. + * Implements multi-pass sequence matching (exact, trim-end, trim, Unicode-normalized) + * to locate old_lines and change_context within a file. + */ + +/** + * Normalize common Unicode punctuation to ASCII equivalents. + * This allows patches written with plain ASCII to match source files + * containing typographic characters. + */ +function normalizeUnicode(s: string): string { + return s + .trim() + .split("") + .map((c) => { + // Various dash/hyphen code-points → ASCII '-' + if ("\u2010\u2011\u2012\u2013\u2014\u2015\u2212".includes(c)) { + return "-" + } + // Fancy single quotes → '\'' + if ("\u2018\u2019\u201A\u201B".includes(c)) { + return "'" + } + // Fancy double quotes → '"' + if ("\u201C\u201D\u201E\u201F".includes(c)) { + return '"' + } + // Non-breaking space and other odd spaces → normal space + if ("\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000".includes(c)) { + return " " + } + return c + }) + .join("") +} + +/** + * Check if two arrays of lines match exactly. + */ +function exactMatch(lines: string[], pattern: string[], startIndex: number): boolean { + for (let i = 0; i < pattern.length; i++) { + if (lines[startIndex + i] !== pattern[i]) { + return false + } + } + return true +} + +/** + * Check if two arrays of lines match after trimming trailing whitespace. + */ +function trimEndMatch(lines: string[], pattern: string[], startIndex: number): boolean { + for (let i = 0; i < pattern.length; i++) { + if (lines[startIndex + i]?.trimEnd() !== pattern[i]?.trimEnd()) { + return false + } + } + return true +} + +/** + * Check if two arrays of lines match after trimming both sides. + */ +function trimMatch(lines: string[], pattern: string[], startIndex: number): boolean { + for (let i = 0; i < pattern.length; i++) { + if (lines[startIndex + i]?.trim() !== pattern[i]?.trim()) { + return false + } + } + return true +} + +/** + * Check if two arrays of lines match after Unicode normalization. + */ +function normalizedMatch(lines: string[], pattern: string[], startIndex: number): boolean { + for (let i = 0; i < pattern.length; i++) { + if (normalizeUnicode(lines[startIndex + i] ?? "") !== normalizeUnicode(pattern[i] ?? "")) { + return false + } + } + return true +} + +/** + * Attempt to find the sequence of pattern lines within lines beginning at or after start. + * Returns the starting index of the match or null if not found. + * + * Matches are attempted with decreasing strictness: + * 1. Exact match + * 2. Ignoring trailing whitespace + * 3. Ignoring leading and trailing whitespace + * 4. Unicode-normalized (handles typographic characters) + * + * When eof is true, first try starting at the end-of-file (so that patterns + * intended to match file endings are applied at the end), and fall back to + * searching from start if needed. + * + * Special cases handled defensively: + * - Empty pattern → returns start (no-op match) + * - pattern.length > lines.length → returns null (cannot match) + * + * @param lines - The file lines to search in + * @param pattern - The pattern lines to find + * @param start - The starting index to search from + * @param eof - Whether this chunk should match at end of file + * @returns The starting index of the match, or null if not found + */ +export function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | null { + if (pattern.length === 0) { + return start + } + + // When the pattern is longer than available input, there's no possible match + if (pattern.length > lines.length) { + return null + } + + const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start + + const maxStart = lines.length - pattern.length + + // Pass 1: Exact match + for (let i = searchStart; i <= maxStart; i++) { + if (exactMatch(lines, pattern, i)) { + return i + } + } + + // Pass 2: Trim-end match + for (let i = searchStart; i <= maxStart; i++) { + if (trimEndMatch(lines, pattern, i)) { + return i + } + } + + // Pass 3: Trim both sides match + for (let i = searchStart; i <= maxStart; i++) { + if (trimMatch(lines, pattern, i)) { + return i + } + } + + // Pass 4: Unicode-normalized match + for (let i = searchStart; i <= maxStart; i++) { + if (normalizedMatch(lines, pattern, i)) { + return i + } + } + + return null +} diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index f0ce9e16e6..a40b01cdde 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -8,8 +8,20 @@ export function validateToolUse( customModes?: ModeConfig[], toolRequirements?: Record, toolParams?: Record, + experiments?: Record, + includedTools?: string[], ): void { - if (!isToolAllowedForMode(toolName, mode, customModes ?? [], toolRequirements, toolParams)) { + if ( + !isToolAllowedForMode( + toolName, + mode, + customModes ?? [], + toolRequirements, + toolParams, + experiments, + includedTools, + ) + ) { throw new Error(`Tool "${toolName}" is not allowed in ${mode} mode.`) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e8fc12b6ce..f6c4c1e513 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -97,6 +97,9 @@ import { getSystemPromptFilePath } from "../prompts/sections/custom-system-promp import { webviewMessageHandler } from "./webviewMessageHandler" // import type { ClineMessage } from "@roo-code/types" // import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import type { ClineMessage, TodoItem } from "@roo-code/types" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { ZgsmAuthCommands } from "../costrict/auth" @@ -495,19 +498,6 @@ export class ClineProvider return this.clineStack.map((cline) => cline.taskId) } - // Remove the current task/cline instance (at the top of the stack), so this - // task is finished and resume the previous task/cline instance (if it - // exists). - // This is used when a subtask is finished and the parent task needs to be - // resumed. - async finishSubTask(lastMessage: string, subtaskId?: string) { - // Remove the last cline instance from the stack (this is the finished - // subtask). - await this.removeClineFromStack() - // Resume the last cline instance in the stack (if it exists - this is - // the 'parent' calling task). - await this.getCurrentTask()?.completeSubtask(lastMessage, subtaskId) - } // Pending Edit Operations Management /** @@ -936,7 +926,10 @@ export class ClineProvider await this.removeClineFromStack() } - public async createTaskWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) { + public async createTaskWithHistoryItem( + historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, + options?: { startTask?: boolean }, + ) { // Check if we're rehydrating the current task to avoid flicker const currentTask = this.getCurrentTask() const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id @@ -1016,8 +1009,10 @@ export class ClineProvider taskNumber: historyItem.number, workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, + startTask: options?.startTask ?? true, enableBridge: false, - // enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled), + // Preserve the status from the history item to avoid overwriting it when the task saves messages + initialStatus: historyItem.status, }) if (isRehydratingCurrentTask) { @@ -1755,9 +1750,8 @@ export class ClineProvider // remove task from stack if it's the current task if (id === this.getCurrentTask()?.taskId) { - // if we found the taskid to delete - call finish to abort this task and allow a new task to be started, - // if we are deleting a subtask and parent task is still waiting for subtask to finish - it allows the parent to resume (this case should neve exist) - await this.finishSubTask(t("common:tasks.deleted"), id) + // Close the current task instance; delegation flows will be handled via metadata if applicable. + await this.removeClineFromStack() } // delete task from the task history state @@ -2207,6 +2201,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, + debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), } } @@ -2469,7 +2464,13 @@ export class ClineProvider const existingItemIndex = history.findIndex((h) => h.id === item.id) if (existingItemIndex !== -1) { - history[existingItemIndex] = item + // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. + // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, + // terminated, or when routine message persistence occurs. + history[existingItemIndex] = { + ...history[existingItemIndex], + ...item, + } } else { history.push(item) } @@ -2868,6 +2869,15 @@ export class ClineProvider remoteControlEnabled, } = await this.getState() + // Single-open-task invariant: always enforce for user-initiated top-level tasks + if (!parentTask) { + try { + await this.removeClineFromStack() + } catch { + // Non-fatal + } + } + if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) } @@ -3103,11 +3113,11 @@ export class ClineProvider language, mode, taskId: task?.taskId, - parentTaskId: task?.parentTask?.taskId, + parentTaskId: task?.parentTaskId, apiProvider: apiConfiguration?.apiProvider, modelId: task?.api?.getModel().id, diffStrategy: task?.diffStrategy?.getName(), - isSubtask: task ? !!task.parentTask : undefined, + isSubtask: task ? !!task.parentTaskId : undefined, ...userInfo, ...(todos && { todos }), } @@ -3145,6 +3155,287 @@ export class ClineProvider this.zgsmAuthCommands = zgsmAuthCommands } + /** + * Delegate parent task and open child task. + * + * - Enforce single-open invariant + * - Persist parent delegation metadata + * - Emit TaskDelegated (task-level; API forwards to provider/bridge) + * - Create child as sole active and switch mode to child's mode + */ + public async delegateParentAndOpenChild(params: { + parentTaskId: string + message: string + initialTodos: TodoItem[] + mode: string + }): Promise { + const { parentTaskId, message, initialTodos, mode } = params + + // Metadata-driven delegation is always enabled + + // 1) Get parent (must be current task) + const parent = this.getCurrentTask() + if (!parent) { + throw new Error("[delegateParentAndOpenChild] No current task") + } + if (parent.taskId !== parentTaskId) { + throw new Error( + `[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`, + ) + } + + // 2) Enforce single-open invariant by closing/disposing the parent first + // This ensures we never have >1 tasks open at any time during delegation. + // Await abort completion to ensure clean disposal and prevent unhandled rejections. + try { + await this.removeClineFromStack() + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + // Non-fatal: proceed with child creation even if parent cleanup had issues + } + + // 3) Switch provider mode to child's requested mode BEFORE creating the child task + // This ensures the child's system prompt and configuration are based on the correct mode. + // The mode switch must happen before createTask() because the Task constructor + // initializes its mode from provider.getState() during initializeTaskMode(). + try { + await this.handleModeSwitch(mode as any) + } catch (e) { + this.log( + `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ + (e as Error)?.message ?? String(e) + }`, + ) + } + + // 4) 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. + const child = await this.createTask(message, undefined, parent as any, { + initialTodos, + initialStatus: "active", + }) + + // 5) Persist parent delegation metadata + try { + const { historyItem } = await this.getTaskWithId(parentTaskId) + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) + const updatedHistory: typeof historyItem = { + ...historyItem, + status: "delegated", + delegatedToId: child.taskId, + awaitingChildId: child.taskId, + childIds, + } + await this.updateTaskHistory(updatedHistory) + } catch (err) { + this.log( + `[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // 6) Emit TaskDelegated (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) + } catch { + // non-fatal + } + + return child + } + + /** + * Reopen parent task from delegation with write-back and events. + */ + public async reopenParentFromDelegation(params: { + parentTaskId: string + childTaskId: string + completionResultSummary: string + }): Promise { + const { parentTaskId, childTaskId, completionResultSummary } = params + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + // 1) Load parent from history and current persisted messages + const { historyItem } = await this.getTaskWithId(parentTaskId) + + let parentClineMessages: ClineMessage[] = [] + try { + parentClineMessages = await readTaskMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch { + parentClineMessages = [] + } + + let parentApiMessages: any[] = [] + try { + parentApiMessages = (await readApiMessages({ + taskId: parentTaskId, + globalStoragePath, + })) as any[] + } catch { + parentApiMessages = [] + } + + // 2) Inject synthetic records: UI subtask_result and update API tool_result + const ts = Date.now() + + // Defensive: ensure arrays + if (!Array.isArray(parentClineMessages)) parentClineMessages = [] + if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + + const subtaskUiMessage: ClineMessage = { + type: "say", + say: "subtask_result", + text: completionResultSummary, + subtaskId: childTaskId, + ts, + } + parentClineMessages.push(subtaskUiMessage) + await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) + + // Find the tool_use_id from the last assistant message's new_task tool_use + let toolUseId: string | undefined + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i] + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name === "new_task") { + toolUseId = block.id + break + } + } + if (toolUseId) break + } + } + + // The API expects: user → assistant (with tool_use) → user (with tool_result) + // We need to add a NEW user message with the tool_result AFTER the assistant's tool_use + // NOT add it to an existing user message + if (toolUseId) { + // Check if the last message is already a user message with a tool_result for this tool_use_id + // (in case this is a retry or the history was already updated) + const lastMsg = parentApiMessages[parentApiMessages.length - 1] + let alreadyHasToolResult = false + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + if (block.type === "tool_result" && block.tool_use_id === toolUseId) { + // Update the existing tool_result content + block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + alreadyHasToolResult = true + break + } + } + } + + // If no existing tool_result found, create a NEW user message with the tool_result + if (!alreadyHasToolResult) { + parentApiMessages.push({ + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: toolUseId, + content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } + } else { + // Fallback for XML protocol or when toolUseId couldn't be found: + // Add a text block (not ideal but maintains backward compatibility) + parentApiMessages.push({ + role: "user", + content: [ + { + type: "text", + text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + ], + ts, + }) + } + + await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) + + // 3) Update child metadata to "completed" status + try { + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + await this.updateTaskHistory({ + ...childHistory, + status: "completed", + }) + } catch (err) { + this.log( + `[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // 4) Update parent metadata and persist BEFORE emitting completion event + const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) + const updatedHistory: typeof historyItem = { + ...historyItem, + status: "active", + completedByChildId: childTaskId, + completionResultSummary, + awaitingChildId: undefined, + childIds, + } + await this.updateTaskHistory(updatedHistory) + + // 5) Emit TaskDelegationCompleted (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) + } catch { + // non-fatal + } + + // 6) Close child instance if still open (single-open-task invariant) + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + // 7) Reopen the parent from history as the sole active task (restores saved mode) + // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling + const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) + + // 8) Inject restored histories into the in-memory instance before resuming + if (parentInstance) { + try { + await parentInstance.overwriteClineMessages(parentClineMessages) + } catch { + // non-fatal + } + try { + await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + } catch { + // non-fatal + } + + // Auto-resume parent without ask("resume_task") + await parentInstance.resumeAfterDelegation() + } + + // 9) Emit TaskDelegationResumed (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } + } + /** * Convert a file path to a webview-accessible URI * This method safely converts file paths to URIs that can be loaded in the webview diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index f3ef6a3747..06bc34018a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -641,14 +641,12 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) }) - test("calls clearTask when there is no parent task", async () => { + test("calls clearTask (delegation handled via metadata)", async () => { // Setup a single task without parent const mockCline = new Task(defaultTaskOptions) - // No need to set parentTask - it's undefined by default // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Add task to stack @@ -660,25 +658,22 @@ describe("ClineProvider", () => { // Trigger clearTask message await messageHandler({ type: "clearTask" }) - // Verify clearTask was called (not finishSubTask) + // Verify clearTask was called expect(clearTaskSpy).toHaveBeenCalled() - expect(finishSubTaskSpy).not.toHaveBeenCalled() expect(postStateToWebviewSpy).toHaveBeenCalled() }) - test("calls finishSubTask when there is a parent task", async () => { + test("calls clearTask even with parent task (delegation via metadata)", async () => { // Setup parent and child tasks const parentTask = new Task(defaultTaskOptions) const childTask = new Task(defaultTaskOptions) - // Set up parent-child relationship by setting the parentTask property - // The mock allows us to set properties directly + // Set up parent-child relationship ;(childTask as any).parentTask = parentTask ;(childTask as any).rootTask = parentTask // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Add both tasks to stack (parent first, then child) @@ -691,9 +686,8 @@ describe("ClineProvider", () => { // Trigger clearTask message await messageHandler({ type: "clearTask" }) - // Verify finishSubTask was called (not clearTask) - expect(finishSubTaskSpy).toHaveBeenCalledWith("tasks.canceled", "test-task-id") - expect(clearTaskSpy).not.toHaveBeenCalled() + // Verify clearTask was called (delegation happens via metadata, not finishSubTask) + expect(clearTaskSpy).toHaveBeenCalled() expect(postStateToWebviewSpy).toHaveBeenCalled() }) @@ -702,7 +696,6 @@ describe("ClineProvider", () => { // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Get the message handler @@ -713,21 +706,17 @@ describe("ClineProvider", () => { // When there's no current task, clearTask is still called (it handles the no-task case internally) expect(clearTaskSpy).toHaveBeenCalled() - expect(finishSubTaskSpy).not.toHaveBeenCalled() - // State should still be posted expect(postStateToWebviewSpy).toHaveBeenCalled() }) - test("correctly identifies subtask scenario for issue #4602", async () => { - // This test specifically validates the fix for issue #4602 - // where canceling during API retry was incorrectly treating a single task as a subtask + test("correctly identifies task scenario for issue #4602", async () => { + // This test validates the fix for issue #4602 + // where canceling during API retry correctly uses clearTask const mockCline = new Task(defaultTaskOptions) - // No parent task by default - no need to explicitly set // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) - const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) // Add only one task to stack await provider.addClineToStack(mockCline) @@ -741,9 +730,8 @@ describe("ClineProvider", () => { // Trigger clearTask message (simulating cancel during API retry) await messageHandler({ type: "clearTask" }) - // The fix ensures clearTask is called, not finishSubTask + // clearTask should be called (delegation handled via metadata) expect(clearTaskSpy).toHaveBeenCalled() - expect(finishSubTaskSpy).not.toHaveBeenCalled() }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0fee31a210..612948e7ea 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -719,19 +719,10 @@ export const webviewMessageHandler = async ( } break case "clearTask": - // Clear task resets the current session and allows for a new task - // to be started, if this session is a subtask - it allows the - // parent task to be resumed. - // Check if the current task actually has a parent task. - const currentTask = provider.getCurrentTask() - - if (currentTask && currentTask.parentTask) { - await provider.finishSubTask(t("common:tasks.canceled"), currentTask.taskId) - } else { - // Regular task - just clear it - await provider.clearTask() - } - + // Clear task resets the current session. Delegation flows are + // handled via metadata; parent resumption occurs through + // reopenParentFromDelegation, not via finishSubTask. + await provider.clearTask() await provider.postStateToWebview() break case "didShowAnnouncement": @@ -3462,6 +3453,63 @@ export const webviewMessageHandler = async ( } break } + + case "openDebugApiHistory": + case "openDebugUiHistory": { + const currentTask = provider.getCurrentTask() + if (!currentTask) { + vscode.window.showErrorMessage("No active task to view history for") + break + } + + try { + const { getTaskDirectoryPath } = await import("../../utils/storage") + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const taskDirPath = await getTaskDirectoryPath(globalStoragePath, currentTask.taskId) + + const fileName = + message.type === "openDebugApiHistory" ? "api_conversation_history.json" : "ui_messages.json" + const sourceFilePath = path.join(taskDirPath, fileName) + + // Check if file exists + if (!(await fileExistsAtPath(sourceFilePath))) { + vscode.window.showErrorMessage(`File not found: ${fileName}`) + break + } + + // Read the source file + const content = await fs.readFile(sourceFilePath, "utf8") + let jsonContent: unknown + + try { + jsonContent = JSON.parse(content) + } catch { + vscode.window.showErrorMessage(`Failed to parse ${fileName}`) + break + } + + // Prettify the JSON + const prettifiedContent = JSON.stringify(jsonContent, null, 2) + + // Create a temporary file + const tmpDir = os.tmpdir() + const timestamp = Date.now() + const tempFileName = `costrict-debug-${message.type === "openDebugApiHistory" ? "api" : "ui"}-${currentTask.taskId.slice(0, 8)}-${timestamp}.json` + const tempFilePath = path.join(tmpDir, tempFileName) + + await fs.writeFile(tempFilePath, prettifiedContent, "utf8") + + // Open the temp file in VS Code + const doc = await vscode.workspace.openTextDocument(tempFilePath) + await vscode.window.showTextDocument(doc, { preview: true }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error opening debug history: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to open debug history: ${errorMessage}`) + } + break + } + default: { // console.log(`Unhandled message type: ${message.type}`) // diff --git a/src/extension/api.ts b/src/extension/api.ts index da3ce377bf..4944932218 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -170,8 +170,9 @@ export class API extends EventEmitter implements RooCodeAPI { return this.sidebarProvider.getCurrentTaskStack() } - public async clearCurrentTask(lastMessage?: string) { - await this.sidebarProvider.finishSubTask(lastMessage ?? "") + public async clearCurrentTask(_lastMessage?: string) { + // Legacy finishSubTask removed; clear current by closing active task instance. + await this.sidebarProvider.removeClineFromStack() await this.sidebarProvider.postStateToWebview() } @@ -269,6 +270,18 @@ export class API extends EventEmitter implements RooCodeAPI { this.emit(RooCodeEventName.TaskSpawned, task.taskId, childTaskId) }) + task.on(RooCodeEventName.TaskDelegated as any, (childTaskId: string) => { + ;(this.emit as any)(RooCodeEventName.TaskDelegated, task.taskId, childTaskId) + }) + + task.on(RooCodeEventName.TaskDelegationCompleted as any, (childTaskId: string, summary: string) => { + ;(this.emit as any)(RooCodeEventName.TaskDelegationCompleted, task.taskId, childTaskId, summary) + }) + + task.on(RooCodeEventName.TaskDelegationResumed as any, (childTaskId: string) => { + ;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, task.taskId, childTaskId) + }) + // Task Execution task.on(RooCodeEventName.Message, async (message) => { diff --git a/src/package.json b/src/package.json index 495828345f..22ec5c7220 100644 --- a/src/package.json +++ b/src/package.json @@ -793,6 +793,11 @@ ], "default": "auto", "description": "%settings.commit.language.description%" + }, + "zgsm.debug": { + "type": "boolean", + "default": false, + "description": "%settings.debug.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index d18ddaaa8d..b329ef373a 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -78,5 +78,6 @@ "settings.commit.maxLength.description": "Maximum length for commit message subject line (20-300 characters)", "settings.commit.language.description": "Language for commit message generation (auto = use system language)", "settings.toolProtocol.description": "Tool protocol to use for AI interactions. XML is the default and recommended protocol. Native is experimental and may not work with all providers.", - "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60." + "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", + "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 5fcccd2458..d9876980fd 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -78,5 +78,6 @@ "settings.commit.maxLength.description": "提交信息主题行的最大长度(20-300个字符)", "settings.commit.language.description": "提交信息生成语言(auto = 使用系统语言)", "settings.toolProtocol.description": "用于 AI 交互的工具协议。XML 是默认且推荐的协议。本机是实验性的,可能不适用于所有提供商。", - "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。" + "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。", + "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index ea7a9299cc..33bad387db 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -78,5 +78,6 @@ "settings.commit.maxLength.description": "提交訊息主旨行的最大長度(20-300個字元)", "settings.commit.language.description": "提交訊息生成語言(auto = 使用系統語言)", "settings.toolProtocol.description": "用於 AI 互動的工具協議。XML 是預設且推薦的協議。本機是實驗性的,可能不適用於所有提供商。", - "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。" + "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。", + "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。" } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 80465ffac6..ba25854a3f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -395,6 +395,7 @@ export type ExtensionState = Pick< remoteControlEnabled: boolean taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean + debug?: boolean } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index a684d45163..ea67aa8838 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -198,6 +198,8 @@ export interface WebviewMessage { | "showBrowserSessionPanelAtStep" | "refreshBrowserSessionPanel" | "browserPanelDidLaunch" + | "openDebugApiHistory" + | "openDebugUiHistory" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" | "zgsm-account" diff --git a/src/shared/__tests__/modes.spec.ts b/src/shared/__tests__/modes.spec.ts index b00ed986e0..a822b9247e 100644 --- a/src/shared/__tests__/modes.spec.ts +++ b/src/shared/__tests__/modes.spec.ts @@ -339,6 +339,69 @@ describe("isToolAllowedForMode", () => { expect(isToolAllowedForMode("write_to_file", "markdown-editor", customModes, toolRequirements)).toBe(false) }) + + describe("customTools (opt-in tools)", () => { + const customModesWithEditGroup: ModeConfig[] = [ + { + slug: "test-custom-tools", + name: "Test Custom Tools Mode", + roleDefinition: "You are a test mode", + groups: ["read", "edit", "browser"], + }, + ] + + it("disallows customTools by default (not in includedTools)", () => { + // search_and_replace is a customTool in the edit group, should be disallowed by default + expect(isToolAllowedForMode("search_and_replace", "test-custom-tools", customModesWithEditGroup)).toBe( + false, + ) + }) + + it("allows customTools when included in includedTools", () => { + // search_and_replace should be allowed when explicitly included + expect( + isToolAllowedForMode( + "search_and_replace", + "test-custom-tools", + customModesWithEditGroup, + undefined, + undefined, + undefined, + ["search_and_replace"], + ), + ).toBe(true) + }) + + it("disallows customTools even in includedTools if mode doesn't have the group", () => { + const customModesWithoutEdit: ModeConfig[] = [ + { + slug: "no-edit-mode", + name: "No Edit Mode", + roleDefinition: "You have no edit powers", + groups: ["read", "browser"], // No edit group + }, + ] + + // Even if included, should be disallowed because the mode doesn't have edit group + expect( + isToolAllowedForMode( + "search_and_replace", + "no-edit-mode", + customModesWithoutEdit, + undefined, + undefined, + undefined, + ["search_and_replace"], + ), + ).toBe(false) + }) + + it("allows regular tools in the same group as customTools", () => { + // apply_diff (regular tool) should be allowed even without includedTools + expect(isToolAllowedForMode("apply_diff", "test-custom-tools", customModesWithEditGroup)).toBe(true) + expect(isToolAllowedForMode("write_to_file", "test-custom-tools", customModesWithEditGroup)).toBe(true) + }) + }) }) describe("FileRestrictionError", () => { diff --git a/src/shared/modes.ts b/src/shared/modes.ts index 79da4665e6..0acefee01f 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -48,7 +48,7 @@ export function doesFileMatchRegex(filePath: string, pattern: string): boolean { export function getToolsForMode(groups: readonly GroupEntry[]): string[] { const tools = new Set() - // Add tools from each group + // Add tools from each group (excluding customTools which are opt-in only) groups.forEach((group) => { const groupName = getGroupName(group) const groupConfig = TOOL_GROUPS[groupName] @@ -187,6 +187,7 @@ export function isToolAllowedForMode( toolRequirements?: Record, toolParams?: Record, // All tool parameters experiments?: Record, + includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) ): boolean { // Always allow these tools if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { @@ -230,8 +231,14 @@ export function isToolAllowedForMode( return true } - // If the tool isn't in this group's tools, continue to next group - if (!groupConfig.tools.includes(tool)) { + // Check if the tool is in the group's regular tools + const isRegularTool = groupConfig.tools.includes(tool) + + // Check if the tool is a custom tool that has been explicitly included + const isCustomTool = groupConfig.customTools?.includes(tool) && includedTools?.includes(tool) + + // If the tool isn't in regular tools and isn't an included custom tool, continue to next group + if (!isRegularTool && !isCustomTool) { continue } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 6aea5fd311..e993c81dd9 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -71,6 +71,8 @@ export const toolParamNames = [ "prompt", "image", "files", // Native protocol parameter for read_file + "operations", // search_and_replace parameter for multiple operations + "patch", // apply_patch parameter ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -88,6 +90,8 @@ export type NativeToolArgs = { execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } apply_diff: { path: string; diff: string } + search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } + apply_patch: { patch: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -237,6 +241,7 @@ export interface GenerateImageToolUse extends ToolUse<"generate_image"> { export type ToolGroupConfig = { tools: readonly string[] alwaysAvailable?: boolean // Whether this group is always available and shouldn't show in prompts view + customTools?: readonly string[] // Opt-in only tools - only available when explicitly included via model's includedTools } export const TOOL_DISPLAY_NAMES: Record = { @@ -245,6 +250,8 @@ export const TOOL_DISPLAY_NAMES: Record = { fetch_instructions: "fetch instructions", write_to_file: "write files", apply_diff: "apply changes", + search_and_replace: "apply changes using search and replace", + apply_patch: "apply patches using codex format", search_files: "search files", list_files: "list files", list_code_definition_names: "list definitions", @@ -276,6 +283,7 @@ export const TOOL_GROUPS: Record = { }, edit: { tools: ["apply_diff", "write_to_file", "insert_content", "generate_image"], + customTools: ["search_and_replace", "apply_patch"], }, browser: { tools: ["browser_action"], diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 5d0cbfb695..67bd9a1a86 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -377,8 +377,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", + ) + if (isCompletedSubtask) { + setPrimaryButtonText(t("chat:startNewTask.title")) + setSecondaryButtonText(undefined) + } else { + setPrimaryButtonText(t("chat:resumeTask.title")) + setSecondaryButtonText(t("chat:terminate.title")) + } setDidClickCancel(false) // special case where we reset the cancel button state break case "resume_completed_task": @@ -422,6 +436,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (clineAsk === "resume_task" && currentTaskItem?.parentTaskId) { + const hasCompletionResult = messages.some( + (msg) => msg.ask === "completion_result" || msg.say === "completion_result", + ) + if (hasCompletionResult) { + setPrimaryButtonText(t("chat:startNewTask.title")) + setSecondaryButtonText(undefined) + } + } + }, [clineAsk, currentTaskItem?.parentTaskId, messages, t]) + useEffect(() => { if (messages.length === 0) { setSendingDisabled(false) @@ -669,11 +696,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { vscode.postMessage({ @@ -689,6 +712,34 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", + ) + if (isCompletedSubtaskForClick) { + startNewTask() + } else { + // Only send text/images if they exist + if (trimmedInput || (images && images.length > 0)) { + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: trimmedInput, + images: images, + }) + // Clear input state after sending + setInputValue("") + setSelectedImages([]) + } else { + vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) + } + } + break case "completion_result": case "resume_completed_task": // Waiting for feedback, but we can just present a new task button @@ -703,7 +754,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Only filter out the launch ask and result messages - browser actions appear in chat - const result: ClineMessage[] = visibleMessages.filter( - (msg) => - !isBrowserSessionMessage(msg) && - !msg?.metadata?.isRateLimitRetry && // Hide rate limit retries - !["condense_context_error", "shell_integration_warning"].includes(msg.say!) && // Hide shell integration warning - !(msg.type === "say" && msg.say === "reasoning" && !msg.text?.trim()) && // Hide empty reasoning messages - msg.say !== "error" && - apiConfiguration?.apiProvider === "zgsm", // Hide error messages from ZGSM - ) + const result: ClineMessage[] = visibleMessages.filter((msg) => { + if (apiConfiguration?.apiProvider !== "zgsm") { + return !isBrowserSessionMessage(msg) + } + + if (msg.say === "error") return false + + return ( + !isBrowserSessionMessage(msg) || + !msg?.metadata?.isRateLimitRetry || // Hide rate limit retries + !["condense_context_error", "shell_integration_warning"].includes(msg.say!) || // Hide shell integration warning + !(msg.type === "say" && msg.say === "reasoning" && !msg.text?.trim()) + ) // Hide empty reasoning messages + }) if (isCondensing) { result.push({ diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index a1e68324fa..f9f37c4784 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -5,12 +5,12 @@ import type { HistoryItem } from "@roo-code/types" import { vscode } from "@/utils/vscode" import { useCopyToClipboard } from "@/utils/clipboard" +import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { ShareButton } from "./ShareButton" // import { CloudTaskButton } from "./CloudTaskButton" -// import { CloudTaskButton } from "./CloudTaskButton" -import { CopyIcon, DownloadIcon, Trash2Icon } from "lucide-react" +import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" import { LucideIconButton } from "./LucideIconButton" interface TaskActionsProps { @@ -22,6 +22,7 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) const { t } = useTranslation() const { copyWithFeedback } = useCopyToClipboard() + const { debug } = useExtensionState() return (
@@ -68,6 +69,20 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { {/* */} {/* */} + {debug && item?.id && ( + <> + vscode.postMessage({ type: "openDebugApiHistory" })} + /> + vscode.postMessage({ type: "openDebugUiHistory" })} + /> + + )}
) } diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 4d7955f97e..8805fb7ca9 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -462,7 +462,12 @@ describe("ChatView - Focus Grabbing Tests", () => { ], }) - // Clear any initial calls + // Wait for the component to fully render and settle before clearing mocks + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Clear any initial calls after state has settled mockFocus.mockClear() // Add follow-up question @@ -483,7 +488,7 @@ describe("ChatView - Focus Grabbing Tests", () => { ], }) - // Wait a bit to ensure any focus operations would have occurred + // Wait for state update to complete await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx index 607ab9d370..c0562744bb 100644 --- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx @@ -42,6 +42,8 @@ vi.mock("react-i18next", () => ({ "chat:task.connectToCloud": "Connect to Cloud", "chat:task.connectToCloudDescription": "Sign in to Roo Code Cloud to share tasks", "chat:task.sharingDisabledByOrganization": "Sharing disabled by organization", + "chat:task.openApiHistory": "Open API History", + "chat:task.openUiHistory": "Open UI History", "cloud:cloudBenefitsTitle": "Connect to Roo Code Cloud", "cloud:cloudBenefitHistory": "Access your task history from anywhere", "cloud:cloudBenefitSharing": "Share tasks with your team", @@ -381,4 +383,109 @@ describe("TaskActions", () => { expect(deleteButton).toBeDisabled() }) }) + + describe("Debug Buttons", () => { + it("does not render debug buttons when debug is false", () => { + mockUseExtensionState.mockReturnValue({ + sharingEnabled: true, + cloudIsAuthenticated: true, + cloudUserInfo: { organizationName: "Test Organization" }, + debug: false, + } as any) + + render() + + const apiHistoryButton = screen.queryByLabelText("Open API History") + const uiHistoryButton = screen.queryByLabelText("Open UI History") + + expect(apiHistoryButton).not.toBeInTheDocument() + expect(uiHistoryButton).not.toBeInTheDocument() + }) + + it("does not render debug buttons when debug is undefined", () => { + mockUseExtensionState.mockReturnValue({ + sharingEnabled: true, + cloudIsAuthenticated: true, + cloudUserInfo: { organizationName: "Test Organization" }, + } as any) + + render() + + const apiHistoryButton = screen.queryByLabelText("Open API History") + const uiHistoryButton = screen.queryByLabelText("Open UI History") + + expect(apiHistoryButton).not.toBeInTheDocument() + expect(uiHistoryButton).not.toBeInTheDocument() + }) + + it("renders debug buttons when debug is true and item has id", () => { + mockUseExtensionState.mockReturnValue({ + sharingEnabled: true, + cloudIsAuthenticated: true, + cloudUserInfo: { organizationName: "Test Organization" }, + debug: true, + } as any) + + render() + + const apiHistoryButton = screen.getByLabelText("Open API History") + const uiHistoryButton = screen.getByLabelText("Open UI History") + + expect(apiHistoryButton).toBeInTheDocument() + expect(uiHistoryButton).toBeInTheDocument() + }) + + it("does not render debug buttons when debug is true but item has no id", () => { + mockUseExtensionState.mockReturnValue({ + sharingEnabled: true, + cloudIsAuthenticated: true, + cloudUserInfo: { organizationName: "Test Organization" }, + debug: true, + } as any) + + render() + + const apiHistoryButton = screen.queryByLabelText("Open API History") + const uiHistoryButton = screen.queryByLabelText("Open UI History") + + expect(apiHistoryButton).not.toBeInTheDocument() + expect(uiHistoryButton).not.toBeInTheDocument() + }) + + it("sends openDebugApiHistory message when Open API History button is clicked", () => { + mockUseExtensionState.mockReturnValue({ + sharingEnabled: true, + cloudIsAuthenticated: true, + cloudUserInfo: { organizationName: "Test Organization" }, + debug: true, + } as any) + + render() + + const apiHistoryButton = screen.getByLabelText("Open API History") + fireEvent.click(apiHistoryButton) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openDebugApiHistory", + }) + }) + + it("sends openDebugUiHistory message when Open UI History button is clicked", () => { + mockUseExtensionState.mockReturnValue({ + sharingEnabled: true, + cloudIsAuthenticated: true, + cloudUserInfo: { organizationName: "Test Organization" }, + debug: true, + } as any) + + render() + + const uiHistoryButton = screen.getByLabelText("Open UI History") + fireEvent.click(uiHistoryButton) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openDebugUiHistory", + }) + }) + }) }) diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index 949306fa2a..087a790013 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -47,7 +47,8 @@ const TaskItem = ({ key={item.id} data-testid={`task-item-${item.id}`} className={cn( - "cursor-pointer group bg-vscode-editor-background rounded-xl relative overflow-hidden border border-transparent hover:bg-vscode-editor-foreground/10 transition-colors", + "cursor-pointer group bg-vscode-editor-background rounded-xl relative overflow-hidden border hover:bg-vscode-editor-foreground/10 transition-colors", + "border-transparent", className, )} onClick={handleClick}> @@ -80,6 +81,7 @@ const TaskItem = ({ {...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}> {item.highlight ? undefined : item.task} + { uriScheme, useZgsmCustomConfig, setUseZgsmCustomConfig, + cloudIsAuthenticated, } = useExtensionState() + // const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme, cloudIsAuthenticated } = + useExtensionState() const { t } = useAppTranslation() const [errorMessage, setErrorMessage] = useState(undefined) const [selectedProvider, setSelectedProvider] = useState("roo") + const [authInProgress, setAuthInProgress] = useState(false) + const [showManualEntry, setShowManualEntry] = useState(false) + const [manualUrl, setManualUrl] = useState("") + const manualUrlInputRef = useRef(null) + + // When auth completes during the provider signup flow, save the Roo config + // This will cause showWelcome to become false and navigate to chat + useEffect(() => { + if (cloudIsAuthenticated && authInProgress) { + // Auth completed from provider signup flow - save the config now + const rooConfig: ProviderSettings = { + apiProvider: "roo", + } + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: rooConfig, + }) + setAuthInProgress(false) + setShowManualEntry(false) + } + }, [cloudIsAuthenticated, authInProgress, currentApiConfigName]) + + // Focus the manual URL input when it becomes visible + useEffect(() => { + if (showManualEntry && manualUrlInputRef.current) { + setTimeout(() => { + manualUrlInputRef.current?.focus() + }, 50) + } + }, [showManualEntry]) // Memoize the setApiConfigurationField function to pass to ApiOptions const setApiConfigurationFieldForApiOptions = useCallback( @@ -40,20 +75,14 @@ const WelcomeViewProvider = () => { const handleGetStarted = useCallback(() => { if (selectedProvider === "roo") { - // Set the Roo provider configuration - const rooConfig: ProviderSettings = { - apiProvider: "roo", - } - - // Save the Roo provider configuration - vscode.postMessage({ - type: "upsertApiConfiguration", - text: currentApiConfigName, - apiConfiguration: rooConfig, - }) - - // Then trigger cloud sign-in with provider signup flow + // Trigger cloud sign-in with provider signup flow + // NOTE: We intentionally do NOT save the API configuration yet. + // The configuration will be saved by the extension after auth completes. + // This keeps showWelcome true so we can show the waiting state. vscode.postMessage({ type: "rooCloudSignIn", useProviderSignup: true }) + + // Show the waiting state + setAuthInProgress(true) } else { // Use custom provider - validate first const error = apiConfiguration ? validateApiConfiguration(apiConfiguration) : undefined @@ -68,6 +97,104 @@ const WelcomeViewProvider = () => { } }, [selectedProvider, apiConfiguration, currentApiConfigName]) + const handleGoBack = useCallback(() => { + setAuthInProgress(false) + setShowManualEntry(false) + setManualUrl("") + }, []) + + const handleManualUrlChange = (e: any) => { + const url = e.target.value + setManualUrl(url) + + // Auto-trigger authentication when a complete URL is pasted + setTimeout(() => { + if (url.trim() && url.includes("://") && url.includes("/auth/clerk/callback")) { + vscode.postMessage({ type: "rooCloudManualUrl", text: url.trim() }) + } + }, 100) + } + + const handleKeyDown = (e: any) => { + if (e.key === "Enter") { + const url = manualUrl.trim() + if (url && url.includes("://") && url.includes("/auth/clerk/callback")) { + vscode.postMessage({ type: "rooCloudManualUrl", text: url }) + } + } + } + + const handleOpenSignupUrl = () => { + vscode.postMessage({ type: "rooCloudSignIn", useProviderSignup: true }) + } + + // Render the waiting for cloud state + if (authInProgress) { + return ( + + +
+ +

{t("welcome:waitingForCloud.heading")}

+

+ {t("welcome:waitingForCloud.description")} +

+ +

+ + ), + }} + /> +

+ +

+ setShowManualEntry(true)} + className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground underline cursor-pointer bg-transparent border-none p-0 text-sm" + /> + ), + }} + /> +

+ + {showManualEntry && ( +
+

+ {t("welcome:waitingForCloud.pasteUrl")} +

+ +
+ )} +
+
+
+ +
+
+ ) + } + return ( diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 17c9a4e698..b59dedbf05 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -27,7 +27,9 @@ "shareSuccessOrganization": "Organization link copied to clipboard", "shareSuccessPublic": "Public link copied to clipboard", "openInCloud": "Open task in Roo Code Cloud", - "openInCloudIntro": "Keep monitoring or interacting with Roo from anywhere. Scan, click or copy to open." + "openInCloudIntro": "Keep monitoring or interacting with Roo from anywhere. Scan, click or copy to open.", + "openApiHistory": "Open API History", + "openUiHistory": "Open UI History" }, "unpin": "Unpin", "pin": "Pin", diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 77b014eae2..da86e671d7 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "Waited {{timeout}} seconds for checkpoint initialization. If you don't need the checkpoint feature, please turn it off in the checkpoint settings.", "init_checkpoint_fail_long_time": "Checkpoint initialization has taken more than {{timeout}} seconds, so checkpoints are disabled for this task. You can disable checkpoints or extend the waiting time in the checkpoint settings.", "attempt_completion_tool_failed": "Cannot execute attempt_completion because a previous tool call failed in this turn. Please address the tool failure before attempting completion." + }, + "tasks": { + "delegated": "Delegated to subtask", + "delegated_to": "Delegated to task {{childId}}", + "delegation_completed": "Subtask completed, resuming parent", + "awaiting_child": "Awaiting child task {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/en/welcome.json b/webview-ui/src/i18n/locales/en/welcome.json index 705bc92cb7..d5e0ec0739 100644 --- a/webview-ui/src/i18n/locales/en/welcome.json +++ b/webview-ui/src/i18n/locales/en/welcome.json @@ -25,6 +25,14 @@ "useAnotherProviderDescription": "Enter an API key and get going.", "getStarted": "Get started" }, + "waitingForCloud": { + "heading": "Taking you to Roo Code Cloud...", + "description": "Complete sign-up in your browser, then you'll return here automatically.", + "noPrompt": "If you don't get prompted to open a URL, click here.", + "havingTrouble": "If you've completed the sign up but are having trouble, click here.", + "pasteUrl": "Paste the callback URL from your browser:", + "goBack": "Go back" + }, "startRouter": "We recommend using an LLM Router:", "startCustom": "Or you can bring your provider API key:", "telemetry": { diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 5f207b58ef..032b2d6e7e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -27,7 +27,9 @@ "shareSuccessOrganization": "组织链接已复制到剪贴板", "shareSuccessPublic": "公开链接已复制到剪贴板", "openInCloud": "在 Roo Code Cloud 中打开任务", - "openInCloudIntro": "从任何地方继续监控或与 Roo 交互。扫描、点击或复制以打开。" + "openInCloudIntro": "从任何地方继续监控或与 Roo 交互。扫描、点击或复制以打开。", + "openApiHistory": "打开 API 历史", + "openUiHistory": "打开 UI 历史" }, "unpin": "取消置顶", "pin": "置顶", diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 53da4313be..f762a3044e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "初始化存档点已等待 {{timeout}} 秒。如果你不需要存档点功能,请在存档点设置中关闭。", "init_checkpoint_fail_long_time": "存档点初始化已超过 {{timeout}} 秒,因此本任务已禁用存档点。你可以关闭存档点或在存档点设置中延长等待时间。", "attempt_completion_tool_failed": "无法执行 attempt_completion,因为本轮中先前的工具调用失败了。请在尝试完成前解决工具失败问题。" + }, + "tasks": { + "delegated": "已委托给子任务", + "delegated_to": "已委托给任务 {{childId}}", + "delegation_completed": "子任务已完成,恢复父任务", + "awaiting_child": "等待子任务 {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/welcome.json b/webview-ui/src/i18n/locales/zh-CN/welcome.json index 783cc342d8..f21b35ca8a 100644 --- a/webview-ui/src/i18n/locales/zh-CN/welcome.json +++ b/webview-ui/src/i18n/locales/zh-CN/welcome.json @@ -25,6 +25,14 @@ "useAnotherProviderDescription": "输入 API 密钥即可开始。", "getStarted": "开始使用" }, + "waitingForCloud": { + "heading": "正在跳转到 Roo Code Cloud...", + "description": "在浏览器中完成注册,随后你将自动返回此处。", + "noPrompt": "如果你未被提示打开 URL,请点击此处。", + "havingTrouble": "如果你已完成注册但遇到问题,请点击此处。", + "pasteUrl": "从浏览器粘贴回调 URL:", + "goBack": "返回" + }, "startRouter": "我们推荐使用 LLM 路由器:", "startCustom": "或者你可以使用自己的 API 密钥:", "telemetry": { diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index ec482f703d..cc76dcad03 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -27,7 +27,9 @@ "shareSuccessOrganization": "組織連結已複製到剪貼簿", "shareSuccessPublic": "公開連結已複製到剪貼簿", "openInCloud": "在 Roo Code Cloud 中開啟工作", - "openInCloudIntro": "從任何地方繼續監控或與 Roo 互動。掃描、點擊或複製以開啟。" + "openInCloudIntro": "從任何地方繼續監控或與 Roo 互動。掃描、點擊或複製以開啟。", + "openApiHistory": "開啟 API 歷史紀錄", + "openUiHistory": "開啟 UI 歷史紀錄" }, "unpin": "取消釘選", "pin": "釘選", diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 9b2a69c77d..3dd64c1c4c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -100,5 +100,11 @@ "wait_checkpoint_long_time": "初始化存檔點已等待 {{timeout}} 秒。如果你不需要存檔點功能,請在存檔點設定中關閉。", "init_checkpoint_fail_long_time": "存檔點初始化已超過 {{timeout}} 秒,因此此工作已停用存檔點。你可以關閉存檔點或在存檔點設定中延長等待時間。", "attempt_completion_tool_failed": "無法執行 attempt_completion,因為本輪中先前的工具呼叫失敗了。請在嘗試完成前解決工具失敗問題。" + }, + "tasks": { + "delegated": "已委派給子工作", + "delegated_to": "已委派給工作 {{childId}}", + "delegation_completed": "子工作已完成,繼續父工作", + "awaiting_child": "等待子工作 {{childId}}" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/welcome.json b/webview-ui/src/i18n/locales/zh-TW/welcome.json index b03ab1b144..6d3fc0dca0 100644 --- a/webview-ui/src/i18n/locales/zh-TW/welcome.json +++ b/webview-ui/src/i18n/locales/zh-TW/welcome.json @@ -25,6 +25,14 @@ "useAnotherProviderDescription": "輸入 API 金鑰即可開始。", "getStarted": "開始使用" }, + "waitingForCloud": { + "heading": "正在帶您前往 Roo Code Cloud...", + "description": "在瀏覽器中完成註冊,您將自動返回此處。", + "noPrompt": "如果您未被提示開啟 URL,請點擊此處。", + "havingTrouble": "如果您已完成註冊但遇到問題,請點擊此處。", + "pasteUrl": "從瀏覽器貼上回呼 URL:", + "goBack": "返回" + }, "startRouter": "我們建議使用 LLM 路由器:", "startCustom": "或者您可以使用自己的 API 金鑰:", "telemetry": {