From ac2de60560fcb7d4f0dd6aebd2727838d59b8099 Mon Sep 17 00:00:00 2001 From: Ruslan Andreev Date: Mon, 2 Mar 2026 17:45:36 +0300 Subject: [PATCH] feat: Add read_lints tool Integrate the read_lints tool into the assistant message handling and native tool call parser. Update task management to track edited file paths for read_lints functionality. Enhance tool parameter definitions and ensure proper registration in the native tools index. --- packages/types/src/tool.ts | 1 + .../assistant-message/NativeToolCallParser.ts | 31 +++ .../presentAssistantMessage.ts | 10 + src/core/prompts/tools/native-tools/index.ts | 2 + .../prompts/tools/native-tools/read_lints.ts | 38 ++++ src/core/task/Task.ts | 11 + src/core/tools/ReadLintsTool.ts | 126 +++++++++++ .../tools/__tests__/readLintsTool.spec.ts | 209 ++++++++++++++++++ src/integrations/editor/DiffViewProvider.ts | 10 + src/shared/tools.ts | 5 +- 10 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 src/core/prompts/tools/native-tools/read_lints.ts create mode 100644 src/core/tools/ReadLintsTool.ts create mode 100644 src/core/tools/__tests__/readLintsTool.spec.ts diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9fc..f7139ed4e2d 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -34,6 +34,7 @@ export const toolNames = [ "apply_patch", "search_files", "list_files", + "read_lints", "use_mcp_tool", "access_mcp_resource", "ask_followup_question", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index bda7c71eb8d..a83cdad2143 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -89,6 +89,23 @@ export class NativeToolCallParser { return undefined } + private static coerceStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string") + } + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown + return Array.isArray(parsed) + ? (parsed as unknown[]).filter((item): item is string => typeof item === "string") + : [] + } catch { + return [] + } + } + return [] + } + /** * Process a raw tool call chunk from the API stream. * Handles tracking, buffering, and emits start/delta/end events. @@ -627,6 +644,14 @@ export class NativeToolCallParser { } break + case "read_lints": + if (partialArgs.paths !== undefined) { + nativeArgs = { + paths: this.coerceStringArray(partialArgs.paths), + } + } + break + case "new_task": if (partialArgs.mode !== undefined || partialArgs.message !== undefined) { nativeArgs = { @@ -976,6 +1001,12 @@ export class NativeToolCallParser { } break + case "read_lints": + nativeArgs = { + paths: this.coerceStringArray(args.paths), + } as NativeArgsFor + break + case "new_task": if (args.mode !== undefined && args.message !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be154..7d1b06dba93 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,6 +37,7 @@ import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" +import { readLintsTool } from "../tools/ReadLintsTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" @@ -355,6 +356,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name}]` case "list_files": return `[${block.name} for '${block.params.path}']` + case "read_lints": + return block.params.paths ? `[${block.name} for paths]` : `[${block.name}]` case "use_mcp_tool": return `[${block.name} for '${block.params.server_name}']` case "access_mcp_resource": @@ -747,6 +750,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "read_lints": + await readLintsTool.handle(cline, block as ToolUse<"read_lints">, { + askApproval, + handleError, + pushToolResult, + }) + break case "codebase_search": await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { askApproval, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d65..ae0ef2421b0 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -10,6 +10,7 @@ import executeCommand from "./execute_command" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" +import readLints from "./read_lints" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" @@ -57,6 +58,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch generateImage, listFiles, newTask, + readLints, readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, diff --git a/src/core/prompts/tools/native-tools/read_lints.ts b/src/core/prompts/tools/native-tools/read_lints.ts new file mode 100644 index 00000000000..c7a31149780 --- /dev/null +++ b/src/core/prompts/tools/native-tools/read_lints.ts @@ -0,0 +1,38 @@ +import type OpenAI from "openai" + +const READ_LINTS_DESCRIPTION = `Read linter errors and warnings from the workspace. Use this after editing files to check for problems. If no paths are provided, returns diagnostics only for files that have been edited in this task. If paths are provided, returns diagnostics for those files or directories (relative to the current workspace directory). + +Parameters: +- paths: (optional) Array of file or directory paths to get diagnostics for. If omitted, returns diagnostics for files edited in this task (or a message if none edited yet). + +Example: Get lints for files edited in this task +{ } + +Example: Get lints for a specific file +{ "paths": ["src/foo.ts"] } + +Example: Get lints for a directory +{ "paths": ["src"] }` + +const PATHS_PARAMETER_DESCRIPTION = `Optional array of file or directory paths (relative to workspace) to get diagnostics for. If omitted, returns diagnostics only for files edited in this task.` + +export default { + type: "function", + function: { + name: "read_lints", + description: READ_LINTS_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + paths: { + type: "array", + items: { type: "string" }, + description: PATHS_PARAMETER_DESCRIPTION, + }, + }, + required: [], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f8a5e49fdc..82f053e4c89 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -304,6 +304,8 @@ export class Task extends EventEmitter implements TaskLike { diffViewProvider: DiffViewProvider diffStrategy?: DiffStrategy didEditFile: boolean = false + /** Relative paths (POSIX) of files written in this task, for read_lints "edited only" scope. */ + editedFilePaths: Set = new Set() // LLM Messages & Chat Messages apiConversationHistory: ApiMessage[] = [] @@ -1876,6 +1878,15 @@ export class Task extends EventEmitter implements TaskLike { return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) } + /** + * Record that a file was edited in this task (for read_lints "edited only" scope). + * Call this after successfully saving a file via DiffViewProvider. + */ + recordEditedFile(relPath: string): void { + const normalized = path.relative(this.cwd, path.resolve(this.cwd, relPath)).toPosix() + this.editedFilePaths.add(normalized) + } + // Lifecycle // Start / Resume / Abort / Dispose diff --git a/src/core/tools/ReadLintsTool.ts b/src/core/tools/ReadLintsTool.ts new file mode 100644 index 00000000000..7a58903ee57 --- /dev/null +++ b/src/core/tools/ReadLintsTool.ts @@ -0,0 +1,126 @@ +import * as path from "path" +import * as vscode from "vscode" + +import { Task } from "../task/Task" +import { diagnosticsToProblemsString } from "../../integrations/diagnostics" +import type { ToolUse } from "../../shared/tools" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +const NO_EDITS_MESSAGE = + "No files have been edited in this task yet. Edit a file, then use read_lints to see errors and warnings." +const NO_PROBLEMS_MESSAGE = "No errors or warnings detected." + +interface ReadLintsParams { + paths?: string[] +} + +/** + * Normalize a path to POSIX relative form for comparison with editedFilePaths. + */ +function toRelativePosix(cwd: string, absolutePath: string): string { + return path.relative(cwd, absolutePath).toPosix() +} + +/** + * Check if a file URI is under a directory (both relative to cwd). + */ +function isUnderDir(fileRelPosix: string, dirRelPosix: string): boolean { + if (dirRelPosix === "." || dirRelPosix === "") { + return true + } + const norm = dirRelPosix.endsWith("/") ? dirRelPosix : dirRelPosix + "/" + return fileRelPosix === dirRelPosix || fileRelPosix.startsWith(norm) +} + +export class ReadLintsTool extends BaseTool<"read_lints"> { + readonly name = "read_lints" as const + + async execute(params: ReadLintsParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError } = callbacks + const { paths } = params + const cwd = task.cwd + + try { + const state = await task.providerRef.deref()?.getState() + const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true + const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50 + + if (!includeDiagnosticMessages) { + pushToolResult(NO_PROBLEMS_MESSAGE) + return + } + + let diagnosticsTuples: [vscode.Uri, vscode.Diagnostic[]][] = [] + + if (paths === undefined || paths.length === 0) { + // No paths: return diagnostics only for files edited in this task + if (task.editedFilePaths.size === 0) { + pushToolResult(NO_EDITS_MESSAGE) + return + } + const allDiagnostics = vscode.languages.getDiagnostics() + const editedSet = task.editedFilePaths + for (const [uri, diags] of allDiagnostics) { + const relPosix = toRelativePosix(cwd, uri.fsPath) + if (editedSet.has(relPosix)) { + diagnosticsTuples.push([uri, diags]) + } + } + } else { + // Paths provided: return diagnostics for those files/directories + const allDiagnostics = vscode.languages.getDiagnostics() + const dirPaths: string[] = [] + const fileUris: vscode.Uri[] = [] + + for (const relPath of paths) { + if (!relPath || typeof relPath !== "string") continue + const absolutePath = path.resolve(cwd, relPath) + const uri = vscode.Uri.file(absolutePath) + try { + const stat = await vscode.workspace.fs.stat(uri) + if (stat.type === vscode.FileType.Directory) { + dirPaths.push(toRelativePosix(cwd, absolutePath)) + } else { + fileUris.push(uri) + } + } catch { + // Path may not exist; treat as file and try getDiagnostics(uri) + fileUris.push(uri) + } + } + + const seenUri = new Set() + for (const uri of fileUris) { + const diags = vscode.languages.getDiagnostics(uri) + if (diags.length > 0) { + diagnosticsTuples.push([uri, diags]) + seenUri.add(uri.toString()) + } + } + for (const [uri, diags] of allDiagnostics) { + if (diags.length === 0 || seenUri.has(uri.toString())) continue + const fileRelPosix = toRelativePosix(cwd, uri.fsPath) + const included = dirPaths.some((dirRelPosix) => isUnderDir(fileRelPosix, dirRelPosix)) + if (included) { + diagnosticsTuples.push([uri, diags]) + } + } + } + + const result = await diagnosticsToProblemsString( + diagnosticsTuples, + [vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning], + cwd, + true, + maxDiagnosticMessages, + ) + + pushToolResult(result.trim() ? result.trim() : NO_PROBLEMS_MESSAGE) + } catch (error) { + await handleError("reading lints", error instanceof Error ? error : new Error(String(error))) + } + } +} + +export const readLintsTool = new ReadLintsTool() diff --git a/src/core/tools/__tests__/readLintsTool.spec.ts b/src/core/tools/__tests__/readLintsTool.spec.ts new file mode 100644 index 00000000000..f09c43bed7b --- /dev/null +++ b/src/core/tools/__tests__/readLintsTool.spec.ts @@ -0,0 +1,209 @@ +import * as path from "path" +import * as vscode from "vscode" + +import { readLintsTool } from "../ReadLintsTool" + +const NO_EDITS_MESSAGE = + "No files have been edited in this task yet. Edit a file, then use read_lints to see errors and warnings." +const NO_PROBLEMS_MESSAGE = "No errors or warnings detected." + +vi.mock("vscode", () => ({ + Uri: { + file: (p: string) => ({ + fsPath: p, + toString: () => p, + }), + }, + Diagnostic: class { + constructor( + public range: { start: { line: number }; end: { line: number } }, + public message: string, + public severity: number, + ) {} + }, + Range: class { + start: { line: number; character: number } + end: { line: number; character: number } + constructor(startLine: number, startChar: number, endLine: number, endChar: number) { + this.start = { line: startLine, character: startChar } + this.end = { line: endLine, character: endChar } + } + }, + DiagnosticSeverity: { + Error: 0, + Warning: 1, + Information: 2, + Hint: 3, + }, + FileType: { + Unknown: 0, + File: 1, + Directory: 2, + SymbolicLink: 64, + }, + languages: { + getDiagnostics: vi.fn(), + }, + workspace: { + fs: { + stat: vi.fn(), + }, + openTextDocument: vi.fn(), + }, +})) + +vi.mock("../../../integrations/diagnostics", () => ({ + diagnosticsToProblemsString: vi.fn( + async (diagnostics: [vscode.Uri, vscode.Diagnostic[]][], _severities: unknown, _cwd: string) => { + if (diagnostics.length === 0) return "" + return diagnostics + .map(([uri, diags]) => `${path.basename(uri.fsPath)}\n${diags.map((d) => ` ${d.message}`).join("\n")}`) + .join("\n\n") + }, + ), +})) + +describe("ReadLintsTool", () => { + const cwd = path.resolve("/project") + let mockTask: any + let mockPushToolResult: ReturnType + let mockHandleError: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockPushToolResult = vi.fn() + mockHandleError = vi.fn() + mockTask = { + cwd, + editedFilePaths: new Set(), + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + includeDiagnosticMessages: true, + maxDiagnosticMessages: 50, + }), + }), + }, + } + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + vi.mocked(vscode.workspace.fs.stat).mockResolvedValue({ + type: vscode.FileType.File, + ctime: 0, + mtime: 0, + size: 0, + }) + }) + + it("returns NO_EDITS_MESSAGE when no paths and no edited files", async () => { + mockTask.editedFilePaths = new Set() + await readLintsTool.execute({}, mockTask, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + expect(mockPushToolResult).toHaveBeenCalledWith(NO_EDITS_MESSAGE) + expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() + }) + + it("filters diagnostics to edited files when no paths provided", async () => { + const editedRel = "src/foo.ts" + mockTask.editedFilePaths = new Set([editedRel]) + const fileUri = vscode.Uri.file(path.join(cwd, editedRel)) + const diag = new vscode.Diagnostic(new vscode.Range(0, 0, 0, 5), "Test error", vscode.DiagnosticSeverity.Error) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([[fileUri, [diag]]]) + + await readLintsTool.execute({}, mockTask, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(vscode.languages.getDiagnostics).toHaveBeenCalledWith() + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("foo.ts") + expect(result).toContain("Test error") + }) + + it("returns NO_PROBLEMS_MESSAGE when includeDiagnosticMessages is false", async () => { + mockTask.editedFilePaths = new Set(["src/foo.ts"]) + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + includeDiagnosticMessages: false, + maxDiagnosticMessages: 50, + }), + }) + + await readLintsTool.execute({}, mockTask, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(NO_PROBLEMS_MESSAGE) + }) + + it("returns diagnostics for requested file path", async () => { + const fileUri = vscode.Uri.file(path.join(cwd, "src/bar.ts")) + const diag = new vscode.Diagnostic( + new vscode.Range(1, 0, 1, 10), + "Bar warning", + vscode.DiagnosticSeverity.Warning, + ) + vi.mocked(vscode.languages.getDiagnostics).mockImplementation((uri?: vscode.Uri): any => { + if (uri && uri.fsPath === fileUri.fsPath) return [diag] + return [] + }) + + await readLintsTool.execute({ paths: ["src/bar.ts"] }, mockTask, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Bar warning") + }) + + it("returns NO_PROBLEMS_MESSAGE when diagnostics are empty", async () => { + mockTask.editedFilePaths = new Set(["src/empty.ts"]) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + await readLintsTool.execute({}, mockTask, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(NO_PROBLEMS_MESSAGE) + }) + + it("only includes Error and Warning severities in output", async () => { + const { diagnosticsToProblemsString } = await import("../../../integrations/diagnostics") + vi.mocked(diagnosticsToProblemsString).mockResolvedValue("file.ts\n Error message\n Warning message") + + mockTask.editedFilePaths = new Set(["src/file.ts"]) + const fileUri = vscode.Uri.file(path.join(cwd, "src/file.ts")) + const diagnostics = [ + new vscode.Diagnostic(new vscode.Range(0, 0, 0, 5), "Error message", vscode.DiagnosticSeverity.Error), + new vscode.Diagnostic(new vscode.Range(1, 0, 1, 5), "Warning message", vscode.DiagnosticSeverity.Warning), + new vscode.Diagnostic(new vscode.Range(2, 0, 2, 5), "Info message", vscode.DiagnosticSeverity.Information), + ] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([[fileUri, diagnostics]]) + + await readLintsTool.execute({}, mockTask, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(diagnosticsToProblemsString).toHaveBeenCalledWith( + expect.any(Array), + [vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning], + cwd, + true, + 50, + ) + }) +}) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 80b57992173..6e6631eb836 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -213,6 +213,11 @@ export class DiffViewProvider { await updatedDocument.save() } + const task = this.taskRef.deref() + if (task?.recordEditedFile && this.relPath) { + task.recordEditedFile(this.relPath) + } + await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false, preserveFocus: true }) await this.closeAllDiffViews() @@ -718,6 +723,11 @@ export class DiffViewProvider { this.relPath = relPath this.newContent = content + const taskForRecord = this.taskRef.deref() + if (taskForRecord?.recordEditedFile) { + taskForRecord.recordEditedFile(relPath) + } + return { newProblemsMessage, userEdits: undefined, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d2dd9907b17..a719935da06 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -26,6 +26,7 @@ export interface TextContent { export const toolParamNames = [ "command", "path", + "paths", "content", "regex", "file_pattern", @@ -102,6 +103,7 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } + read_lints: { paths?: string[] } new_task: { mode: string; message: string; todos?: string } ask_followup_question: { question: string @@ -278,6 +280,7 @@ export const TOOL_DISPLAY_NAMES: Record = { apply_patch: "apply patches using codex format", search_files: "search files", list_files: "list files", + read_lints: "read lints", use_mcp_tool: "use mcp tools", access_mcp_resource: "access mcp resources", ask_followup_question: "ask questions", @@ -295,7 +298,7 @@ export const TOOL_DISPLAY_NAMES: Record = { // Define available tool groups. export const TOOL_GROUPS: Record = { read: { - tools: ["read_file", "search_files", "list_files", "codebase_search"], + tools: ["read_file", "search_files", "list_files", "codebase_search", "read_lints"], }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"],