From f530ca35c9452ce4ff92f83481e6ae2b81c73980 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 1 May 2025 00:53:57 -0500 Subject: [PATCH 01/10] feat: add new parameters to the new_task tool to allow files to be attached by the parent --- src/core/prompts/tools/new-task.ts | 15 ++++++-- src/core/task/Task.ts | 57 +++++++++++++++++++++++++++++- src/core/tools/newTaskTool.ts | 15 +++++++- src/core/webview/ClineProvider.ts | 1 + src/shared/tools.ts | 3 +- 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/core/prompts/tools/new-task.ts b/src/core/prompts/tools/new-task.ts index 7301b7b422..c1e95f5538 100644 --- a/src/core/prompts/tools/new-task.ts +++ b/src/core/prompts/tools/new-task.ts @@ -2,22 +2,31 @@ import { ToolArgs } from "./types" export function getNewTaskDescription(_args: ToolArgs): string { return `## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. - +- files: (optional) A list of files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path. + Usage: your-mode-slug-here Your initial instructions here + +path1 +path2 + - + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts + ` } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 231a6049ad..a1cfda35b6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -49,6 +49,7 @@ import { findToolName, formatContentBlockToMarkdown } from "../../integrations/m import { RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" + // utils import { calculateApiCostAnthropic } from "../../utils/cost" import { getWorkspacePath } from "../../utils/path" @@ -79,6 +80,8 @@ import { processUserContentMentions } from "../mentions/processUserContentMentio import { ApiMessage } from "../task-persistence/apiMessages" import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" +import { readLines } from '../../integrations/misc/read-lines' +import { addLineNumbers } from '../../integrations/misc/extract-text' export type ClineEvents = { message: [{ action: "created" | "updated"; message: ClineMessage }] @@ -106,6 +109,7 @@ export type TaskOptions = { historyItem?: HistoryItem experiments?: Record startTask?: boolean + attachedFiles?: string[] rootTask?: Task parentTask?: Task taskNumber?: number @@ -121,6 +125,8 @@ export class Task extends EventEmitter { readonly taskNumber: number readonly workspacePath: string + public readonly attachedFiles: string[] = [] + providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false @@ -196,6 +202,7 @@ export class Task extends EventEmitter { consecutiveMistakeLimit = 3, task, images, + attachedFiles, historyItem, startTask = true, rootTask, @@ -240,6 +247,7 @@ export class Task extends EventEmitter { this.rootTask = rootTask this.parentTask = parentTask this.taskNumber = taskNumber + this.attachedFiles = attachedFiles || [] if (historyItem) { telemetryService.captureTaskRestarted(this.taskId) @@ -666,6 +674,11 @@ export class Task extends EventEmitter { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() + const providerState = await this.providerRef.deref()?.getState() + const workspaceRoot = this.cwd + const maxReadFileLine = providerState?.maxReadFileLine ?? 500 // Default to 500 + const isFullRead = maxReadFileLine === -1 + await this.say("text", task, images) this.isInitialized = true @@ -673,13 +686,55 @@ export class Task extends EventEmitter { console.log(`[subtasks] task ${this.taskId}.${this.instanceId} starting`) + const fileContentBlocks: Anthropic.TextBlockParam[] = [] + for (const relativeFilePath of this.attachedFiles) { + if (!relativeFilePath) continue // Skip empty paths + const absolutePath = path.join(workspaceRoot, relativeFilePath) + try { + let content = "" + let notice = "" + let linesArray: string = "" + + if (!isFullRead && maxReadFileLine > 0) { + // Read up to maxReadFileLine lines (0-based index for endLine) + linesArray = await readLines(absolutePath, maxReadFileLine - 1, 0) + if (linesArray.length >= maxReadFileLine) { + notice = `\nFile content limited to first ${maxReadFileLine} lines.` + } + } else if (isFullRead || maxReadFileLine === 0) { + // Handle full read or definitions-only (max=0) + // If maxReadFileLine is 0, readLines defaults to reading all lines, which is fine. + // We just won't display the content later if maxReadFileLine is 0. + linesArray = await readLines(absolutePath) + } + + // Only add content if maxReadFileLine is not 0 + if (maxReadFileLine !== 0 && linesArray) { + content = addLineNumbers(linesArray) + } else { + // Add notice for definitions-only mode + notice = `\nFile content omitted (maxReadFileLine is 0).` + } + + const formattedText = `\n${content}${notice}\n` + fileContentBlocks.push({ type: "text", text: formattedText }) + } catch (error) { + console.warn(`[startTask] Failed to read attached file ${relativeFilePath}:`, error) + // Add an error block to the prompt to inform the AI + const errorText = `\nFailed to read file: ${error instanceof Error ? error.message : String(error)}\n` + fileContentBlocks.push({ type: "text", text: errorText }) + } + } + await this.initiateTaskLoop([ + ...fileContentBlocks, { type: "text", - text: `\n${task}\n`, + text: `\n${task ?? ""}\n`, }, ...imageBlocks, ]) + } public async resumePausedTask(lastMessage: string) { diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index 38b4cbf302..200ab4e539 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -15,6 +15,7 @@ export async function newTaskTool( ) { const mode: string | undefined = block.params.mode const message: string | undefined = block.params.message + const filesParam: string | undefined = block.params.files try { if (block.partial) { @@ -78,7 +79,19 @@ export async function newTaskTool( // Delay to allow mode change to take effect before next tool is executed. await delay(500) - const newCline = await provider.initClineWithTask(message, undefined, cline) + let attachedFiles: string[] = [] + if (filesParam && filesParam.trim()) { + const fileRegex = /(.*?)<\/file>/g + for (const match of filesParam.matchAll(fileRegex)) { + attachedFiles.push(match[1]) + } + } + + const newCline = await provider.initClineWithTask(message, undefined, cline, { + attachedFiles, + enableDiff: true, + enableCheckpoints: true, + }) cline.emit("taskSpawned", newCline.taskId) pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${message}`) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 43f56b8fa5..0cd5d3f78d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -478,6 +478,7 @@ export class ClineProvider extends EventEmitter implements Pick< TaskOptions, "enableDiff" | "enableCheckpoints" | "fuzzyMatchThreshold" | "consecutiveMistakeLimit" | "experiments" + | "attachedFiles" > > = {}, ) { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 37ab53516e..143433f753 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -64,6 +64,7 @@ export const toolParamNames = [ "start_line", "end_line", "query", + "files", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -154,7 +155,7 @@ export interface SwitchModeToolUse extends ToolUse { export interface NewTaskToolUse extends ToolUse { name: "new_task" - params: Partial, "mode" | "message">> + params: Partial, "mode" | "message" | "files">> } export interface SearchAndReplaceToolUse extends ToolUse { From 0fc399ba1d7eb3b59444fa5f013cd6d8dd1ec33b Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 1 May 2025 22:26:04 -0500 Subject: [PATCH 02/10] feat: show files attached in subtask block and allow line ranges --- src/core/prompts/tools/new-task.ts | 12 +- src/core/task/Task.ts | 178 +++++++++++++----- src/core/tools/newTaskTool.ts | 36 +++- src/core/webview/ClineProvider.ts | 6 +- src/shared/ExtensionMessage.ts | 2 + src/shared/tools.ts | 9 + webview-ui/src/components/chat/ChatRow.tsx | 30 ++- .../src/context/ExtensionStateContext.tsx | 1 + 8 files changed, 207 insertions(+), 67 deletions(-) diff --git a/src/core/prompts/tools/new-task.ts b/src/core/prompts/tools/new-task.ts index c1e95f5538..40462e8b64 100644 --- a/src/core/prompts/tools/new-task.ts +++ b/src/core/prompts/tools/new-task.ts @@ -7,25 +7,25 @@ Description: This will let you create a new task instance in the chosen mode usi Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. -- files: (optional) A list of files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path. - +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). + Usage: your-mode-slug-here Your initial instructions here -path1 -path2 +path/without/range.js +path/with/range.py:25:100 - + Example: code Implement a new feature for the application. src/somefile.ts -src/anotherfile.ts +src/anotherfile.ts:10:50 ` diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a1cfda35b6..a9c14af45c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -33,7 +33,7 @@ import { getApiMetrics } from "../../shared/getApiMetrics" import { HistoryItem } from "../../shared/HistoryItem" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" -import { DiffStrategy } from "../../shared/tools" +import { DiffStrategy, AttachedFileSpec } from "../../shared/tools" // services import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" @@ -106,10 +106,10 @@ export type TaskOptions = { consecutiveMistakeLimit?: number task?: string images?: string[] + attachedFiles?: string[] | AttachedFileSpec[] historyItem?: HistoryItem experiments?: Record startTask?: boolean - attachedFiles?: string[] rootTask?: Task parentTask?: Task taskNumber?: number @@ -124,8 +124,7 @@ export class Task extends EventEmitter { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string - - public readonly attachedFiles: string[] = [] + public readonly attachedFiles: (string | AttachedFileSpec)[] = [] providerRef: WeakRef private readonly globalStoragePath: string @@ -663,6 +662,123 @@ export class Task extends EventEmitter { // Start / Abort / Resume + /** + * Formats the content of attached files for inclusion in the task prompt + * @param workspaceRoot The root directory of the workspace + * @param maxReadFileLine Maximum number of lines to read from each file + * @returns Formatted string containing all attached files content + */ + private async _formatAttachedFilesContent(workspaceRoot: string, maxReadFileLine: number): Promise { + const isFullRead = maxReadFileLine === -1 + let attachedFilesContent = "" + + for (const fileSpec of this.attachedFiles) { + // Handle both string and AttachedFileSpec types + const isString = typeof fileSpec === "string" + const relativeFilePath = isString ? fileSpec : fileSpec.path + + // Get line range information + const hasSpecificRange = !isString && (fileSpec.startLine !== undefined || fileSpec.endLine !== undefined) + const startLine0Based = isString ? 0 : fileSpec.startLine ? Math.max(0, fileSpec.startLine - 1) : 0 + const requestedEndLine = isString ? undefined : fileSpec.endLine + + // Convert to 1-based for display + const startLine1Based = startLine0Based + 1 + + if (!relativeFilePath) continue // Skip empty paths + + const absolutePath = path.join(workspaceRoot, relativeFilePath) + try { + // If maxReadFileLine is 0 and no specific range was requested, just include the file path + if (maxReadFileLine === 0 && !hasSpecificRange) { + attachedFilesContent += `${relativeFilePath}\n` + continue + } + + // Otherwise, attempt to read the file content + let linesArray: string = "" + let actualEndLine0Based = 0 + let actualEndLine1Based = 0 + let truncationNotice = "" + + // Determine effective end line based on settings and requested range + if (!isFullRead && maxReadFileLine > 0) { + // If we have a specific end line from AttachedFileSpec, use it + // otherwise use maxReadFileLine + const effectiveEndLine0Based = requestedEndLine + ? Math.min(requestedEndLine - 1, maxReadFileLine - 1) + : maxReadFileLine - 1 + + // Read the specified line range + linesArray = await readLines(absolutePath, effectiveEndLine0Based, startLine0Based) + + // Recalculate actual end line based on content read + truncationNotice = "" + if (linesArray) { + actualEndLine0Based = startLine0Based + linesArray.split("\n").length - 1 + } else { + actualEndLine0Based = startLine0Based - 1 + } + actualEndLine1Based = actualEndLine0Based + 1 + + // Add truncation notice if we hit the maxReadFileLine limit (but only if no specific end line was requested) + if (!requestedEndLine && maxReadFileLine > 0 && linesArray.split("\n").length >= maxReadFileLine) { + truncationNotice = " File content truncated by max lines setting." + } else if (requestedEndLine && actualEndLine1Based < requestedEndLine) { + truncationNotice = ` File ended before requested line ${requestedEndLine}.` + } + } else if (isFullRead) { + // Handle full read, but respect startLine and endLine if provided + if (requestedEndLine) { + const requestedEndLine0Based = requestedEndLine - 1 + linesArray = await readLines(absolutePath, requestedEndLine0Based, startLine0Based) + + // Recalculate actual end line based on content read + truncationNotice = "" + if (linesArray) { + actualEndLine0Based = startLine0Based + linesArray.split("\n").length - 1 + } else { + actualEndLine0Based = startLine0Based - 1 + } + actualEndLine1Based = actualEndLine0Based + 1 + + // Add notice if file ended before requested line + if (actualEndLine1Based < requestedEndLine) { + truncationNotice = ` File ended before requested line ${requestedEndLine}.` + } + } else { + linesArray = await readLines(absolutePath, undefined, startLine0Based) + + // Calculate actual end line based on content read + truncationNotice = "" + if (linesArray) { + actualEndLine0Based = startLine0Based + linesArray.split("\n").length - 1 + } else { + actualEndLine0Based = startLine0Based - 1 + } + actualEndLine1Based = actualEndLine0Based + 1 + } + } + + // Add header line with path and line range (using 1-based line numbers for display) + attachedFilesContent += `${relativeFilePath}:${startLine1Based}:${actualEndLine1Based}${truncationNotice}\n` + + // Add the file content in a markdown code block + if (linesArray) { + const content = addLineNumbers(linesArray, startLine1Based) + attachedFilesContent += "```\n" + content + "```\n\n" + } + } catch (error) { + console.warn(`[_formatAttachedFilesContent] Failed to read attached file ${relativeFilePath}:`, error) + // Add an error line with line range information if specified + const rangeInfo = hasSpecificRange ? ` (lines ${startLine1Based}-${requestedEndLine || "end"})` : "" + attachedFilesContent += `${relativeFilePath}${rangeInfo} Failed to read file: ${error instanceof Error ? error.message : String(error)}\n\n` + } + } + + return attachedFilesContent + } + private async startTask(task?: string, images?: string[]): Promise { // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. @@ -677,7 +793,6 @@ export class Task extends EventEmitter { const providerState = await this.providerRef.deref()?.getState() const workspaceRoot = this.cwd const maxReadFileLine = providerState?.maxReadFileLine ?? 500 // Default to 500 - const isFullRead = maxReadFileLine === -1 await this.say("text", task, images) this.isInitialized = true @@ -686,55 +801,20 @@ export class Task extends EventEmitter { console.log(`[subtasks] task ${this.taskId}.${this.instanceId} starting`) - const fileContentBlocks: Anthropic.TextBlockParam[] = [] - for (const relativeFilePath of this.attachedFiles) { - if (!relativeFilePath) continue // Skip empty paths - const absolutePath = path.join(workspaceRoot, relativeFilePath) - try { - let content = "" - let notice = "" - let linesArray: string = "" + const taskBlock = `\n${task ?? ""}\n` - if (!isFullRead && maxReadFileLine > 0) { - // Read up to maxReadFileLine lines (0-based index for endLine) - linesArray = await readLines(absolutePath, maxReadFileLine - 1, 0) - if (linesArray.length >= maxReadFileLine) { - notice = `\nFile content limited to first ${maxReadFileLine} lines.` - } - } else if (isFullRead || maxReadFileLine === 0) { - // Handle full read or definitions-only (max=0) - // If maxReadFileLine is 0, readLines defaults to reading all lines, which is fine. - // We just won't display the content later if maxReadFileLine is 0. - linesArray = await readLines(absolutePath) - } + const attachedFilesContent = await this._formatAttachedFilesContent(workspaceRoot, maxReadFileLine) - // Only add content if maxReadFileLine is not 0 - if (maxReadFileLine !== 0 && linesArray) { - content = addLineNumbers(linesArray) - } else { - // Add notice for definitions-only mode - notice = `\nFile content omitted (maxReadFileLine is 0).` - } + const attachedFilesBlock = + this.attachedFiles.length > 0 ? `\n${attachedFilesContent}` : "" - const formattedText = `\n${content}${notice}\n` - fileContentBlocks.push({ type: "text", text: formattedText }) - } catch (error) { - console.warn(`[startTask] Failed to read attached file ${relativeFilePath}:`, error) - // Add an error block to the prompt to inform the AI - const errorText = `\nFailed to read file: ${error instanceof Error ? error.message : String(error)}\n` - fileContentBlocks.push({ type: "text", text: errorText }) - } + const blocks: Anthropic.ContentBlockParam[] = [{ type: "text", text: taskBlock }, ...imageBlocks] + + if (attachedFilesBlock) { + blocks.push({ type: "text", text: attachedFilesBlock }) } - await this.initiateTaskLoop([ - ...fileContentBlocks, - { - type: "text", - text: `\n${task ?? ""}\n`, - }, - ...imageBlocks, - ]) - + await this.initiateTaskLoop(blocks) } public async resumePausedTask(lastMessage: string) { diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index 200ab4e539..c7161b3032 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -1,6 +1,6 @@ import delay from "delay" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" +import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, AttachedFileSpec } from "../../shared/tools" import { Task } from "../task/Task" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" @@ -52,10 +52,36 @@ export async function newTaskTool( return } + let attachedFiles: AttachedFileSpec[] = [] + if (filesParam && filesParam.trim()) { + const fileRegex = /(.*?)<\/file>/g + for (const match of filesParam.matchAll(fileRegex)) { + const fileString = match[1] + // Parse the file string to extract path and optional line range + // Format could be: path/to/file.js or path/to/file.js:10:20 + const rangeRegex = /^(.*?)(?::(\d+):(\d+))?$/ + const rangeMatch = fileString.match(rangeRegex) + + if (rangeMatch) { + const [, filePath, startLineStr, endLineStr] = rangeMatch + const fileSpec: AttachedFileSpec = { path: filePath } + + // Convert line numbers to numbers if they exist + if (startLineStr && endLineStr) { + fileSpec.startLine = parseInt(startLineStr, 10) + fileSpec.endLine = parseInt(endLineStr, 10) + } + + attachedFiles.push(fileSpec) + } + } + } + const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, + files: attachedFiles, }) const didApprove = await askApproval("tool", toolMessage) @@ -79,14 +105,6 @@ export async function newTaskTool( // Delay to allow mode change to take effect before next tool is executed. await delay(500) - let attachedFiles: string[] = [] - if (filesParam && filesParam.trim()) { - const fileRegex = /(.*?)<\/file>/g - for (const match of filesParam.matchAll(fileRegex)) { - attachedFiles.push(match[1]) - } - } - const newCline = await provider.initClineWithTask(message, undefined, cline, { attachedFiles, enableDiff: true, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0cd5d3f78d..3fedc09ba1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -24,6 +24,7 @@ import { import { t } from "../../i18n" import { setPanel } from "../../activate/registerCommands" import { requestyDefaultModelId, openRouterDefaultModelId, glamaDefaultModelId } from "../../shared/api" +import { AttachedFileSpec } from "../../shared/tools" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" @@ -480,7 +481,9 @@ export class ClineProvider extends EventEmitter implements "enableDiff" | "enableCheckpoints" | "fuzzyMatchThreshold" | "consecutiveMistakeLimit" | "experiments" | "attachedFiles" > - > = {}, + > & { + attachedFiles?: (string | AttachedFileSpec)[] + } = {}, ) { const { apiConfiguration, @@ -1297,6 +1300,7 @@ export class ClineProvider extends EventEmitter implements ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId) : undefined, clineMessages: this.getCurrentCline()?.clineMessages || [], + attachedFiles: this.getCurrentCline()?.attachedFiles || [], taskHistory: (taskHistory || []) .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index cd1efbe983..5785b1a59e 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -16,6 +16,7 @@ import { import { McpServer } from "./mcp" import { Mode } from "./modes" import { RouterModels } from "./api" +import { AttachedFileSpec } from "./tools" export type { ProviderSettingsEntry, ToolProgressStatus } @@ -252,6 +253,7 @@ export interface ClineSayTool { endLine?: number lineNumber?: number query?: string + files?: AttachedFileSpec[] } // Must keep in sync with system prompt. diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 143433f753..28b71fd470 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -26,6 +26,15 @@ export interface TextContent { partial: boolean } +/** + * Represents a file to be attached to a task, optionally with line range specifications + */ +export interface AttachedFileSpec { + path: string + startLine?: number + endLine?: number +} + export const toolParamNames = [ "command", "path", diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f9522901b7..a5b09a28a5 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -571,6 +571,7 @@ export const ChatRowContent = ({ ) case "newTask": + const files = tool.files return ( <>
@@ -588,9 +589,8 @@ export const ChatRowContent = ({ marginTop: "4px", backgroundColor: "var(--vscode-badge-background)", border: "1px solid var(--vscode-badge-background)", - borderRadius: "4px 4px 0 0", + borderRadius: "4px", overflow: "hidden", - marginBottom: "2px", }}>
+ + {files && files.length > 0 && ( + <> +
+ {files.map((file) => { + return ( + + @ + {file.path.split("/").pop() + + (file.endLine + ? `:${file.startLine}-${file.endLine}` + : file.startLine + ? `:${file.startLine}` + : "")} + + ) + })} +
+ + )}
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 53629f942e..250bef5ad5 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -24,6 +24,7 @@ export interface ExtensionStateContextType extends ExtensionState { hasSystemPromptOverride?: boolean currentCheckpoint?: string filePaths: string[] + attachedFiles?: string[] openedTabs: Array<{ label: string; isActive: boolean; path?: string }> condensingApiConfigId?: string setCondensingApiConfigId: (value: string) => void From c37fe98ad51b5d646dee97d40de1255668c9baff Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 1 May 2025 22:48:16 -0500 Subject: [PATCH 03/10] feat: open file and line range if available when clicking the badges --- webview-ui/src/components/chat/ChatRow.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index a5b09a28a5..52eb9dbdc8 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -621,9 +621,20 @@ export const ChatRowContent = ({ }}> {files.map((file) => { return ( - + { + vscode.postMessage({ + type: "openFile", + text: "./" + file.path, + values: { + line: file.startLine, + }, + }) + }}> @ - {file.path.split("/").pop() + + {file.path.replace(/\\/g, "/").split("/").pop() + (file.endLine ? `:${file.startLine}-${file.endLine}` : file.startLine From 49606f16e56923267d30046412ab756c40cbfcf9 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 1 May 2025 23:05:23 -0500 Subject: [PATCH 04/10] tests: update snapshot for system prompt --- .../__snapshots__/system.test.ts.snap | 144 ++++++++++++++++-- 1 file changed, 132 insertions(+), 12 deletions(-) diff --git a/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap b/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap index 4885b93866..71a0b0577a 100644 --- a/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap +++ b/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap @@ -341,22 +341,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -814,22 +824,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -1287,22 +1307,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -1813,22 +1843,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -2338,22 +2378,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -2883,22 +2933,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -3447,22 +3507,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -3920,22 +3990,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -4484,22 +4564,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -4963,22 +5053,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -5324,22 +5424,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + @@ -5879,22 +5989,32 @@ Example: Requesting to switch to code mode ## new_task -Description: This will let you create a new task instance in the chosen mode using your provided message. +Description: This will let you create a new task instance in the chosen mode using your provided message and attached files. +Description: Create a new task with a specified starting mode and initial message. This tool instructs the system to create a new Cline instance in the given mode with the provided message and attached files. Parameters: - mode: (required) The slug of the mode to start the new task in (e.g., "code", "debug", "architect"). - message: (required) The initial user message or instructions for this new task. +- files: (optional) A list of relevant files to include in the new task. Use a parent tag containing one or more tags, each with a relative workspace path, optionally followed by \`:startLine:endLine\` to specify a range (e.g., \`path/to/file.ts:10:50\`) if needed). Usage: your-mode-slug-here Your initial instructions here + +path/without/range.js +path/with/range.py:25:100 + Example: code Implement a new feature for the application. + +src/somefile.ts +src/anotherfile.ts:10:50 + From 2be78a9552bd7f812c2e2b59422b9636c77f01e7 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 1 May 2025 23:20:20 -0500 Subject: [PATCH 05/10] refactor: add a default value for attached files in the state --- src/shared/ExtensionMessage.ts | 2 ++ webview-ui/src/context/ExtensionStateContext.tsx | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5785b1a59e..6cb4f43c94 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -189,6 +189,8 @@ export type ExtensionState = Pick< taskHistory: HistoryItem[] + attachedFiles?: AttachedFileSpec[] + writeDelayMs: number requestDelaySeconds: number diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 250bef5ad5..1e66281257 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -11,6 +11,7 @@ import { CustomSupportPrompts } from "@roo/shared/support-prompt" import { experimentDefault, ExperimentId } from "@roo/shared/experiments" import { TelemetrySetting } from "@roo/shared/TelemetrySetting" import { RouterModels } from "@roo/shared/api" +import { AttachedFileSpec } from "../../../src/shared/tools" import { vscode } from "@src/utils/vscode" import { convertTextMateToHljs } from "@src/utils/textMateToHljs" @@ -24,7 +25,7 @@ export interface ExtensionStateContextType extends ExtensionState { hasSystemPromptOverride?: boolean currentCheckpoint?: string filePaths: string[] - attachedFiles?: string[] + attachedFiles?: AttachedFileSpec[] openedTabs: Array<{ label: string; isActive: boolean; path?: string }> condensingApiConfigId?: string setCondensingApiConfigId: (value: string) => void @@ -194,6 +195,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode codebaseIndexEmbedderModelId: "", }, codebaseIndexModels: { ollama: {}, openai: {} }, + attachedFiles: [], }) const [didHydrateState, setDidHydrateState] = useState(false) From c39451f982fd1da65b6455c3db280b6602c084d3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 1 May 2025 23:23:41 -0500 Subject: [PATCH 06/10] fix: use correct types --- src/core/task/Task.ts | 4 ++-- src/core/webview/ClineProvider.ts | 2 +- src/shared/ExtensionMessage.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a9c14af45c..ec01bdacf9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -106,7 +106,7 @@ export type TaskOptions = { consecutiveMistakeLimit?: number task?: string images?: string[] - attachedFiles?: string[] | AttachedFileSpec[] + attachedFiles?: AttachedFileSpec[] historyItem?: HistoryItem experiments?: Record startTask?: boolean @@ -124,7 +124,7 @@ export class Task extends EventEmitter { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string - public readonly attachedFiles: (string | AttachedFileSpec)[] = [] + public readonly attachedFiles: AttachedFileSpec[] = [] providerRef: WeakRef private readonly globalStoragePath: string diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3fedc09ba1..f80b2b980a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -482,7 +482,7 @@ export class ClineProvider extends EventEmitter implements | "attachedFiles" > > & { - attachedFiles?: (string | AttachedFileSpec)[] + attachedFiles?: AttachedFileSpec[] } = {}, ) { const { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 6cb4f43c94..1f484d7341 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -35,6 +35,7 @@ export interface ExtensionMessage { | "action" | "state" | "selectedImages" + | "attachedFiles" | "theme" | "workspaceUpdated" | "invoke" From c8018e74516c328a18861a2aa976e3a8dca7538b Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Tue, 13 May 2025 15:21:58 -0500 Subject: [PATCH 07/10] feat: move read file logic to shared file --- src/core/task/Task.ts | 133 +++------------- src/core/tools/readFileTool.ts | 180 ++++------------------ src/shared/fileReadUtils.ts | 269 +++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 262 deletions(-) create mode 100644 src/shared/fileReadUtils.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ec01bdacf9..972dc7f28a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -49,7 +49,6 @@ import { findToolName, formatContentBlockToMarkdown } from "../../integrations/m import { RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" - // utils import { calculateApiCostAnthropic } from "../../utils/cost" import { getWorkspacePath } from "../../utils/path" @@ -80,8 +79,7 @@ import { processUserContentMentions } from "../mentions/processUserContentMentio import { ApiMessage } from "../task-persistence/apiMessages" import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" -import { readLines } from '../../integrations/misc/read-lines' -import { addLineNumbers } from '../../integrations/misc/extract-text' +import { processFileForReading, formatProcessedFileResultToString } from "../../shared/fileReadUtils" export type ClineEvents = { message: [{ action: "created" | "updated"; message: ClineMessage }] @@ -201,7 +199,7 @@ export class Task extends EventEmitter { consecutiveMistakeLimit = 3, task, images, - attachedFiles, + attachedFiles, historyItem, startTask = true, rootTask, @@ -246,7 +244,7 @@ export class Task extends EventEmitter { this.rootTask = rootTask this.parentTask = parentTask this.taskNumber = taskNumber - this.attachedFiles = attachedFiles || [] + this.attachedFiles = attachedFiles || [] if (historyItem) { telemetryService.captureTaskRestarted(this.taskId) @@ -669,114 +667,33 @@ export class Task extends EventEmitter { * @returns Formatted string containing all attached files content */ private async _formatAttachedFilesContent(workspaceRoot: string, maxReadFileLine: number): Promise { - const isFullRead = maxReadFileLine === -1 - let attachedFilesContent = "" + const attachedFilesStrings: string[] = [] for (const fileSpec of this.attachedFiles) { // Handle both string and AttachedFileSpec types - const isString = typeof fileSpec === "string" - const relativeFilePath = isString ? fileSpec : fileSpec.path - - // Get line range information - const hasSpecificRange = !isString && (fileSpec.startLine !== undefined || fileSpec.endLine !== undefined) - const startLine0Based = isString ? 0 : fileSpec.startLine ? Math.max(0, fileSpec.startLine - 1) : 0 - const requestedEndLine = isString ? undefined : fileSpec.endLine - - // Convert to 1-based for display - const startLine1Based = startLine0Based + 1 - - if (!relativeFilePath) continue // Skip empty paths - - const absolutePath = path.join(workspaceRoot, relativeFilePath) - try { - // If maxReadFileLine is 0 and no specific range was requested, just include the file path - if (maxReadFileLine === 0 && !hasSpecificRange) { - attachedFilesContent += `${relativeFilePath}\n` - continue - } - - // Otherwise, attempt to read the file content - let linesArray: string = "" - let actualEndLine0Based = 0 - let actualEndLine1Based = 0 - let truncationNotice = "" - - // Determine effective end line based on settings and requested range - if (!isFullRead && maxReadFileLine > 0) { - // If we have a specific end line from AttachedFileSpec, use it - // otherwise use maxReadFileLine - const effectiveEndLine0Based = requestedEndLine - ? Math.min(requestedEndLine - 1, maxReadFileLine - 1) - : maxReadFileLine - 1 - - // Read the specified line range - linesArray = await readLines(absolutePath, effectiveEndLine0Based, startLine0Based) - - // Recalculate actual end line based on content read - truncationNotice = "" - if (linesArray) { - actualEndLine0Based = startLine0Based + linesArray.split("\n").length - 1 - } else { - actualEndLine0Based = startLine0Based - 1 - } - actualEndLine1Based = actualEndLine0Based + 1 - - // Add truncation notice if we hit the maxReadFileLine limit (but only if no specific end line was requested) - if (!requestedEndLine && maxReadFileLine > 0 && linesArray.split("\n").length >= maxReadFileLine) { - truncationNotice = " File content truncated by max lines setting." - } else if (requestedEndLine && actualEndLine1Based < requestedEndLine) { - truncationNotice = ` File ended before requested line ${requestedEndLine}.` - } - } else if (isFullRead) { - // Handle full read, but respect startLine and endLine if provided - if (requestedEndLine) { - const requestedEndLine0Based = requestedEndLine - 1 - linesArray = await readLines(absolutePath, requestedEndLine0Based, startLine0Based) - - // Recalculate actual end line based on content read - truncationNotice = "" - if (linesArray) { - actualEndLine0Based = startLine0Based + linesArray.split("\n").length - 1 - } else { - actualEndLine0Based = startLine0Based - 1 - } - actualEndLine1Based = actualEndLine0Based + 1 - - // Add notice if file ended before requested line - if (actualEndLine1Based < requestedEndLine) { - truncationNotice = ` File ended before requested line ${requestedEndLine}.` - } - } else { - linesArray = await readLines(absolutePath, undefined, startLine0Based) - - // Calculate actual end line based on content read - truncationNotice = "" - if (linesArray) { - actualEndLine0Based = startLine0Based + linesArray.split("\n").length - 1 - } else { - actualEndLine0Based = startLine0Based - 1 - } - actualEndLine1Based = actualEndLine0Based + 1 - } - } - - // Add header line with path and line range (using 1-based line numbers for display) - attachedFilesContent += `${relativeFilePath}:${startLine1Based}:${actualEndLine1Based}${truncationNotice}\n` + const relativePath = typeof fileSpec === "string" ? fileSpec : fileSpec.path + if (!relativePath) continue // Skip empty paths + + const requestedStartLine = typeof fileSpec === "string" ? undefined : fileSpec.startLine + const requestedEndLine = typeof fileSpec === "string" ? undefined : fileSpec.endLine + const absolutePath = path.join(workspaceRoot, relativePath) + + // Process the file using shared utilities + const result = await processFileForReading( + absolutePath, + relativePath, + maxReadFileLine, + requestedStartLine, + requestedEndLine, + this.rooIgnoreController, + ) - // Add the file content in a markdown code block - if (linesArray) { - const content = addLineNumbers(linesArray, startLine1Based) - attachedFilesContent += "```\n" + content + "```\n\n" - } - } catch (error) { - console.warn(`[_formatAttachedFilesContent] Failed to read attached file ${relativeFilePath}:`, error) - // Add an error line with line range information if specified - const rangeInfo = hasSpecificRange ? ` (lines ${startLine1Based}-${requestedEndLine || "end"})` : "" - attachedFilesContent += `${relativeFilePath}${rangeInfo} Failed to read file: ${error instanceof Error ? error.message : String(error)}\n\n` - } + // Format the result to string + const fileString = formatProcessedFileResultToString(result) + attachedFilesStrings.push(fileString) } - return attachedFilesContent + return attachedFilesStrings.join("\n") } private async startTask(task?: string, images?: string[]): Promise { @@ -790,7 +707,7 @@ export class Task extends EventEmitter { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() - const providerState = await this.providerRef.deref()?.getState() + const providerState = await this.providerRef.deref()?.getState() const workspaceRoot = this.cwd const maxReadFileLine = providerState?.maxReadFileLine ?? 500 // Default to 500 diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 67fd4b5e96..b5280c52b9 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -1,18 +1,13 @@ import path from "path" -import { isBinaryFile } from "isbinaryfile" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" -import { countFileLines } from "../../integrations/misc/line-counter" -import { readLines } from "../../integrations/misc/read-lines" -import { extractTextFromFile, addLineNumbers } from "../../integrations/misc/extract-text" -import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" +import { processFileForReading, formatProcessedFileResultToString } from "../../shared/fileReadUtils" export async function readFileTool( cline: Task, @@ -52,56 +47,30 @@ export async function readFileTool( const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} const isFullRead = maxReadFileLine === -1 - // Check if we're doing a line range read - let isRangeRead = false - let startLine: number | undefined = undefined - let endLine: number | undefined = undefined - - // Check if we have either range parameter and we're not doing a full read - if (!isFullRead && (startLineStr || endLineStr)) { - isRangeRead = true - } - - // Parse start_line if provided + // Parse start_line if provided (keep as 1-based) + let requestedStartLine: number | undefined = undefined if (startLineStr) { - startLine = parseInt(startLineStr) - - if (isNaN(startLine)) { - // Invalid start_line + requestedStartLine = parseInt(startLineStr) + if (isNaN(requestedStartLine)) { cline.consecutiveMistakeCount++ cline.recordToolError("read_file") await cline.say("error", `Failed to parse start_line: ${startLineStr}`) pushToolResult(`${relPath}Invalid start_line value`) return } - - startLine -= 1 // Convert to 0-based index } - // Parse end_line if provided + // Parse end_line if provided (keep as 1-based) + let requestedEndLine: number | undefined = undefined if (endLineStr) { - endLine = parseInt(endLineStr) - - if (isNaN(endLine)) { - // Invalid end_line + requestedEndLine = parseInt(endLineStr) + if (isNaN(requestedEndLine)) { cline.consecutiveMistakeCount++ cline.recordToolError("read_file") await cline.say("error", `Failed to parse end_line: ${endLineStr}`) pushToolResult(`${relPath}Invalid end_line value`) return } - - // Convert to 0-based index - endLine -= 1 - } - - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - pushToolResult(`${relPath}${errorMsg}`) - return } // Create line snippet description for approval message @@ -109,12 +78,12 @@ export async function readFileTool( if (isFullRead) { // No snippet for full read - } else if (startLine !== undefined && endLine !== undefined) { - lineSnippet = t("tools:readFile.linesRange", { start: startLine + 1, end: endLine + 1 }) - } else if (startLine !== undefined) { - lineSnippet = t("tools:readFile.linesFromToEnd", { start: startLine + 1 }) - } else if (endLine !== undefined) { - lineSnippet = t("tools:readFile.linesFromStartTo", { end: endLine + 1 }) + } else if (requestedStartLine !== undefined && requestedEndLine !== undefined) { + lineSnippet = t("tools:readFile.linesRange", { start: requestedStartLine, end: requestedEndLine }) + } else if (requestedStartLine !== undefined) { + lineSnippet = t("tools:readFile.linesFromToEnd", { start: requestedStartLine }) + } else if (requestedEndLine !== undefined) { + lineSnippet = t("tools:readFile.linesFromStartTo", { end: requestedEndLine }) } else if (maxReadFileLine === 0) { lineSnippet = t("tools:readFile.definitionsOnly") } else if (maxReadFileLine > 0) { @@ -136,120 +105,23 @@ export async function readFileTool( return } - // Count total lines in the file - let totalLines = 0 - - try { - totalLines = await countFileLines(absolutePath) - } catch (error) { - console.error(`Error counting lines in file ${absolutePath}:`, error) - } - - // now execute the tool like normal - let content: string - let isFileTruncated = false - let sourceCodeDef = "" - - const isBinary = await isBinaryFile(absolutePath).catch(() => false) - - if (isRangeRead) { - if (startLine === undefined) { - content = addLineNumbers(await readLines(absolutePath, endLine, startLine)) - } else { - content = addLineNumbers(await readLines(absolutePath, endLine, startLine), startLine + 1) - } - } else if (!isBinary && maxReadFileLine >= 0 && totalLines > maxReadFileLine) { - // If file is too large, only read the first maxReadFileLine lines - isFileTruncated = true - - const res = await Promise.all([ - maxReadFileLine > 0 ? readLines(absolutePath, maxReadFileLine - 1, 0) : "", - (async () => { - try { - return await parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController) - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - return undefined - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - return undefined - } - } - })(), - ]) - - content = res[0].length > 0 ? addLineNumbers(res[0]) : "" - const result = res[1] - - if (result) { - sourceCodeDef = `${result}` - } - } else { - // Read entire file - content = await extractTextFromFile(absolutePath) - } - - // Create variables to store XML components - let xmlInfo = "" - let contentTag = "" - - // Add truncation notice if applicable - if (isFileTruncated) { - xmlInfo += `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use start_line and end_line if you need to read more\n` - - // Add source code definitions if available - if (sourceCodeDef) { - xmlInfo += `${sourceCodeDef}\n` - } - } - - // Empty files (zero lines) - if (content === "" && totalLines === 0) { - // Always add self-closing content tag and notice for empty files - contentTag = `` - xmlInfo += `File is empty\n` - } - // Range reads should always show content regardless of maxReadFileLine - else if (isRangeRead) { - // Create content tag with line range information - let lineRangeAttr = "" - const displayStartLine = startLine !== undefined ? startLine + 1 : 1 - const displayEndLine = endLine !== undefined ? endLine + 1 : totalLines - lineRangeAttr = ` lines="${displayStartLine}-${displayEndLine}"` - - // Maintain exact format expected by tests - contentTag = `\n${content}\n` - } - // maxReadFileLine=0 for non-range reads - else if (maxReadFileLine === 0) { - // Skip content tag for maxReadFileLine=0 (definitions only mode) - contentTag = "" - } - // Normal case: non-empty files with content (non-range reads) - else { - // For non-range reads, always show line range - let lines = totalLines - - if (maxReadFileLine >= 0 && totalLines > maxReadFileLine) { - lines = maxReadFileLine - } - - const lineRangeAttr = ` lines="1-${lines}"` - - // Maintain exact format expected by tests - contentTag = `\n${content}\n` - } + // Use shared utility functions for file processing + const result = await processFileForReading( + absolutePath, + relPath, + maxReadFileLine, + requestedStartLine, + requestedEndLine, + cline.rooIgnoreController, + ) // Track file read operation if (relPath) { await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) } - // Format the result into the required XML structure - const xmlResult = `${relPath}\n${contentTag}${xmlInfo}` + // Format and push the result + const xmlResult = formatProcessedFileResultToString(result) pushToolResult(xmlResult) } } catch (error) { diff --git a/src/shared/fileReadUtils.ts b/src/shared/fileReadUtils.ts new file mode 100644 index 0000000000..a3f73947b8 --- /dev/null +++ b/src/shared/fileReadUtils.ts @@ -0,0 +1,269 @@ +/** + * Utilities for reading and processing files with various options and controls + */ + +import { RooIgnoreController } from "../core/ignore/RooIgnoreController" +import { isBinaryFile } from "isbinaryfile" +import * as fs from "node:fs" +import * as readline from "node:readline" +import { parseSourceCodeDefinitionsForFile } from "../services/tree-sitter" + +// Utility functions +async function countFileLines(filePath: string): Promise { + const fileStream = fs.createReadStream(filePath) + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }) + + let count = 0 + for await (const _ of rl) { + count++ + } + return count +} + +async function readLines(filePath: string, endLine: number | undefined, startLine = 0): Promise { + const fileStream = fs.createReadStream(filePath) + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }) + + const lines: string[] = [] + let currentLine = 0 + + for await (const line of rl) { + if (currentLine >= startLine && (endLine === undefined || currentLine <= endLine)) { + lines.push(line) + } + currentLine++ + if (endLine !== undefined && currentLine > endLine) break + } + + return lines +} + +function addLineNumbers(lines: string[], startAt = 1): string { + return lines.map((line, i) => `${startAt + i} | ${line}`).join("\n") +} + +async function extractTextFromFile(filePath: string): Promise { + const content = await fs.promises.readFile(filePath, "utf-8") + return addLineNumbers(content.split("\n"), 1) +} + +/** + * Represents the result of processing a file read operation + */ +export interface ProcessedFileReadResult { + /** Relative path to the file from workspace root */ + relativePath: string + /** File content with line numbers (if applicable) */ + contentWithLineNumbers?: string + /** Notice or warning message about the read operation */ + notice?: string + /** Extracted source code definitions (if applicable) */ + sourceCodeDefinitions?: string + /** Error message if reading failed */ + error?: string + /** Actual start line that was read (1-based) */ + actualStartLine?: number + /** Actual end line that was read (1-based) */ + actualEndLine?: number + /** Total number of lines in the file */ + totalLinesInFile: number + /** Whether the file is binary */ + isBinary: boolean + /** Whether the content was truncated */ + wasTruncated: boolean + /** Whether a range was read (vs full file) */ + wasRangeRead: boolean +} + +/** + * Processes a file for reading with various options and controls + * @param absolutePath Absolute path to the file + * @param relativePath Relative path from workspace root + * @param maxReadFileLine Maximum lines to read + * @param requestedStartLine Optional start line (1-based) + * @param requestedEndLine Optional end line (1-based) + * @param rooIgnoreController Optional ignore controller + * @returns Processed file read result + */ +export async function processFileForReading( + absolutePath: string, + relativePath: string, + maxReadFileLine: number, + requestedStartLine: number | undefined, + requestedEndLine: number | undefined, + rooIgnoreController: RooIgnoreController | undefined, +): Promise { + // 1. Initial checks & setup + if (rooIgnoreController && !rooIgnoreController.validateAccess(relativePath)) { + return { + relativePath, + error: "Access to file denied by .rooignore", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + } + } + + // 2. Validate range parameters + const baseErrorResult = { + relativePath, + totalLinesInFile: 0, + isBinary: false, // Assuming not binary until checked, or error occurs before + wasTruncated: false, + wasRangeRead: false, // If range params are invalid, it's not a valid range read + } + + if (requestedStartLine !== undefined) { + if (typeof requestedStartLine !== "number" || isNaN(requestedStartLine) || requestedStartLine < 1) { + return { ...baseErrorResult, error: "Invalid start_line value" } + } + } + if (requestedEndLine !== undefined) { + if (typeof requestedEndLine !== "number" || isNaN(requestedEndLine) || requestedEndLine < 1) { + return { ...baseErrorResult, error: "Invalid end_line value" } + } + } + if (requestedStartLine !== undefined && requestedEndLine !== undefined && requestedStartLine > requestedEndLine) { + return { ...baseErrorResult, error: "start_line must be less than or equal to end_line" } + } + + // 3. Count lines and check for binary (moved after range validation) + let totalLinesInFile = 0 + try { + totalLinesInFile = await countFileLines(absolutePath) + } catch (error) { + return { + relativePath, + error: `Failed to count lines: ${error instanceof Error ? error.message : String(error)}`, + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, // If counting lines fails, it's not a successful range read + } + } + + const isBinary = await isBinaryFile(absolutePath).catch(() => false) + // Determine wasRangeRead *after* validation and *before* it's used for logic + const wasRangeRead = requestedStartLine !== undefined || requestedEndLine !== undefined + const startLine0Based = requestedStartLine ? requestedStartLine - 1 : 0 + const endLine0Based = requestedEndLine ? requestedEndLine - 1 : undefined + + // Initialize result object + const result: ProcessedFileReadResult = { + relativePath, + totalLinesInFile, + isBinary, + wasTruncated: false, + wasRangeRead, + } + + // 2. Handle binary files + if (isBinary) { + result.notice = "File is binary. Content display may be limited." + return result + } + + // 3. Determine read strategy + if (wasRangeRead) { + // Range read logic + const linesArray = await readLines(absolutePath, endLine0Based, startLine0Based) + result.contentWithLineNumbers = addLineNumbers(linesArray, requestedStartLine || 1) + result.actualStartLine = requestedStartLine || 1 + result.actualEndLine = result.actualStartLine + linesArray.length - 1 + + if (requestedEndLine && result.actualEndLine < requestedEndLine) { + result.notice = `File ended at line ${result.actualEndLine} (requested to ${requestedEndLine})` + } + } else { + // Full or partial read logic + const wasTruncated = maxReadFileLine >= 0 && totalLinesInFile > maxReadFileLine + result.wasTruncated = wasTruncated + + if (maxReadFileLine === 0) { + result.notice = "Content omitted (maxReadFileLine: 0). Showing definitions if available." + } else if (wasTruncated) { + const linesArray = await readLines(absolutePath, maxReadFileLine - 1, 0) + result.contentWithLineNumbers = addLineNumbers(linesArray, 1) + result.actualStartLine = 1 + result.actualEndLine = maxReadFileLine + result.notice = `Showing only ${maxReadFileLine} of ${totalLinesInFile} total lines. Use start_line and end_line if you need to read more.` + } else { + result.contentWithLineNumbers = await extractTextFromFile(absolutePath) + result.actualStartLine = 1 + result.actualEndLine = totalLinesInFile + } + + // Get source code definitions if needed + if ((wasTruncated && maxReadFileLine > 0) || (maxReadFileLine === 0 && !wasRangeRead)) { + try { + const definitions = await parseSourceCodeDefinitionsForFile(absolutePath, rooIgnoreController) + if (definitions) { + result.sourceCodeDefinitions = definitions + } + } catch (error) { + if (error instanceof Error && !error.message.startsWith("Unsupported language:")) { + console.error(`Error parsing definitions: ${error.message}`) + } + } + } + } + + // Handle empty files + if (totalLinesInFile === 0 && !isBinary) { + result.notice = "File is empty." + } + + return result +} + +/** + * Formats a processed file result to a string representation + * @param result The processed file result + * @returns Formatted string + */ +export function formatProcessedFileResultToString(result: ProcessedFileReadResult): string { + // Handle errors first + if (result.error) { + return `${result.relativePath}${result.error}` + } + + // Initialize XML components + let xmlInfo = "" + let contentTag = "" + const pathTag = `${result.relativePath}\n` + + // Build xmlInfo from result properties + if (result.notice) { + xmlInfo += `${result.notice}\n` + } + if (result.sourceCodeDefinitions) { + xmlInfo += `${result.sourceCodeDefinitions}\n` + } + + // Build contentTag based on file type and read mode + if (result.isBinary) { + contentTag = `\n` + } else if (result.totalLinesInFile === 0) { + contentTag = `\n` + } else if (result.wasRangeRead) { + const lineRangeAttr = `lines="${result.actualStartLine}-${result.actualEndLine}"` + contentTag = `\n${result.contentWithLineNumbers || ""}\n` + } else { + if (result.contentWithLineNumbers === undefined) { + contentTag = "" + } else { + const lineRangeAttr = `lines="${result.actualStartLine}-${result.actualEndLine}"` + contentTag = `\n${result.contentWithLineNumbers || ""}\n` + } + } + + // Assemble final XML string + return `${pathTag}${contentTag}${xmlInfo}` +} From fb37034dfd5e82bc42394040b43d385a63acb973 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Tue, 13 May 2025 15:22:38 -0500 Subject: [PATCH 08/10] test: update tests to accomodate to the new split logic --- src/core/tools/__tests__/readFileTool.test.ts | 1063 +++-------------- src/shared/__tests__/fileReadUtils.test.ts | 495 ++++++++ 2 files changed, 692 insertions(+), 866 deletions(-) create mode 100644 src/shared/__tests__/fileReadUtils.test.ts diff --git a/src/core/tools/__tests__/readFileTool.test.ts b/src/core/tools/__tests__/readFileTool.test.ts index f0b3600a26..0717574a33 100644 --- a/src/core/tools/__tests__/readFileTool.test.ts +++ b/src/core/tools/__tests__/readFileTool.test.ts @@ -1,15 +1,8 @@ -// npx jest src/core/tools/__tests__/readFileTool.test.ts - -import * as path from "path" - -import { countFileLines } from "../../../integrations/misc/line-counter" -import { readLines } from "../../../integrations/misc/read-lines" -import { extractTextFromFile } from "../../../integrations/misc/extract-text" -import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter" -import { isBinaryFile } from "isbinaryfile" -import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools" +import { processFileForReading, formatProcessedFileResultToString } from "../../../shared/fileReadUtils" +import { ToolUse } from "../../../shared/tools" import { readFileTool } from "../readFileTool" +jest.mock("../../../shared/fileReadUtils") jest.mock("path", () => { const originalPath = jest.requireActual("path") return { @@ -18,935 +11,273 @@ jest.mock("path", () => { } }) -jest.mock("fs/promises", () => ({ - mkdir: jest.fn().mockResolvedValue(undefined), - writeFile: jest.fn().mockResolvedValue(undefined), - readFile: jest.fn().mockResolvedValue("{}"), -})) - -jest.mock("isbinaryfile") - -jest.mock("../../../integrations/misc/line-counter") -jest.mock("../../../integrations/misc/read-lines") - -let mockInputContent = "" - -jest.mock("../../../integrations/misc/extract-text", () => { - const actual = jest.requireActual("../../../integrations/misc/extract-text") - // Create a spy on the actual addLineNumbers function. - const addLineNumbersSpy = jest.spyOn(actual, "addLineNumbers") - - return { - ...actual, - // Expose the spy so tests can access it. - __addLineNumbersSpy: addLineNumbersSpy, - extractTextFromFile: jest.fn().mockImplementation((_filePath) => { - // Use the actual addLineNumbers function. - const content = mockInputContent - return Promise.resolve(actual.addLineNumbers(content)) - }), - } -}) - -const addLineNumbersSpy = jest.requireMock("../../../integrations/misc/extract-text").__addLineNumbersSpy - -jest.mock("../../../services/tree-sitter") - -jest.mock("../../ignore/RooIgnoreController", () => ({ - RooIgnoreController: class { - initialize() { - return Promise.resolve() - } - validateAccess() { - return true - } - }, -})) - -jest.mock("../../../utils/fs", () => ({ - fileExistsAtPath: jest.fn().mockReturnValue(true), -})) - -describe("read_file tool with maxReadFileLine setting", () => { - // Test data - const testFilePath = "test/file.txt" - const absoluteFilePath = "/test/file.txt" - const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n" - const sourceCodeDef = "\n\n# file.txt\n1--5 | Content" - const expectedFullFileXml = `${testFilePath}\n\n${numberedFileContent}\n` - - // Mocked functions with correct types - const mockedCountFileLines = countFileLines as jest.MockedFunction - const mockedReadLines = readLines as jest.MockedFunction - const mockedExtractTextFromFile = extractTextFromFile as jest.MockedFunction - const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction< - typeof parseSourceCodeDefinitionsForFile - > - - const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction - const mockedPathResolve = path.resolve as jest.MockedFunction - - const mockCline: any = {} +describe("readFileTool tests", () => { + let mockPushToolResult: jest.Mock + let mockAskApproval: jest.Mock + let mockRemoveClosingTag: jest.Mock + let mockHandleError: jest.Mock + let mockCline: any let mockProvider: any - let toolResult: ToolResponse | undefined beforeEach(() => { jest.clearAllMocks() - mockedPathResolve.mockReturnValue(absoluteFilePath) - mockedIsBinaryFile.mockResolvedValue(false) - - mockInputContent = fileContent - - // Setup the extractTextFromFile mock implementation with the current - // mockInputContent. - mockedExtractTextFromFile.mockImplementation((_filePath) => { - const actual = jest.requireActual("../../../integrations/misc/extract-text") - return Promise.resolve(actual.addLineNumbers(mockInputContent)) - }) - - // No need to setup the extractTextFromFile mock implementation here - // as it's already defined at the module level. - + // Mock for providerRef.deref().getState() mockProvider = { - getState: jest.fn(), + getState: jest.fn().mockResolvedValue({ maxReadFileLine: 500 }), // Default maxReadFileLine deref: jest.fn().mockReturnThis(), } - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: jest.fn().mockReturnValue(true), - } - mockCline.say = jest.fn().mockResolvedValue(undefined) - mockCline.ask = jest.fn().mockResolvedValue(true) - mockCline.presentAssistantMessage = jest.fn() - - mockCline.fileContextTracker = { - trackFileContext: jest.fn().mockResolvedValue(undefined), - } - - mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined) - mockCline.recordToolError = jest.fn().mockReturnValue(undefined) - - toolResult = undefined - }) - - /** - * Helper function to execute the read file tool with different maxReadFileLine settings - */ - async function executeReadFileTool( - params: Partial = {}, - options: { - maxReadFileLine?: number - totalLines?: number - skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check - } = {}, - ): Promise { - // Configure mocks based on test scenario - const maxReadFileLine = options.maxReadFileLine ?? 500 - const totalLines = options.totalLines ?? 5 - - mockProvider.getState.mockResolvedValue({ maxReadFileLine }) - mockedCountFileLines.mockResolvedValue(totalLines) - - // Reset the spy before each test - addLineNumbersSpy.mockClear() - - // Create a tool use object - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: { path: testFilePath, ...params }, - partial: false, - } - - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - jest.fn(), - (result: ToolResponse) => { - toolResult = result + mockPushToolResult = jest.fn() + mockAskApproval = jest.fn().mockResolvedValue(true) + mockRemoveClosingTag = jest.fn((_, content) => content) + mockHandleError = jest.fn() + + mockCline = { + cwd: "/test/workspace", + task: "TestTask", + providerRef: mockProvider, + rooIgnoreController: { + validateAccess: jest.fn().mockReturnValue(true), + }, + fileContextTracker: { + trackFileContext: jest.fn().mockResolvedValue(undefined), }, - (_: ToolParamName, content?: string) => content ?? "", - ) - - // Verify addLineNumbers was called appropriately - if (!options.skipAddLineNumbersCheck) { - expect(addLineNumbersSpy).toHaveBeenCalled() - } else { - expect(addLineNumbersSpy).not.toHaveBeenCalled() + say: jest.fn().mockResolvedValue(undefined), + ask: mockAskApproval, + recordToolError: jest.fn(), + sayAndCreateMissingParamError: jest.fn().mockResolvedValue("Missing required parameter"), + consecutiveMistakeCount: 0, } - return toolResult - } - - describe("when maxReadFileLine is negative", () => { - it("should read the entire file using extractTextFromFile", async () => { - // Setup - use default mockInputContent - mockInputContent = fileContent - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: -1 }) + // Reset individual spies + mockPushToolResult.mockClear() + mockAskApproval.mockClear().mockResolvedValue(true) + mockRemoveClosingTag.mockClear().mockImplementation((_, content) => content) + mockHandleError.mockClear() + ;(processFileForReading as jest.Mock).mockClear() + ;(formatProcessedFileResultToString as jest.Mock).mockClear() + }) - // Verify - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath) - expect(mockedReadLines).not.toHaveBeenCalled() - expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled() - expect(result).toBe(expectedFullFileXml) - }) + it("should have readFileTool defined", () => { + expect(readFileTool).toBeDefined() + }) - it("should ignore range parameters and read entire file when maxReadFileLine is -1", async () => { - // Setup - use default mockInputContent - mockInputContent = fileContent + describe("Parameter Validation and Error Handling", () => { + it("should handle missing path parameter", async () => { + const block: ToolUse = { + type: "tool_use" as const, + name: "read_file", + params: {}, + partial: false, + } - // Execute with range parameters - const result = await executeReadFileTool( - { - start_line: "2", - end_line: "4", - }, - { maxReadFileLine: -1 }, + await readFileTool( + mockCline, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, ) - // Verify that extractTextFromFile is still used (not readLines) - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath) - expect(mockedReadLines).not.toHaveBeenCalled() - expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled() - expect(result).toBe(expectedFullFileXml) - }) - - it("should not show line snippet in approval message when maxReadFileLine is -1", async () => { - // This test verifies the line snippet behavior for the approval message - // Setup - use default mockInputContent - mockInputContent = fileContent - - // Execute - we'll reuse executeReadFileTool to run the tool - await executeReadFileTool({}, { maxReadFileLine: -1 }) - - // Verify the empty line snippet for full read was passed to the approval message - // Look at the parameters passed to the 'ask' method in the approval message - const askCall = mockCline.ask.mock.calls[0] - const completeMessage = JSON.parse(askCall[1]) - - // Verify the reason (lineSnippet) is empty or undefined for full read - expect(completeMessage.reason).toBeFalsy() + expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path") + expect(mockPushToolResult).toHaveBeenCalledWith( + "Missing required parameter", + ) + expect(processFileForReading).not.toHaveBeenCalled() + expect(mockCline.consecutiveMistakeCount).toBe(1) + expect(mockCline.recordToolError).toHaveBeenCalledWith("read_file") }) - }) - describe("when maxReadFileLine is 0", () => { - it("should return an empty content with source code definitions", async () => { - // Setup - for maxReadFileLine = 0, the implementation won't call readLines - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef) - - // Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0 - const result = await executeReadFileTool( - {}, - { - maxReadFileLine: 0, - totalLines: 5, - skipAddLineNumbersCheck: true, + it("should handle invalid start_line parameter", async () => { + const block: ToolUse = { + type: "tool_use" as const, + name: "read_file", + params: { + path: "test/file.txt", + start_line: "not-a-number", }, - ) + partial: false, + } - // Verify - expect(mockedExtractTextFromFile).not.toHaveBeenCalled() - expect(mockedReadLines).not.toHaveBeenCalled() // Per implementation line 141 - expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith( - absoluteFilePath, - mockCline.rooIgnoreController, + await readFileTool( + mockCline, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, ) - // Verify XML structure - expect(result).toContain(`${testFilePath}`) - expect(result).toContain("Showing only 0 of 5 total lines") - expect(result).toContain("") - expect(result).toContain("") - expect(result).toContain(sourceCodeDef.trim()) - expect(result).toContain("") - expect(result).not.toContain(" { - it("should read only maxReadFileLine lines and add source code definitions", async () => { - // Setup - const content = "Line 1\nLine 2\nLine 3" - mockedReadLines.mockResolvedValue(content) - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef) - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 3 }) - - // Verify - check behavior but not specific implementation details - expect(mockedExtractTextFromFile).not.toHaveBeenCalled() - expect(mockedReadLines).toHaveBeenCalled() - expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith( - absoluteFilePath, - mockCline.rooIgnoreController, + expect(mockCline.say).toHaveBeenCalledWith("error", "Failed to parse start_line: not-a-number") + expect(mockPushToolResult).toHaveBeenCalledWith( + "test/file.txtInvalid start_line value", ) - - // Verify XML structure - expect(result).toContain(`${testFilePath}`) - expect(result).toContain('') - expect(result).toContain("1 | Line 1") - expect(result).toContain("2 | Line 2") - expect(result).toContain("3 | Line 3") - expect(result).toContain("") - expect(result).toContain("Showing only 3 of 5 total lines") - expect(result).toContain("") - expect(result).toContain("") - expect(result).toContain(sourceCodeDef.trim()) - expect(result).toContain("") - expect(result).toContain("") - expect(result).toContain(sourceCodeDef.trim()) - }) - }) - - describe("when maxReadFileLine equals or exceeds file length", () => { - it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => { - // Setup - mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine - mockInputContent = fileContent - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 }) - - // Verify - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath) - expect(result).toBe(expectedFullFileXml) + expect(processFileForReading).not.toHaveBeenCalled() + expect(mockCline.consecutiveMistakeCount).toBe(1) + expect(mockCline.recordToolError).toHaveBeenCalledWith("read_file") }) - it("should read with extractTextFromFile when file has few lines", async () => { - // Setup - mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine - mockInputContent = fileContent - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 }) - - // Verify - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath) - expect(mockedReadLines).not.toHaveBeenCalled() - // Create a custom expected XML with lines="1-3" since totalLines is 3 - const expectedXml = `${testFilePath}\n\n${numberedFileContent}\n` - expect(result).toBe(expectedXml) - }) - }) - - describe("when file is binary", () => { - it("should always use extractTextFromFile regardless of maxReadFileLine", async () => { - // Setup - mockedIsBinaryFile.mockResolvedValue(true) - // For binary files, we're using a maxReadFileLine of 3 and totalLines is assumed to be 3 - mockedCountFileLines.mockResolvedValue(3) - - // For binary files, we need a special mock implementation that doesn't use addLineNumbers - // Save the original mock implementation - const originalMockImplementation = mockedExtractTextFromFile.getMockImplementation() - // Create a special mock implementation that doesn't call addLineNumbers - mockedExtractTextFromFile.mockImplementation(() => { - return Promise.resolve(numberedFileContent) - }) - - // Reset the spy to clear any previous calls - addLineNumbersSpy.mockClear() - - // Execute - skip addLineNumbers check as we're directly providing the numbered content - const result = await executeReadFileTool( - {}, - { - maxReadFileLine: 3, - totalLines: 3, - skipAddLineNumbersCheck: true, + it("should handle invalid end_line parameter", async () => { + const block: ToolUse = { + type: "tool_use" as const, + name: "read_file", + params: { + path: "test/file.txt", + end_line: "not-a-number", }, - ) - - // Restore the original mock implementation after the test - mockedExtractTextFromFile.mockImplementation(originalMockImplementation) - - // Verify - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath) - expect(mockedReadLines).not.toHaveBeenCalled() - // Create a custom expected XML with lines="1-3" for binary files - const expectedXml = `${testFilePath}\n\n${numberedFileContent}\n` - expect(result).toBe(expectedXml) - }) - }) - - describe("with range parameters", () => { - it("should honor start_line and end_line when provided", async () => { - // Setup - mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4") - - // Execute using executeReadFileTool with range parameters - const rangeResult = await executeReadFileTool({ - start_line: "2", - end_line: "4", - }) - - // Verify - expect(mockedReadLines).toHaveBeenCalledWith(absoluteFilePath, 3, 1) // end_line - 1, start_line - 1 - expect(addLineNumbersSpy).toHaveBeenCalledWith(expect.any(String), 2) // start with proper line numbers - - // Verify XML structure with lines attribute - expect(rangeResult).toContain(`${testFilePath}`) - expect(rangeResult).toContain(``) - expect(rangeResult).toContain("2 | Line 2") - expect(rangeResult).toContain("3 | Line 3") - expect(rangeResult).toContain("4 | Line 4") - expect(rangeResult).toContain("") - }) - }) -}) - -describe("read_file tool XML output structure", () => { - // Test data - const testFilePath = "test/file.txt" - const absoluteFilePath = "/test/file.txt" - const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n" - const sourceCodeDef = "\n\n# file.txt\n1--5 | Content" - - // Mocked functions with correct types - const mockedCountFileLines = countFileLines as jest.MockedFunction - const mockedReadLines = readLines as jest.MockedFunction - const mockedExtractTextFromFile = extractTextFromFile as jest.MockedFunction - const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction< - typeof parseSourceCodeDefinitionsForFile - > - const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction - const mockedPathResolve = path.resolve as jest.MockedFunction - - // Mock instances - const mockCline: any = {} - let mockProvider: any - let toolResult: ToolResponse | undefined - - beforeEach(() => { - jest.clearAllMocks() - - mockedPathResolve.mockReturnValue(absoluteFilePath) - mockedIsBinaryFile.mockResolvedValue(false) - - mockInputContent = fileContent - - mockProvider = { - getState: jest.fn().mockResolvedValue({ maxReadFileLine: 500 }), - deref: jest.fn().mockReturnThis(), - } - - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: jest.fn().mockReturnValue(true), - } - mockCline.say = jest.fn().mockResolvedValue(undefined) - mockCline.ask = jest.fn().mockResolvedValue(true) - mockCline.presentAssistantMessage = jest.fn() - mockCline.sayAndCreateMissingParamError = jest.fn().mockResolvedValue("Missing required parameter") - - mockCline.fileContextTracker = { - trackFileContext: jest.fn().mockResolvedValue(undefined), - } - - mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined) - mockCline.recordToolError = jest.fn().mockReturnValue(undefined) - - toolResult = undefined - }) - - /** - * Helper function to execute the read file tool with custom parameters - */ - async function executeReadFileTool( - params: Partial = {}, - options: { - totalLines?: number - maxReadFileLine?: number - isBinary?: boolean - validateAccess?: boolean - skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check - } = {}, - ): Promise { - // Configure mocks based on test scenario - const totalLines = options.totalLines ?? 5 - const maxReadFileLine = options.maxReadFileLine ?? 500 - const isBinary = options.isBinary ?? false - const validateAccess = options.validateAccess ?? true - - mockProvider.getState.mockResolvedValue({ maxReadFileLine }) - mockedCountFileLines.mockResolvedValue(totalLines) - mockedIsBinaryFile.mockResolvedValue(isBinary) - mockCline.rooIgnoreController.validateAccess = jest.fn().mockReturnValue(validateAccess) - - // Create a tool use object - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: { - path: testFilePath, - ...params, - }, - partial: false, - } - - // Reset the spy's call history before each test - addLineNumbersSpy.mockClear() - - // Execute the tool - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - jest.fn(), - (result: ToolResponse) => { - toolResult = result - }, - (param: ToolParamName, content?: string) => content ?? "", - ) - // Verify addLineNumbers was called (unless explicitly skipped) - if (!options.skipAddLineNumbersCheck) { - expect(addLineNumbersSpy).toHaveBeenCalled() - } else { - // For cases where we expect addLineNumbers NOT to be called - expect(addLineNumbersSpy).not.toHaveBeenCalled() - } - - return toolResult - } - - describe("Basic XML Structure Tests", () => { - it("should produce XML output with no unnecessary indentation", async () => { - // Setup - use default mockInputContent (fileContent) - mockInputContent = fileContent - - // Execute - const result = await executeReadFileTool() + partial: false, + } - // Verify - expect(result).toBe( - `${testFilePath}\n\n${numberedFileContent}\n`, + await readFileTool( + mockCline, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, ) - }) - - it("should follow the correct XML structure format", async () => { - // Setup - use default mockInputContent (fileContent) - mockInputContent = fileContent - - // Execute - const result = await executeReadFileTool() - // Verify using regex to check structure - const xmlStructureRegex = new RegExp( - `^${testFilePath}\\n\\n.*\\n$`, - "s", + expect(mockCline.say).toHaveBeenCalledWith("error", "Failed to parse end_line: not-a-number") + expect(mockPushToolResult).toHaveBeenCalledWith( + "test/file.txtInvalid end_line value", ) - expect(result).toMatch(xmlStructureRegex) + expect(processFileForReading).not.toHaveBeenCalled() + expect(mockCline.consecutiveMistakeCount).toBe(1) + expect(mockCline.recordToolError).toHaveBeenCalledWith("read_file") }) }) - describe("Line Range Tests", () => { - it("should include lines attribute when start_line is specified", async () => { - // Setup - const startLine = 2 - mockedReadLines.mockResolvedValue( - fileContent - .split("\n") - .slice(startLine - 1) - .join("\n"), + describe("Main Execution, Approval Flow, and Line Snippets", () => { + const setupReadFileTest = async (params: Record, maxReadFileLine: number = 500) => { + mockProvider.getState.mockResolvedValue({ maxReadFileLine }) + ;(processFileForReading as jest.Mock).mockResolvedValue({ + type: "success", + path: "test/file.txt", + content: "file content", + lines: "1-1", + totalLines: 1, + isBinary: false, + }) + ;(formatProcessedFileResultToString as jest.Mock).mockReturnValue( + "test/file.txtfile content", ) - // Execute - const result = await executeReadFileTool({ start_line: startLine.toString() }) - - // Verify - expect(result).toContain(``) - }) - - it("should include lines attribute when end_line is specified", async () => { - // Setup - const endLine = 3 - mockedReadLines.mockResolvedValue(fileContent.split("\n").slice(0, endLine).join("\n")) - - // Execute - const result = await executeReadFileTool({ end_line: endLine.toString() }) - - // Verify - expect(result).toContain(``) - }) + const block: ToolUse = { + type: "tool_use" as const, + name: "read_file", + params, + partial: false, + } - it("should include lines attribute when both start_line and end_line are specified", async () => { - // Setup - const startLine = 2 - const endLine = 4 - mockedReadLines.mockResolvedValue( - fileContent - .split("\n") - .slice(startLine - 1, endLine) - .join("\n"), + await readFileTool( + mockCline, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, ) + } - // Execute - const result = await executeReadFileTool({ - start_line: startLine.toString(), - end_line: endLine.toString(), - }) - - // Verify - expect(result).toContain(``) - }) - - it("should include lines attribute even when no range is specified", async () => { - // Setup - use default mockInputContent (fileContent) - mockInputContent = fileContent + it("should handle full file read", async () => { + await setupReadFileTest({ path: "test/file.txt" }, -1) - // Execute - const result = await executeReadFileTool() + expect(mockAskApproval).toHaveBeenCalledWith("tool", expect.any(String)) + const approvalMessage = JSON.parse(mockAskApproval.mock.calls[0][1]) + expect(approvalMessage.reason).toBeFalsy() - // Verify - expect(result).toContain(`\n`) + expect(processFileForReading).toHaveBeenCalled() + expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test/file.txt", "read_tool") + expect(mockPushToolResult).toHaveBeenCalled() }) - it("should include content when maxReadFileLine=0 and range is specified", async () => { - // Setup - const maxReadFileLine = 0 - const startLine = 2 - const endLine = 4 - const totalLines = 10 - - mockedReadLines.mockResolvedValue( - fileContent - .split("\n") - .slice(startLine - 1, endLine) - .join("\n"), - ) - - // Execute - const result = await executeReadFileTool( - { - start_line: startLine.toString(), - end_line: endLine.toString(), - }, - { maxReadFileLine, totalLines }, - ) - - // Verify - // Should include content tag with line range - expect(result).toContain(``) - - // Should NOT include definitions (range reads never show definitions) - expect(result).not.toContain("") + it("should handle range specified read", async () => { + await setupReadFileTest({ + path: "test/file.txt", + start_line: "2", + end_line: "4", + }) - // Should NOT include truncation notice - expect(result).not.toContain(`Showing only ${maxReadFileLine} of ${totalLines} total lines`) + const approvalMessage = JSON.parse(mockAskApproval.mock.calls[0][1]) + expect(approvalMessage.reason).toBe("readFile.linesRange") }) - it("should include content when maxReadFileLine=0 and only start_line is specified", async () => { - // Setup - const maxReadFileLine = 0 - const startLine = 3 - const totalLines = 10 - - mockedReadLines.mockResolvedValue( - fileContent - .split("\n") - .slice(startLine - 1) - .join("\n"), - ) - - // Execute - const result = await executeReadFileTool( - { - start_line: startLine.toString(), - }, - { maxReadFileLine, totalLines }, - ) - - // Verify - // Should include content tag with line range - expect(result).toContain(``) - - // Should NOT include definitions (range reads never show definitions) - expect(result).not.toContain("") + it("should handle start_line only read", async () => { + await setupReadFileTest({ + path: "test/file.txt", + start_line: "2", + }) - // Should NOT include truncation notice - expect(result).not.toContain(`Showing only ${maxReadFileLine} of ${totalLines} total lines`) + const approvalMessage = JSON.parse(mockAskApproval.mock.calls[0][1]) + expect(approvalMessage.reason).toBe("readFile.linesFromToEnd") }) - it("should include content when maxReadFileLine=0 and only end_line is specified", async () => { - // Setup - const maxReadFileLine = 0 - const endLine = 3 - const totalLines = 10 - - mockedReadLines.mockResolvedValue(fileContent.split("\n").slice(0, endLine).join("\n")) - - // Execute - const result = await executeReadFileTool( - { - end_line: endLine.toString(), - }, - { maxReadFileLine, totalLines }, - ) - - // Verify - // Should include content tag with line range - expect(result).toContain(``) - - // Should NOT include definitions (range reads never show definitions) - expect(result).not.toContain("") + it("should handle end_line only read", async () => { + await setupReadFileTest({ + path: "test/file.txt", + end_line: "4", + }) - // Should NOT include truncation notice - expect(result).not.toContain(`Showing only ${maxReadFileLine} of ${totalLines} total lines`) + const approvalMessage = JSON.parse(mockAskApproval.mock.calls[0][1]) + expect(approvalMessage.reason).toBe("readFile.linesFromStartTo") }) - it("should include full range content when maxReadFileLine=5 and content has more than 5 lines", async () => { - // Setup - const maxReadFileLine = 5 - const startLine = 2 - const endLine = 8 - const totalLines = 10 - - // Create mock content with 7 lines (more than maxReadFileLine) - const rangeContent = Array(endLine - startLine + 1) - .fill("Range line content") - .join("\n") - - mockedReadLines.mockResolvedValue(rangeContent) - - // Execute - const result = await executeReadFileTool( - { - start_line: startLine.toString(), - end_line: endLine.toString(), - }, - { maxReadFileLine, totalLines }, - ) - - // Verify - // Should include content tag with the full requested range (not limited by maxReadFileLine) - expect(result).toContain(``) - - // Should NOT include definitions (range reads never show definitions) - expect(result).not.toContain("") - - // Should NOT include truncation notice - expect(result).not.toContain(`Showing only ${maxReadFileLine} of ${totalLines} total lines`) - - // Should contain all the requested lines, not just maxReadFileLine lines - expect(result).toBeDefined() - expect(typeof result).toBe("string") + it("should handle definitions only read", async () => { + await setupReadFileTest({ path: "test/file.txt" }, 0) - if (typeof result === "string") { - expect(result.split("\n").length).toBeGreaterThan(maxReadFileLine) - } + const approvalMessage = JSON.parse(mockAskApproval.mock.calls[0][1]) + expect(approvalMessage.reason).toBe("readFile.definitionsOnly") }) - }) - - describe("Notice and Definition Tags Tests", () => { - it("should include notice tag for truncated files", async () => { - // Setup - const maxReadFileLine = 3 - const totalLines = 10 - mockedReadLines.mockResolvedValue(fileContent.split("\n").slice(0, maxReadFileLine).join("\n")) - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine, totalLines }) + it("should handle max lines read", async () => { + await setupReadFileTest({ path: "test/file.txt" }, 100) - // Verify - expect(result).toContain(`Showing only ${maxReadFileLine} of ${totalLines} total lines`) + const approvalMessage = JSON.parse(mockAskApproval.mock.calls[0][1]) + expect(approvalMessage.reason).toBe("readFile.maxLines") }) - it("should include list_code_definition_names tag when source code definitions are available", async () => { - // Setup - const maxReadFileLine = 3 - const totalLines = 10 - mockedReadLines.mockResolvedValue(fileContent.split("\n").slice(0, maxReadFileLine).join("\n")) - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef) - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine, totalLines }) - - // Verify - // Use regex to match the tag content regardless of whitespace - expect(result).toMatch( - new RegExp( - `[\\s\\S]*${sourceCodeDef.trim()}[\\s\\S]*`, - ), - ) - }) + it("should handle file read denied", async () => { + mockAskApproval.mockResolvedValueOnce(false) + await setupReadFileTest({ path: "test/file.txt" }) - it("should only have definitions, no content when maxReadFileLine=0", async () => { - // Setup - const maxReadFileLine = 0 - const totalLines = 10 - // Mock content with exactly 10 lines to match totalLines - const rawContent = Array(10).fill("Line content").join("\n") - mockInputContent = rawContent - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef) - - // Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0 - const result = await executeReadFileTool({}, { maxReadFileLine, totalLines, skipAddLineNumbersCheck: true }) - - // Verify - expect(result).toContain(`Showing only 0 of ${totalLines} total lines`) - // Use regex to match the tag content regardless of whitespace - expect(result).toMatch( - new RegExp( - `[\\s\\S]*${sourceCodeDef.trim()}[\\s\\S]*`, - ), - ) - expect(result).not.toContain(` { - // Setup - const maxReadFileLine = 0 - const totalLines = 10 - // Mock that no source code definitions are available - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue("") - // Mock content with exactly 10 lines to match totalLines - const rawContent = Array(10).fill("Line content").join("\n") - mockInputContent = rawContent - - // Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0 - const result = await executeReadFileTool({}, { maxReadFileLine, totalLines, skipAddLineNumbersCheck: true }) - - // Verify - // Should include notice - expect(result).toContain( - `${testFilePath}\nShowing only 0 of ${totalLines} total lines. Use start_line and end_line if you need to read more\n`, - ) - // Should not include list_code_definition_names tag since there are no definitions - expect(result).not.toContain("") - // Should not include content tag for non-empty files with maxReadFileLine=0 - expect(result).not.toContain(" { - it("should include error tag for invalid path", async () => { - // Setup - missing path parameter - const toolUse: ReadFileToolUse = { - type: "tool_use", + it("should handle partial block processing", async () => { + const block: ToolUse = { + type: "tool_use" as const, name: "read_file", - params: {}, - partial: false, + params: { path: "test/file.txt" }, + partial: true, } - // Execute the tool await readFileTool( mockCline, - toolUse, - mockCline.ask, - jest.fn(), - (result: ToolResponse) => { - toolResult = result - }, - (param: ToolParamName, content?: string) => content ?? "", + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, ) - // Verify - expect(toolResult).toContain(``) - expect(toolResult).not.toContain(` { - // Execute - skip addLineNumbers check as it returns early with an error - const result = await executeReadFileTool({ start_line: "invalid" }, { skipAddLineNumbersCheck: true }) - - // Verify - expect(result).toContain(`${testFilePath}Invalid start_line value`) - expect(result).not.toContain(` { - // Execute - skip addLineNumbers check as it returns early with an error - const result = await executeReadFileTool({ end_line: "invalid" }, { skipAddLineNumbersCheck: true }) - - // Verify - expect(result).toContain(`${testFilePath}Invalid end_line value`) - expect(result).not.toContain(` { - // Execute - skip addLineNumbers check as it returns early with an error - const result = await executeReadFileTool({}, { validateAccess: false, skipAddLineNumbersCheck: true }) - - // Verify - expect(result).toContain(`${testFilePath}`) - expect(result).not.toContain(` { - it("should handle empty files correctly with maxReadFileLine=-1", async () => { - // Setup - use empty string - mockInputContent = "" - const maxReadFileLine = -1 - const totalLines = 0 - mockedCountFileLines.mockResolvedValue(totalLines) - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine, totalLines }) - console.log(result) - - // Verify - // Empty files should include a content tag and notice - expect(result).toBe(`${testFilePath}\nFile is empty\n`) - // And make sure there's no error - expect(result).not.toContain(``) - }) - - it("should handle empty files correctly with maxReadFileLine=0", async () => { - // Setup - use empty string - mockInputContent = "" - const maxReadFileLine = 0 - const totalLines = 0 - mockedCountFileLines.mockResolvedValue(totalLines) - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine, totalLines }) - - // Verify - // Empty files should include a content tag and notice even with maxReadFileLine=0 - expect(result).toBe(`${testFilePath}\nFile is empty\n`) - }) - - it("should handle binary files correctly", async () => { - // Setup - // For binary content, we need to override the mock since we don't use addLineNumbers - mockedExtractTextFromFile.mockResolvedValue("Binary content") - - // Execute - skip addLineNumbers check as we're directly mocking extractTextFromFile - const result = await executeReadFileTool({}, { isBinary: true, skipAddLineNumbersCheck: true }) - - // Verify - expect(result).toBe( - `${testFilePath}\n\nBinary content\n`, - ) - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath) + expect(mockCline.ask).toHaveBeenCalledWith("tool", expect.stringContaining('"tool":"readFile"'), true) + expect(processFileForReading).not.toHaveBeenCalled() + expect(mockPushToolResult).not.toHaveBeenCalled() }) - it("should handle file read errors correctly", async () => { - // Setup - const errorMessage = "File not found" - // For error cases, we need to override the mock to simulate a failure - mockedExtractTextFromFile.mockRejectedValue(new Error(errorMessage)) + it("should handle error during processFileForReading", async () => { + const error = new Error("Simulated read error") + ;(processFileForReading as jest.Mock).mockRejectedValueOnce(error) - // Execute - skip addLineNumbers check as it throws an error - const result = await executeReadFileTool({}, { skipAddLineNumbersCheck: true }) + await setupReadFileTest({ path: "test/file.txt" }) - // Verify - expect(result).toContain( - `${testFilePath}Error reading file: ${errorMessage}`, + expect(mockHandleError).toHaveBeenCalledWith("reading file", error) + expect(mockPushToolResult).toHaveBeenCalledWith( + "test/file.txtError reading file: Simulated read error", ) - expect(result).not.toContain(` Array.from({ length: 5 }, (_, i) => `line ${i + 1}`) +const getGlobalDefaultMockContentString = () => getGlobalDefaultMockLines().join("\n") + +jest.mock("path", () => { + const originalPath = jest.requireActual("path") + return { + ...originalPath, + resolve: jest.fn().mockImplementation((...args) => args.join("/")), + } +}) + +jest.mock("node:fs", () => { + const originalFs = jest.requireActual("node:fs") + const streamMock = { + on: jest.fn().mockReturnThis(), + pipe: jest.fn().mockReturnThis(), + // @ts-ignore + [Symbol.asyncIterator]: async function* () { + for (const line of getGlobalDefaultMockLines()) { + yield line + } + }, + } + return { + ...originalFs, + createReadStream: jest.fn().mockReturnValue(streamMock), + promises: { + readFile: jest.fn().mockImplementation(async () => getGlobalDefaultMockContentString()), + }, + } +}) + +jest.mock("node:readline", () => ({ + createInterface: jest.fn().mockImplementation((_options) => ({ + [Symbol.asyncIterator]: jest.fn().mockImplementation(async function* () { + for (const line of getGlobalDefaultMockLines()) { + yield line + } + }), + close: jest.fn(), + })), +})) + +jest.mock("isbinaryfile") +jest.mock("../../services/tree-sitter", () => ({ + parseSourceCodeDefinitionsForFile: jest.fn(), +})) + +jest.mock("../../core/ignore/RooIgnoreController", () => ({ + RooIgnoreController: class { + // Mock only the method needed by fileReadUtils + validateAccess: jest.Mock = jest.fn().mockReturnValue(true) + }, +})) + +describe("processFileForReading", () => { + const testFilePath = "test/file.txt" + const absoluteFilePath = "/test/file.txt" + + const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction + const mockedPathResolve = path.resolve as jest.MockedFunction + const mockRooIgnoreController = new RooIgnoreController("/mock/cwd") + + beforeEach(() => { + jest.clearAllMocks() + mockedPathResolve.mockReturnValue(absoluteFilePath) + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should process a text file with line range", async () => { + const result = await processFileForReading(absoluteFilePath, testFilePath, 500, 2, 4, mockRooIgnoreController) + + expect(result).toEqual({ + relativePath: testFilePath, + contentWithLineNumbers: expect.any(String), + totalLinesInFile: expect.any(Number), + isBinary: false, + wasTruncated: false, + wasRangeRead: true, + actualStartLine: 2, + actualEndLine: 4, + }) + }) + + it("should handle binary files", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + + const result = await processFileForReading(absoluteFilePath, testFilePath, 500, 1, 5, mockRooIgnoreController) + + expect(result).toEqual({ + relativePath: testFilePath, + notice: "File is binary. Content display may be limited.", + totalLinesInFile: expect.any(Number), + isBinary: true, + wasTruncated: false, + wasRangeRead: true, + }) + }) + + describe("processFileForReading with maxReadFileLine setting", () => { + it("should return full content when maxReadFileLine is negative (-1)", async () => { + // This test will now use the global default mocks (5 lines of "line X") + // fs.promises.readFile will provide the 5 lines, and countFileLines will also count 5 lines. + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + -1, // maxReadFileLine = -1 + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + contentWithLineNumbers: getGlobalDefaultMockLines() + .map((line, i) => `${i + 1} | ${line}`) + .join("\n"), + totalLinesInFile: 5, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + actualStartLine: 1, + actualEndLine: 5, + }) + }) + + it("should truncate content when maxReadFileLine is less than file length", async () => { + // This test uses the global default mocks (5 lines "line X"). + // processFileForReading will call countFileLines (sees 5 lines), + // then determine truncation, then call readLines which will read from the 5-line mock + // but limit to maxReadFileLine. + // NO local mock override for readline needed here for this specific scenario. + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 3, // maxReadFileLine = 3 + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + contentWithLineNumbers: "1 | line 1\n2 | line 2\n3 | line 3", + totalLinesInFile: 5, + isBinary: false, + wasTruncated: true, + wasRangeRead: false, + actualStartLine: 1, + actualEndLine: 3, + notice: "Showing only 3 of 5 total lines. Use start_line and end_line if you need to read more.", + }) + }) + + it("should return full content when maxReadFileLine equals file length", async () => { + // Uses global default mocks (5 lines "line X") + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 5, // maxReadFileLine = 5 + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + contentWithLineNumbers: getGlobalDefaultMockLines() + .map((line, i) => `${i + 1} | ${line}`) + .join("\n"), + totalLinesInFile: 5, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + actualStartLine: 1, + actualEndLine: 5, + }) + }) + + it("should return full content when maxReadFileLine exceeds file length", async () => { + // Uses global default mocks (5 lines "line X") + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 10, // maxReadFileLine = 10 + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + contentWithLineNumbers: "1 | line 1\n2 | line 2\n3 | line 3\n4 | line 4\n5 | line 5", + totalLinesInFile: 5, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + actualStartLine: 1, + actualEndLine: 5, + }) + }) + }) + + describe("processFileForReading with invalid range parameters", () => { + it("should return error for non-numeric start_line", async () => { + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, + "invalid" as any, + undefined, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + error: "Invalid start_line value", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + + it("should return error for non-numeric end_line", async () => { + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, + undefined, + "invalid" as any, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + error: "Invalid end_line value", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + + it("should return error for negative start_line", async () => { + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, + -1, + undefined, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + error: "Invalid start_line value", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + + it("should return error for negative end_line", async () => { + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, + undefined, + -1, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + error: "Invalid end_line value", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + + it("should return error when start_line > end_line", async () => { + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, + 5, + 3, + mockRooIgnoreController, + ) + + expect(result).toEqual({ + relativePath: testFilePath, + error: "start_line must be less than or equal to end_line", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + }) + + describe("processFileForReading line numbering verification", () => { + it("should correctly number lines for full file read", async () => { + const specificMockLines = ["first line", "second line", "third line"] + const specificMockContentString = specificMockLines.join("\n") + const { createInterface: mockRlCreateInterface } = require("node:readline") + const { promises: mockFsPromises } = require("node:fs") + + mockRlCreateInterface.mockImplementation(() => ({ + [Symbol.asyncIterator]: async function* () { + for (const line of specificMockLines) yield line + }, + close: jest.fn(), + })) + mockFsPromises.readFile.mockResolvedValue(specificMockContentString) + + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + -1, // full read + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result.contentWithLineNumbers).toBe("1 | first line\n2 | second line\n3 | third line") + expect(result.totalLinesInFile).toBe(3) + }) + + it("should correctly number lines for truncated read", async () => { + // Simulating a file that has 3 lines, but we only want to read 2. + const fullFileSpecificMockLines = ["first line", "second line", "third line"] + const { createInterface: mockRlCreateInterface } = require("node:readline") + // fs.promises.readFile mock is not strictly needed here if truncation path is taken, + // but good practice if the test was ever to change to not truncate. + // For this test, countFileLines needs to see 3 lines. + mockRlCreateInterface.mockImplementation(() => ({ + [Symbol.asyncIterator]: async function* () { + for (const line of fullFileSpecificMockLines) yield line + }, + close: jest.fn(), + })) + + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 2, // truncate at 2 lines + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result.contentWithLineNumbers).toBe("1 | first line\n2 | second line") + }) + + it("should correctly number lines for range read", async () => { + const specificMockLinesForRangeTest = [ + "range test line 1", + "range test line 2", // This is the one we want to start with (line 2) + "range test line 3", // This is the one we want to end with (line 3) + "range test line 4", + ] + const { createInterface: mockRlCreateInterface } = require("node:readline") + // countFileLines and readLines both use readline, so this mock serves both. + mockRlCreateInterface.mockImplementation(() => ({ + [Symbol.asyncIterator]: async function* () { + for (const line of specificMockLinesForRangeTest) { + yield line + } + }, + close: jest.fn(), + })) + + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, // maxReadFileLine (not strictly relevant for pure range read if range is smaller) + 2, // requestedStartLine + 3, // requestedEndLine + mockRooIgnoreController, + ) + + expect(result.contentWithLineNumbers).toBe("2 | range test line 2\n3 | range test line 3") + expect(result.totalLinesInFile).toBe(specificMockLinesForRangeTest.length) + expect(result.actualStartLine).toBe(2) + expect(result.actualEndLine).toBe(3) + expect(result.wasRangeRead).toBe(true) + expect(result.notice).toBeUndefined() + }) + }) + + it("should handle access denied files", async () => { + const denyingController = new RooIgnoreController("/mock/cwd") + denyingController.validateAccess = jest.fn().mockReturnValue(false) as any + + const result = await processFileForReading(absoluteFilePath, testFilePath, 500, 1, 5, denyingController) + + expect(result).toEqual({ + relativePath: testFilePath, + error: "Access to file denied by .rooignore", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + + it("should handle empty files", async () => { + const { createReadStream } = require("node:fs") + const { createInterface } = require("node:readline") + + createReadStream.mockImplementation(() => ({ + on: jest.fn((event, _callback) => { + if (event === "data") { + // No data emitted for empty file + return this + } + return this + }), + pipe: jest.fn(), + })) + + createInterface.mockImplementation(() => ({ + [Symbol.asyncIterator]: jest.fn().mockImplementation(function* () { + // No lines yielded for empty file + return + }), + close: jest.fn(), + })) + + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 500, + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(result).toMatchObject({ + relativePath: testFilePath, + notice: "File is empty.", + totalLinesInFile: 0, + isBinary: false, + wasTruncated: false, + wasRangeRead: false, + }) + }) + + it("should handle files with source code definitions", async () => { + const { parseSourceCodeDefinitionsForFile: localMockedParse } = jest.requireMock("../../services/tree-sitter") + const definitions = "function test() {}" + localMockedParse.mockResolvedValue(definitions) + + const { createInterface } = require("node:readline") + createInterface.mockImplementation(() => ({ + [Symbol.asyncIterator]: jest.fn().mockImplementation(function* () { + for (let i = 1; i <= 5; i++) { + yield `line ${i}` + } + }), + close: jest.fn(), + })) + + const result = await processFileForReading( + absoluteFilePath, + testFilePath, + 0, // maxReadFileLine = 0 to trigger definitions lookup + undefined, + undefined, + mockRooIgnoreController, + ) + + expect(localMockedParse).toHaveBeenCalled() + expect(result).toMatchObject({ + relativePath: testFilePath, + sourceCodeDefinitions: definitions, + notice: "Content omitted (maxReadFileLine: 0). Showing definitions if available.", + totalLinesInFile: 5, + isBinary: false, + wasTruncated: true, + wasRangeRead: false, + }) + }) +}) From 533d9e954070f99d0b5d817187e9dfca5a9b6b5a Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Tue, 13 May 2025 17:07:22 -0500 Subject: [PATCH 09/10] docs: commit to retrigger tests --- src/shared/fileReadUtils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shared/fileReadUtils.ts b/src/shared/fileReadUtils.ts index a3f73947b8..0b4fce8fff 100644 --- a/src/shared/fileReadUtils.ts +++ b/src/shared/fileReadUtils.ts @@ -99,7 +99,7 @@ export async function processFileForReading( requestedEndLine: number | undefined, rooIgnoreController: RooIgnoreController | undefined, ): Promise { - // 1. Initial checks & setup + // Initial checks & setup if (rooIgnoreController && !rooIgnoreController.validateAccess(relativePath)) { return { relativePath, @@ -111,7 +111,7 @@ export async function processFileForReading( } } - // 2. Validate range parameters + // Validate range parameters const baseErrorResult = { relativePath, totalLinesInFile: 0, @@ -134,7 +134,7 @@ export async function processFileForReading( return { ...baseErrorResult, error: "start_line must be less than or equal to end_line" } } - // 3. Count lines and check for binary (moved after range validation) + // Count lines and check for binary (moved after range validation) let totalLinesInFile = 0 try { totalLinesInFile = await countFileLines(absolutePath) @@ -164,13 +164,13 @@ export async function processFileForReading( wasRangeRead, } - // 2. Handle binary files + // Handle binary files if (isBinary) { result.notice = "File is binary. Content display may be limited." return result } - // 3. Determine read strategy + // Determine read strategy if (wasRangeRead) { // Range read logic const linesArray = await readLines(absolutePath, endLine0Based, startLine0Based) From fe2387e703f5614b4d5a879404f4407226c9b23f Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Tue, 27 May 2025 21:53:03 -0500 Subject: [PATCH 10/10] fix: outdated imports --- .../src/context/ExtensionStateContext.tsx | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 1e66281257..1a6960a49c 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,20 +1,24 @@ import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import { useEvent } from "react-use" -import { ProviderSettingsEntry, ExtensionMessage, ExtensionState } from "@roo/shared/ExtensionMessage" -import { ProviderSettings } from "@roo/shared/api" -import { findLastIndex } from "@roo/shared/array" -import { McpServer } from "@roo/shared/mcp" -import { checkExistKey } from "@roo/shared/checkExistApiConfig" -import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig } from "@roo/shared/modes" -import { CustomSupportPrompts } from "@roo/shared/support-prompt" -import { experimentDefault, ExperimentId } from "@roo/shared/experiments" -import { TelemetrySetting } from "@roo/shared/TelemetrySetting" -import { RouterModels } from "@roo/shared/api" -import { AttachedFileSpec } from "../../../src/shared/tools" +import type { + ProviderSettings, + ProviderSettingsEntry, + CustomModePrompts, + ModeConfig, + ExperimentId, +} from "@roo-code/types" -import { vscode } from "@src/utils/vscode" -import { convertTextMateToHljs } from "@src/utils/textMateToHljs" +import { ExtensionMessage, ExtensionState } from "@roo/ExtensionMessage" +import { findLastIndex } from "@roo/array" +import { McpServer } from "@roo/mcp" +import { checkExistKey } from "@roo/checkExistApiConfig" +import { Mode, defaultModeSlug, defaultPrompts } from "@roo/modes" +import { CustomSupportPrompts } from "@roo/support-prompt" +import { experimentDefault } from "@roo/experiments" +import { TelemetrySetting } from "@roo/TelemetrySetting" +import { RouterModels } from "@roo/api" +import { AttachedFileSpec } from "../../../src/shared/tools" export interface ExtensionStateContextType extends ExtensionState { historyPreviewCollapsed?: boolean // Add the new state property @@ -25,7 +29,7 @@ export interface ExtensionStateContextType extends ExtensionState { hasSystemPromptOverride?: boolean currentCheckpoint?: string filePaths: string[] - attachedFiles?: AttachedFileSpec[] + attachedFiles?: AttachedFileSpec[] openedTabs: Array<{ label: string; isActive: boolean; path?: string }> condensingApiConfigId?: string setCondensingApiConfigId: (value: string) => void