mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
refactor(read_file): Codex-inspired read_file refactor EXT-617 (#10981)
This commit is contained in:
parent
0f43cc9814
commit
cc86049f10
62 changed files with 3448 additions and 4553 deletions
|
|
@ -376,7 +376,7 @@ suite.skip("Roo Code read_file Tool", function () {
|
|||
}
|
||||
})
|
||||
|
||||
test("Should read file with line range", async function () {
|
||||
test("Should read file with slice offset/limit", async function () {
|
||||
const api = globalThis.api
|
||||
const messages: ClineMessage[] = []
|
||||
let taskCompleted = false
|
||||
|
|
@ -446,7 +446,7 @@ suite.skip("Roo Code read_file Tool", function () {
|
|||
alwaysAllowReadOnly: true,
|
||||
alwaysAllowReadOnlyOutsideWorkspace: true,
|
||||
},
|
||||
text: `Use the read_file tool to read the file "${fileName}" and show me what's on lines 2, 3, and 4. The file contains lines like "Line 1", "Line 2", etc. Assume the file exists and you can read it directly.`,
|
||||
text: `Use the read_file tool to read the file "${fileName}" using slice mode with offset=2 and limit=3 (1-based offset). The file contains lines like "Line 1", "Line 2", etc. After reading, show me the three lines you read.`,
|
||||
})
|
||||
|
||||
// Wait for task completion
|
||||
|
|
@ -455,9 +455,8 @@ suite.skip("Roo Code read_file Tool", function () {
|
|||
// Verify tool was executed
|
||||
assert.ok(toolExecuted, "The read_file tool should have been executed")
|
||||
|
||||
// Verify the tool returned the correct lines (when line range is used)
|
||||
// Verify the tool returned the correct lines (offset=2, limit=3 -> lines 2-4)
|
||||
if (toolResult && (toolResult as string).includes(" | ")) {
|
||||
// The result includes line numbers
|
||||
assert.ok(
|
||||
(toolResult as string).includes("2 | Line 2"),
|
||||
"Tool result should include line 2 with line number",
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ describe("CloudSettingsService - Response Parsing", () => {
|
|||
version: 2,
|
||||
defaultSettings: {
|
||||
maxOpenTabsContext: 10,
|
||||
maxReadFileLine: 1000,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: false,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
|
|||
.pick({
|
||||
enableCheckpoints: true,
|
||||
maxOpenTabsContext: true,
|
||||
maxReadFileLine: true,
|
||||
maxWorkspaceFiles: true,
|
||||
showRooIgnoredFiles: true,
|
||||
terminalCommandDelay: true,
|
||||
|
|
@ -107,7 +106,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
|
|||
.merge(
|
||||
z.object({
|
||||
maxOpenTabsContext: z.number().int().nonnegative().optional(),
|
||||
maxReadFileLine: z.number().int().gte(-1).optional(),
|
||||
maxWorkspaceFiles: z.number().int().nonnegative().optional(),
|
||||
terminalCommandDelay: z.number().int().nonnegative().optional(),
|
||||
terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(),
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ export const globalSettingsSchema = z.object({
|
|||
allowedMaxCost: z.number().nullish(),
|
||||
autoCondenseContext: z.boolean().optional(),
|
||||
autoCondenseContextPercent: z.number().optional(),
|
||||
maxConcurrentFileReads: z.number().optional(),
|
||||
|
||||
/**
|
||||
* Whether to include current time in the environment details
|
||||
|
|
@ -172,7 +171,6 @@ export const globalSettingsSchema = z.object({
|
|||
maxWorkspaceFiles: z.number().optional(),
|
||||
showRooIgnoredFiles: z.boolean().optional(),
|
||||
enableSubfolderRules: z.boolean().optional(),
|
||||
maxReadFileLine: z.number().optional(),
|
||||
maxImageFileSize: z.number().optional(),
|
||||
maxTotalImageSize: z.number().optional(),
|
||||
|
||||
|
|
@ -382,7 +380,6 @@ export const EVALS_SETTINGS: RooCodeSettings = {
|
|||
maxWorkspaceFiles: 200,
|
||||
maxGitStatusFiles: 20,
|
||||
showRooIgnoredFiles: true,
|
||||
maxReadFileLine: -1, // -1 to enable full file reading.
|
||||
|
||||
includeDiagnosticMessages: true,
|
||||
maxDiagnosticMessages: 50,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export enum TelemetryEventName {
|
|||
CODE_INDEX_ERROR = "Code Index Error",
|
||||
TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed",
|
||||
MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response",
|
||||
READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,6 +204,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
|
|||
TelemetryEventName.TAB_SHOWN,
|
||||
TelemetryEventName.MODE_SETTINGS_CHANGED,
|
||||
TelemetryEventName.CUSTOM_MODE_CREATED,
|
||||
TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED,
|
||||
]),
|
||||
properties: telemetryPropertiesSchema,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,16 +2,96 @@
|
|||
* Tool parameter type definitions for native protocol
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read mode for the read_file tool.
|
||||
* - "slice": Simple offset/limit reading (default)
|
||||
* - "indentation": Semantic block extraction based on code structure
|
||||
*/
|
||||
export type ReadFileMode = "slice" | "indentation"
|
||||
|
||||
/**
|
||||
* Indentation-mode configuration for the read_file tool.
|
||||
*/
|
||||
export interface IndentationParams {
|
||||
/** 1-based line number to anchor indentation extraction (defaults to offset) */
|
||||
anchor_line?: number
|
||||
/** Maximum indentation levels to include above anchor (0 = unlimited) */
|
||||
max_levels?: number
|
||||
/** Include sibling blocks at the same indentation level */
|
||||
include_siblings?: boolean
|
||||
/** Include file header (imports, comments at top) */
|
||||
include_header?: boolean
|
||||
/** Hard cap on lines returned for indentation mode */
|
||||
max_lines?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the read_file tool (new format).
|
||||
*
|
||||
* NOTE: This is the canonical, single-file-per-call shape.
|
||||
*/
|
||||
export interface ReadFileParams {
|
||||
/** Path to the file, relative to workspace */
|
||||
path: string
|
||||
/** Reading mode: "slice" (default) or "indentation" */
|
||||
mode?: ReadFileMode
|
||||
/** 1-based line number to start reading from (slice mode, default: 1) */
|
||||
offset?: number
|
||||
/** Maximum number of lines to read (default: 2000) */
|
||||
limit?: number
|
||||
/** Indentation-mode configuration (only used when mode === "indentation") */
|
||||
indentation?: IndentationParams
|
||||
}
|
||||
|
||||
// ─── Legacy Format Types (Backward Compatibility) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Line range specification for legacy read_file format.
|
||||
* Represents a contiguous range of lines [start, end] (1-based, inclusive).
|
||||
*/
|
||||
export interface LineRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* File entry for legacy read_file format.
|
||||
* Supports reading multiple disjoint line ranges from a single file.
|
||||
*/
|
||||
export interface FileEntry {
|
||||
/** Path to the file, relative to workspace */
|
||||
path: string
|
||||
/** Optional list of line ranges to read (if omitted, reads entire file) */
|
||||
lineRanges?: LineRange[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy parameters for the read_file tool (pre-refactor format).
|
||||
* Supports reading multiple files in a single call with optional line ranges.
|
||||
*
|
||||
* @deprecated Use ReadFileParams instead. This format is maintained for
|
||||
* backward compatibility with existing chat histories.
|
||||
*/
|
||||
export interface LegacyReadFileParams {
|
||||
/** Array of file entries to read */
|
||||
files: FileEntry[]
|
||||
/** Discriminant flag for type narrowing */
|
||||
_legacyFormat: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for read_file tool parameters.
|
||||
* Supports both new single-file format and legacy multi-file format.
|
||||
*/
|
||||
export type ReadFileToolParams = ReadFileParams | LegacyReadFileParams
|
||||
|
||||
/**
|
||||
* Type guard to check if params are in legacy format.
|
||||
*/
|
||||
export function isLegacyReadFileParams(params: ReadFileToolParams): params is LegacyReadFileParams {
|
||||
return "_legacyFormat" in params && params._legacyFormat === true
|
||||
}
|
||||
|
||||
export interface Coordinate {
|
||||
x: number
|
||||
y: number
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ export interface ExtensionMessage {
|
|||
| "remoteBrowserEnabled"
|
||||
| "ttsStart"
|
||||
| "ttsStop"
|
||||
| "maxReadFileLine"
|
||||
| "fileSearchResults"
|
||||
| "toggleApiConfigPin"
|
||||
| "acceptInput"
|
||||
|
|
@ -304,7 +303,6 @@ export type ExtensionState = Pick<
|
|||
| "ttsSpeed"
|
||||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "maxConcurrentFileReads"
|
||||
| "terminalOutputPreviewSize"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
|
|
@ -355,7 +353,6 @@ export type ExtensionState = Pick<
|
|||
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
|
||||
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
|
||||
enableSubfolderRules: boolean // Whether to load rules from subdirectories
|
||||
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
|
||||
maxImageFileSize: number // Maximum size of image files to process in MB
|
||||
maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB
|
||||
|
||||
|
|
@ -818,6 +815,7 @@ export interface ClineSayTool {
|
|||
isProtected?: boolean
|
||||
additionalFileCount?: number // Number of additional files in the same read_file request
|
||||
lineNumber?: number
|
||||
startLine?: number // Starting line for read_file operations (for navigation on click)
|
||||
query?: string
|
||||
batchFiles?: Array<{
|
||||
path: string
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ describe("Command Mentions", () => {
|
|||
false, // showRooIgnoredFiles
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined, // maxReadFileLine
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,23 +135,18 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
|
|||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "integer" },
|
||||
description: "Optional line ranges",
|
||||
},
|
||||
path: { type: "string" },
|
||||
indentation: {
|
||||
type: ["object", "null"],
|
||||
properties: {
|
||||
anchor_line: {
|
||||
type: ["integer", "null"],
|
||||
description: "Optional anchor line",
|
||||
},
|
||||
required: ["path", "line_ranges"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["files"],
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -167,15 +162,14 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
|
|||
expect(executeCommandSchema.properties.cwd.type).toBeUndefined()
|
||||
expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)")
|
||||
|
||||
// Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf
|
||||
// with items moved inside the array variant (required by GPT-5-mini strict schema validation)
|
||||
// Second tool: nested nullable object should be transformed from type: ["object", "null"] to anyOf
|
||||
const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any
|
||||
const lineRanges = readFileSchema.properties.files.items.properties.line_ranges
|
||||
expect(lineRanges.anyOf).toEqual([{ type: "array", items: { type: "integer" } }, { type: "null" }])
|
||||
expect(lineRanges.type).toBeUndefined()
|
||||
// items should now be inside the array variant, not at root
|
||||
expect(lineRanges.items).toBeUndefined()
|
||||
expect(lineRanges.description).toBe("Optional line ranges")
|
||||
const indentation = readFileSchema.properties.indentation
|
||||
expect(indentation.anyOf).toBeDefined()
|
||||
expect(indentation.type).toBeUndefined()
|
||||
// Object-level schema properties are preserved at the root, not inside the anyOf object variant
|
||||
expect(indentation.additionalProperties).toBe(false)
|
||||
expect(indentation.properties.anchor_line.anyOf).toEqual([{ type: "integer" }, { type: "null" }])
|
||||
})
|
||||
|
||||
it("should filter non-function tools", () => {
|
||||
|
|
|
|||
|
|
@ -313,9 +313,22 @@ export class NativeToolCallParser {
|
|||
return finalToolUse
|
||||
}
|
||||
|
||||
private static coerceOptionalNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n)) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw file entries from API (with line_ranges) to FileEntry objects
|
||||
* (with lineRanges). Handles multiple formats for compatibility:
|
||||
* (with lineRanges). Handles multiple formats for backward compatibility:
|
||||
*
|
||||
* New tuple format: { path: string, line_ranges: [[1, 50], [100, 150]] }
|
||||
* Object format: { path: string, line_ranges: [{ start: 1, end: 50 }] }
|
||||
|
|
@ -323,19 +336,21 @@ export class NativeToolCallParser {
|
|||
*
|
||||
* Returns: { path: string, lineRanges: [{ start: 1, end: 50 }] }
|
||||
*/
|
||||
private static convertFileEntries(files: any[]): FileEntry[] {
|
||||
return files.map((file: any) => {
|
||||
const entry: FileEntry = { path: file.path }
|
||||
if (file.line_ranges && Array.isArray(file.line_ranges)) {
|
||||
entry.lineRanges = file.line_ranges
|
||||
.map((range: any) => {
|
||||
private static convertFileEntries(files: unknown[]): FileEntry[] {
|
||||
return files.map((file: unknown) => {
|
||||
const f = file as Record<string, unknown>
|
||||
const entry: FileEntry = { path: f.path as string }
|
||||
if (f.line_ranges && Array.isArray(f.line_ranges)) {
|
||||
entry.lineRanges = (f.line_ranges as unknown[])
|
||||
.map((range: unknown) => {
|
||||
// Handle tuple format: [start, end]
|
||||
if (Array.isArray(range) && range.length >= 2) {
|
||||
return { start: Number(range[0]), end: Number(range[1]) }
|
||||
}
|
||||
// Handle object format: { start: number, end: number }
|
||||
if (typeof range === "object" && range !== null && "start" in range && "end" in range) {
|
||||
return { start: Number(range.start), end: Number(range.end) }
|
||||
const r = range as { start: unknown; end: unknown }
|
||||
return { start: Number(r.start), end: Number(r.end) }
|
||||
}
|
||||
// Handle legacy string format: "1-50"
|
||||
if (typeof range === "string") {
|
||||
|
|
@ -346,7 +361,7 @@ export class NativeToolCallParser {
|
|||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((r): r is { start: number; end: number } => r !== null)
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
|
@ -378,10 +393,60 @@ export class NativeToolCallParser {
|
|||
// Build partial nativeArgs based on what we have so far
|
||||
let nativeArgs: any = undefined
|
||||
|
||||
// Track if legacy format was used (for telemetry)
|
||||
let usedLegacyFormat = false
|
||||
|
||||
switch (name) {
|
||||
case "read_file":
|
||||
if (partialArgs.files && Array.isArray(partialArgs.files)) {
|
||||
nativeArgs = { files: this.convertFileEntries(partialArgs.files) }
|
||||
// Check for legacy format first: { files: [...] }
|
||||
// Handle both array and stringified array (some models double-stringify)
|
||||
if (partialArgs.files !== undefined) {
|
||||
let filesArray: unknown[] | null = null
|
||||
|
||||
if (Array.isArray(partialArgs.files)) {
|
||||
filesArray = partialArgs.files
|
||||
} else if (typeof partialArgs.files === "string") {
|
||||
// Handle double-stringified case: files is a string containing JSON array
|
||||
try {
|
||||
const parsed = JSON.parse(partialArgs.files)
|
||||
if (Array.isArray(parsed)) {
|
||||
filesArray = parsed
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (filesArray && filesArray.length > 0) {
|
||||
usedLegacyFormat = true
|
||||
nativeArgs = {
|
||||
files: this.convertFileEntries(filesArray),
|
||||
_legacyFormat: true as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
// New format: { path: "...", mode: "..." }
|
||||
if (!nativeArgs && partialArgs.path !== undefined) {
|
||||
nativeArgs = {
|
||||
path: partialArgs.path,
|
||||
mode: partialArgs.mode,
|
||||
offset: this.coerceOptionalNumber(partialArgs.offset),
|
||||
limit: this.coerceOptionalNumber(partialArgs.limit),
|
||||
indentation:
|
||||
partialArgs.indentation && typeof partialArgs.indentation === "object"
|
||||
? {
|
||||
anchor_line: this.coerceOptionalNumber(partialArgs.indentation.anchor_line),
|
||||
max_levels: this.coerceOptionalNumber(partialArgs.indentation.max_levels),
|
||||
max_lines: this.coerceOptionalNumber(partialArgs.indentation.max_lines),
|
||||
include_siblings: this.coerceOptionalBoolean(
|
||||
partialArgs.indentation.include_siblings,
|
||||
),
|
||||
include_header: this.coerceOptionalBoolean(
|
||||
partialArgs.indentation.include_header,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
|
|
@ -596,6 +661,11 @@ export class NativeToolCallParser {
|
|||
result.originalName = originalName
|
||||
}
|
||||
|
||||
// Track legacy format usage for telemetry
|
||||
if (usedLegacyFormat) {
|
||||
result.usedLegacyFormat = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -642,13 +712,6 @@ export class NativeToolCallParser {
|
|||
const params: Partial<Record<ToolParamName, string>> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
// Skip complex parameters that have been migrated to nativeArgs.
|
||||
// For read_file, the 'files' parameter is a FileEntry[] array that can't be
|
||||
// meaningfully stringified. The properly typed data is in nativeArgs instead.
|
||||
if (resolvedName === "read_file" && key === "files") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate parameter name
|
||||
if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) {
|
||||
console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`)
|
||||
|
|
@ -666,10 +729,58 @@ export class NativeToolCallParser {
|
|||
// nativeArgs object. If validation fails, we treat the tool call as invalid and fail fast.
|
||||
let nativeArgs: NativeArgsFor<TName> | undefined = undefined
|
||||
|
||||
// Track if legacy format was used (for telemetry)
|
||||
let usedLegacyFormat = false
|
||||
|
||||
switch (resolvedName) {
|
||||
case "read_file":
|
||||
if (args.files && Array.isArray(args.files)) {
|
||||
nativeArgs = { files: this.convertFileEntries(args.files) } as NativeArgsFor<TName>
|
||||
// Check for legacy format first: { files: [...] }
|
||||
// Handle both array and stringified array (some models double-stringify)
|
||||
if (args.files !== undefined) {
|
||||
let filesArray: unknown[] | null = null
|
||||
|
||||
if (Array.isArray(args.files)) {
|
||||
filesArray = args.files
|
||||
} else if (typeof args.files === "string") {
|
||||
// Handle double-stringified case: files is a string containing JSON array
|
||||
try {
|
||||
const parsed = JSON.parse(args.files)
|
||||
if (Array.isArray(parsed)) {
|
||||
filesArray = parsed
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (filesArray && filesArray.length > 0) {
|
||||
usedLegacyFormat = true
|
||||
nativeArgs = {
|
||||
files: this.convertFileEntries(filesArray),
|
||||
_legacyFormat: true as const,
|
||||
} as NativeArgsFor<TName>
|
||||
}
|
||||
}
|
||||
// New format: { path: "...", mode: "..." }
|
||||
if (!nativeArgs && args.path !== undefined) {
|
||||
nativeArgs = {
|
||||
path: args.path,
|
||||
mode: args.mode,
|
||||
offset: this.coerceOptionalNumber(args.offset),
|
||||
limit: this.coerceOptionalNumber(args.limit),
|
||||
indentation:
|
||||
args.indentation && typeof args.indentation === "object"
|
||||
? {
|
||||
anchor_line: this.coerceOptionalNumber(args.indentation.anchor_line),
|
||||
max_levels: this.coerceOptionalNumber(args.indentation.max_levels),
|
||||
max_lines: this.coerceOptionalNumber(args.indentation.max_lines),
|
||||
include_siblings: this.coerceOptionalBoolean(
|
||||
args.indentation.include_siblings,
|
||||
),
|
||||
include_header: this.coerceOptionalBoolean(args.indentation.include_header),
|
||||
}
|
||||
: undefined,
|
||||
} as NativeArgsFor<TName>
|
||||
}
|
||||
break
|
||||
|
||||
|
|
@ -918,6 +1029,11 @@ export class NativeToolCallParser {
|
|||
result.originalName = toolCall.name
|
||||
}
|
||||
|
||||
// Track legacy format usage for telemetry
|
||||
if (usedLegacyFormat) {
|
||||
result.usedLegacyFormat = true
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -8,20 +8,12 @@ describe("NativeToolCallParser", () => {
|
|||
|
||||
describe("parseToolCall", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should handle line_ranges as tuples (new format)", () => {
|
||||
it("should parse minimal single-file read_file args", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/core/task/Task.ts",
|
||||
line_ranges: [
|
||||
[1920, 1990],
|
||||
[2060, 2120],
|
||||
],
|
||||
},
|
||||
],
|
||||
path: "src/core/task/Task.ts",
|
||||
}),
|
||||
}
|
||||
|
||||
|
|
@ -31,60 +23,20 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.nativeArgs).toBeDefined()
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/core/task/Task.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 1920, end: 1990 },
|
||||
{ start: 2060, end: 2120 },
|
||||
])
|
||||
const nativeArgs = result.nativeArgs as { path: string }
|
||||
expect(nativeArgs.path).toBe("src/core/task/Task.ts")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle line_ranges as strings (legacy format)", () => {
|
||||
it("should parse slice-mode params", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/core/task/Task.ts",
|
||||
line_ranges: ["1920-1990", "2060-2120"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.nativeArgs).toBeDefined()
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/core/task/Task.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 1920, end: 1990 },
|
||||
{ start: 2060, end: 2120 },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle files without line_ranges", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/utils.ts",
|
||||
},
|
||||
],
|
||||
path: "src/core/task/Task.ts",
|
||||
mode: "slice",
|
||||
offset: 10,
|
||||
limit: 20,
|
||||
}),
|
||||
}
|
||||
|
||||
|
|
@ -94,32 +46,31 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
path: string
|
||||
mode?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/utils.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toBeUndefined()
|
||||
expect(nativeArgs.path).toBe("src/core/task/Task.ts")
|
||||
expect(nativeArgs.mode).toBe("slice")
|
||||
expect(nativeArgs.offset).toBe(10)
|
||||
expect(nativeArgs.limit).toBe(20)
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple files with different line_ranges", () => {
|
||||
it("should parse indentation-mode params", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
line_ranges: ["1-50"],
|
||||
},
|
||||
{
|
||||
path: "file2.ts",
|
||||
line_ranges: ["100-150", "200-250"],
|
||||
},
|
||||
{
|
||||
path: "file3.ts",
|
||||
},
|
||||
],
|
||||
path: "src/utils.ts",
|
||||
mode: "indentation",
|
||||
indentation: {
|
||||
anchor_line: 123,
|
||||
max_levels: 2,
|
||||
include_siblings: true,
|
||||
include_header: false,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
|
|
@ -129,85 +80,242 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
path: string
|
||||
mode?: string
|
||||
indentation?: {
|
||||
anchor_line?: number
|
||||
max_levels?: number
|
||||
include_siblings?: boolean
|
||||
include_header?: boolean
|
||||
}
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(3)
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([{ start: 1, end: 50 }])
|
||||
expect(nativeArgs.files[1].lineRanges).toEqual([
|
||||
{ start: 100, end: 150 },
|
||||
{ start: 200, end: 250 },
|
||||
])
|
||||
expect(nativeArgs.files[2].lineRanges).toBeUndefined()
|
||||
expect(nativeArgs.path).toBe("src/utils.ts")
|
||||
expect(nativeArgs.mode).toBe("indentation")
|
||||
expect(nativeArgs.indentation?.anchor_line).toBe(123)
|
||||
expect(nativeArgs.indentation?.include_siblings).toBe(true)
|
||||
expect(nativeArgs.indentation?.include_header).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it("should filter out invalid line_range strings", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_123",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "file.ts",
|
||||
line_ranges: ["1-50", "invalid", "100-200", "abc-def"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
// Legacy format backward compatibility tests
|
||||
describe("legacy format backward compatibility", () => {
|
||||
it("should parse legacy files array format with single file", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_legacy_1",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [{ path: "src/legacy/file.ts" }],
|
||||
}),
|
||||
}
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 1, end: 50 },
|
||||
{ start: 100, end: 200 },
|
||||
])
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBe(true)
|
||||
const nativeArgs = result.nativeArgs as { files: Array<{ path: string }>; _legacyFormat: true }
|
||||
expect(nativeArgs._legacyFormat).toBe(true)
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].path).toBe("src/legacy/file.ts")
|
||||
}
|
||||
})
|
||||
|
||||
it("should parse legacy files array format with multiple files", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_legacy_2",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [{ path: "src/file1.ts" }, { path: "src/file2.ts" }, { path: "src/file3.ts" }],
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBe(true)
|
||||
const nativeArgs = result.nativeArgs as { files: Array<{ path: string }>; _legacyFormat: true }
|
||||
expect(nativeArgs.files).toHaveLength(3)
|
||||
expect(nativeArgs.files[0].path).toBe("src/file1.ts")
|
||||
expect(nativeArgs.files[1].path).toBe("src/file2.ts")
|
||||
expect(nativeArgs.files[2].path).toBe("src/file3.ts")
|
||||
}
|
||||
})
|
||||
|
||||
it("should parse legacy line_ranges as tuples", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_legacy_3",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/task.ts",
|
||||
line_ranges: [
|
||||
[1, 50],
|
||||
[100, 150],
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBe(true)
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
_legacyFormat: true
|
||||
}
|
||||
expect(nativeArgs.files[0].lineRanges).toHaveLength(2)
|
||||
expect(nativeArgs.files[0].lineRanges?.[0]).toEqual({ start: 1, end: 50 })
|
||||
expect(nativeArgs.files[0].lineRanges?.[1]).toEqual({ start: 100, end: 150 })
|
||||
}
|
||||
})
|
||||
|
||||
it("should parse legacy line_ranges as objects", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_legacy_4",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/task.ts",
|
||||
line_ranges: [
|
||||
{ start: 10, end: 20 },
|
||||
{ start: 30, end: 40 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBe(true)
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
}
|
||||
expect(nativeArgs.files[0].lineRanges).toHaveLength(2)
|
||||
expect(nativeArgs.files[0].lineRanges?.[0]).toEqual({ start: 10, end: 20 })
|
||||
expect(nativeArgs.files[0].lineRanges?.[1]).toEqual({ start: 30, end: 40 })
|
||||
}
|
||||
})
|
||||
|
||||
it("should parse legacy line_ranges as strings", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_legacy_5",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/task.ts",
|
||||
line_ranges: ["1-50", "100-150"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBe(true)
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
}
|
||||
expect(nativeArgs.files[0].lineRanges).toHaveLength(2)
|
||||
expect(nativeArgs.files[0].lineRanges?.[0]).toEqual({ start: 1, end: 50 })
|
||||
expect(nativeArgs.files[0].lineRanges?.[1]).toEqual({ start: 100, end: 150 })
|
||||
}
|
||||
})
|
||||
|
||||
it("should parse double-stringified files array (model quirk)", () => {
|
||||
// This tests the real-world case where some models double-stringify the files array
|
||||
// e.g., { files: "[{\"path\": \"...\"}]" } instead of { files: [{path: "..."}] }
|
||||
const toolCall = {
|
||||
id: "toolu_double_stringify",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
files: JSON.stringify([
|
||||
{ path: "src/services/browser/browserDiscovery.ts" },
|
||||
{ path: "src/services/mcp/McpServerManager.ts" },
|
||||
]),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBe(true)
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string }>
|
||||
_legacyFormat: true
|
||||
}
|
||||
expect(nativeArgs._legacyFormat).toBe(true)
|
||||
expect(nativeArgs.files).toHaveLength(2)
|
||||
expect(nativeArgs.files[0].path).toBe("src/services/browser/browserDiscovery.ts")
|
||||
expect(nativeArgs.files[1].path).toBe("src/services/mcp/McpServerManager.ts")
|
||||
}
|
||||
})
|
||||
|
||||
it("should NOT set usedLegacyFormat for new format", () => {
|
||||
const toolCall = {
|
||||
id: "toolu_new",
|
||||
name: "read_file" as const,
|
||||
arguments: JSON.stringify({
|
||||
path: "src/new/format.ts",
|
||||
mode: "slice",
|
||||
offset: 1,
|
||||
limit: 100,
|
||||
}),
|
||||
}
|
||||
|
||||
const result = NativeToolCallParser.parseToolCall(toolCall)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.usedLegacyFormat).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("processStreamingChunk", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should convert line_ranges strings to lineRanges objects during streaming", () => {
|
||||
it("should emit a partial ToolUse with nativeArgs.path during streaming", () => {
|
||||
const id = "toolu_streaming_123"
|
||||
NativeToolCallParser.startStreamingToolCall(id, "read_file")
|
||||
|
||||
// Simulate streaming chunks
|
||||
const fullArgs = JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "src/test.ts",
|
||||
line_ranges: ["10-20", "30-40"],
|
||||
},
|
||||
],
|
||||
})
|
||||
const fullArgs = JSON.stringify({ path: "src/test.ts" })
|
||||
|
||||
// Process the complete args as a single chunk for simplicity
|
||||
const result = NativeToolCallParser.processStreamingChunk(id, fullArgs)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.nativeArgs).toBeDefined()
|
||||
const nativeArgs = result?.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
}
|
||||
expect(nativeArgs.files).toHaveLength(1)
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([
|
||||
{ start: 10, end: 20 },
|
||||
{ start: 30, end: 40 },
|
||||
])
|
||||
const nativeArgs = result?.nativeArgs as { path: string }
|
||||
expect(nativeArgs.path).toBe("src/test.ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("finalizeStreamingToolCall", () => {
|
||||
describe("read_file tool", () => {
|
||||
it("should convert line_ranges strings to lineRanges objects on finalize", () => {
|
||||
it("should parse read_file args on finalize", () => {
|
||||
const id = "toolu_finalize_123"
|
||||
NativeToolCallParser.startStreamingToolCall(id, "read_file")
|
||||
|
||||
|
|
@ -215,12 +323,10 @@ describe("NativeToolCallParser", () => {
|
|||
NativeToolCallParser.processStreamingChunk(
|
||||
id,
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
path: "finalized.ts",
|
||||
line_ranges: ["500-600"],
|
||||
},
|
||||
],
|
||||
path: "finalized.ts",
|
||||
mode: "slice",
|
||||
offset: 1,
|
||||
limit: 10,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -229,11 +335,10 @@ describe("NativeToolCallParser", () => {
|
|||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe("tool_use")
|
||||
if (result?.type === "tool_use") {
|
||||
const nativeArgs = result.nativeArgs as {
|
||||
files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }>
|
||||
}
|
||||
expect(nativeArgs.files[0].path).toBe("finalized.ts")
|
||||
expect(nativeArgs.files[0].lineRanges).toEqual([{ start: 500, end: 600 }])
|
||||
const nativeArgs = result.nativeArgs as { path: string; offset?: number; limit?: number }
|
||||
expect(nativeArgs.path).toBe("finalized.ts")
|
||||
expect(nativeArgs.offset).toBe(1)
|
||||
expect(nativeArgs.limit).toBe(10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { serializeError } from "serialize-error"
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
|
||||
import { ConsecutiveMistakeError } from "@roo-code/types"
|
||||
import { ConsecutiveMistakeError, TelemetryEventName } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { customToolRegistry } from "@roo-code/core"
|
||||
|
||||
|
|
@ -600,6 +600,15 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
const recordName = isCustomTool ? "custom_tool" : block.name
|
||||
cline.recordToolUsage(recordName)
|
||||
TelemetryService.instance.captureToolUsage(cline.taskId, recordName)
|
||||
|
||||
// Track legacy format usage for read_file tool (for migration monitoring)
|
||||
if (block.name === "read_file" && block.usedLegacyFormat) {
|
||||
const modelInfo = cline.api.getModel()
|
||||
TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, {
|
||||
taskId: cline.taskId,
|
||||
model: modelInfo?.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool use before execution - ONLY for complete (non-partial) blocks.
|
||||
|
|
|
|||
|
|
@ -26,100 +26,10 @@ describe("processUserContentMentions", () => {
|
|||
vi.mocked(parseMentions).mockImplementation(async (text) => ({
|
||||
text: `parsed: ${text}`,
|
||||
mode: undefined,
|
||||
contentBlocks: [],
|
||||
}))
|
||||
})
|
||||
|
||||
describe("maxReadFileLine parameter", () => {
|
||||
it("should pass maxReadFileLine to parseMentions when provided", async () => {
|
||||
const userContent = [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "<user_message>Read file with limit</user_message>",
|
||||
},
|
||||
]
|
||||
|
||||
await processUserContentMentions({
|
||||
userContent,
|
||||
cwd: "/test",
|
||||
urlContentFetcher: mockUrlContentFetcher,
|
||||
fileContextTracker: mockFileContextTracker,
|
||||
rooIgnoreController: mockRooIgnoreController,
|
||||
maxReadFileLine: 100,
|
||||
})
|
||||
|
||||
expect(parseMentions).toHaveBeenCalledWith(
|
||||
"<user_message>Read file with limit</user_message>",
|
||||
"/test",
|
||||
mockUrlContentFetcher,
|
||||
mockFileContextTracker,
|
||||
mockRooIgnoreController,
|
||||
false,
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
100,
|
||||
)
|
||||
})
|
||||
|
||||
it("should pass undefined maxReadFileLine when not provided", async () => {
|
||||
const userContent = [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "<user_message>Read file without limit</user_message>",
|
||||
},
|
||||
]
|
||||
|
||||
await processUserContentMentions({
|
||||
userContent,
|
||||
cwd: "/test",
|
||||
urlContentFetcher: mockUrlContentFetcher,
|
||||
fileContextTracker: mockFileContextTracker,
|
||||
rooIgnoreController: mockRooIgnoreController,
|
||||
})
|
||||
|
||||
expect(parseMentions).toHaveBeenCalledWith(
|
||||
"<user_message>Read file without limit</user_message>",
|
||||
"/test",
|
||||
mockUrlContentFetcher,
|
||||
mockFileContextTracker,
|
||||
mockRooIgnoreController,
|
||||
false,
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle UNLIMITED_LINES constant correctly", async () => {
|
||||
const userContent = [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "<user_message>Read unlimited lines</user_message>",
|
||||
},
|
||||
]
|
||||
|
||||
await processUserContentMentions({
|
||||
userContent,
|
||||
cwd: "/test",
|
||||
urlContentFetcher: mockUrlContentFetcher,
|
||||
fileContextTracker: mockFileContextTracker,
|
||||
rooIgnoreController: mockRooIgnoreController,
|
||||
maxReadFileLine: -1,
|
||||
})
|
||||
|
||||
expect(parseMentions).toHaveBeenCalledWith(
|
||||
"<user_message>Read unlimited lines</user_message>",
|
||||
"/test",
|
||||
mockUrlContentFetcher,
|
||||
mockFileContextTracker,
|
||||
mockRooIgnoreController,
|
||||
false,
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
-1,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("content processing", () => {
|
||||
it("should process text blocks with <user_message> tags", async () => {
|
||||
const userContent = [
|
||||
|
|
@ -181,10 +91,16 @@ describe("processUserContentMentions", () => {
|
|||
})
|
||||
|
||||
expect(parseMentions).toHaveBeenCalled()
|
||||
// String content is now converted to array format to support content blocks
|
||||
expect(result.content[0]).toEqual({
|
||||
type: "tool_result",
|
||||
tool_use_id: "123",
|
||||
content: "parsed: <user_message>Tool feedback</user_message>",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "parsed: <user_message>Tool feedback</user_message>",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.mode).toBeUndefined()
|
||||
})
|
||||
|
|
@ -258,7 +174,6 @@ describe("processUserContentMentions", () => {
|
|||
cwd: "/test",
|
||||
urlContentFetcher: mockUrlContentFetcher,
|
||||
fileContextTracker: mockFileContextTracker,
|
||||
maxReadFileLine: 50,
|
||||
})
|
||||
|
||||
expect(parseMentions).toHaveBeenCalledTimes(2)
|
||||
|
|
@ -268,10 +183,16 @@ describe("processUserContentMentions", () => {
|
|||
text: "parsed: <user_message>First task</user_message>",
|
||||
})
|
||||
expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged
|
||||
// String content is now converted to array format to support content blocks
|
||||
expect(result.content[2]).toEqual({
|
||||
type: "tool_result",
|
||||
tool_use_id: "456",
|
||||
content: "parsed: <user_message>Feedback</user_message>",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "parsed: <user_message>Feedback</user_message>",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.mode).toBeUndefined()
|
||||
})
|
||||
|
|
@ -302,7 +223,6 @@ describe("processUserContentMentions", () => {
|
|||
false, // showRooIgnoredFiles should default to false
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -331,7 +251,6 @@ describe("processUserContentMentions", () => {
|
|||
false,
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -342,6 +261,7 @@ describe("processUserContentMentions", () => {
|
|||
text: "parsed text",
|
||||
slashCommandHelp: "command help",
|
||||
mode: undefined,
|
||||
contentBlocks: [],
|
||||
})
|
||||
|
||||
const userContent = [
|
||||
|
|
@ -374,6 +294,7 @@ describe("processUserContentMentions", () => {
|
|||
text: "parsed tool output",
|
||||
slashCommandHelp: "command help",
|
||||
mode: undefined,
|
||||
contentBlocks: [],
|
||||
})
|
||||
|
||||
const userContent = [
|
||||
|
|
@ -413,6 +334,7 @@ describe("processUserContentMentions", () => {
|
|||
text: "parsed array item",
|
||||
slashCommandHelp: "command help",
|
||||
mode: undefined,
|
||||
contentBlocks: [],
|
||||
})
|
||||
|
||||
const userContent = [
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../sh
|
|||
import { getCommitInfo, getWorkingState } from "../../utils/git"
|
||||
|
||||
import { openFile } from "../../integrations/misc/open-file"
|
||||
import { extractTextFromFile } from "../../integrations/misc/extract-text"
|
||||
import { extractTextFromFileWithMetadata, type ExtractTextResult } from "../../integrations/misc/extract-text"
|
||||
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
||||
import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
|
||||
|
||||
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
|
||||
|
||||
|
|
@ -71,12 +72,59 @@ export async function openMention(cwd: string, mention?: string): Promise<void>
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a content block generated from an @ mention.
|
||||
* These are returned separately from the user's text to enable
|
||||
* proper formatting as distinct message blocks.
|
||||
*/
|
||||
export interface MentionContentBlock {
|
||||
type: "file" | "folder" | "url" | "diagnostics" | "git_changes" | "git_commit" | "terminal" | "command"
|
||||
/** Path for file/folder mentions */
|
||||
path?: string
|
||||
/** The content to display */
|
||||
content: string
|
||||
/** Metadata about truncation (for files) */
|
||||
metadata?: {
|
||||
totalLines: number
|
||||
returnedLines: number
|
||||
wasTruncated: boolean
|
||||
linesShown?: [number, number]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParseMentionsResult {
|
||||
/** User's text with @ mentions replaced by clean path references */
|
||||
text: string
|
||||
/** Separate content blocks for each mention (file content, URLs, etc.) */
|
||||
contentBlocks: MentionContentBlock[]
|
||||
slashCommandHelp?: string
|
||||
mode?: string // Mode from the first slash command that has one
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats file content to look like a read_file tool result.
|
||||
* Includes Gemini-style truncation warning when content is truncated.
|
||||
*/
|
||||
function formatFileReadResult(filePath: string, result: ExtractTextResult): string {
|
||||
const header = `[read_file for '${filePath}']`
|
||||
|
||||
if (result.wasTruncated && result.linesShown) {
|
||||
const [start, end] = result.linesShown
|
||||
const nextOffset = end + 1
|
||||
return `${header}
|
||||
IMPORTANT: File content truncated.
|
||||
Status: Showing lines ${start}-${end} of ${result.totalLines} total lines.
|
||||
To read more: Use the read_file tool with offset=${nextOffset} and limit=${DEFAULT_LINE_LIMIT}.
|
||||
|
||||
File: ${filePath}
|
||||
${result.content}`
|
||||
}
|
||||
|
||||
return `${header}
|
||||
File: ${filePath}
|
||||
${result.content}`
|
||||
}
|
||||
|
||||
export async function parseMentions(
|
||||
text: string,
|
||||
cwd: string,
|
||||
|
|
@ -86,10 +134,10 @@ export async function parseMentions(
|
|||
showRooIgnoredFiles: boolean = false,
|
||||
includeDiagnosticMessages: boolean = true,
|
||||
maxDiagnosticMessages: number = 50,
|
||||
maxReadFileLine?: number,
|
||||
): Promise<ParseMentionsResult> {
|
||||
const mentions: Set<string> = new Set()
|
||||
const validCommands: Map<string, Command> = new Map()
|
||||
const contentBlocks: MentionContentBlock[] = []
|
||||
let commandMode: string | undefined // Track mode from the first slash command that has one
|
||||
|
||||
// First pass: check which command mentions exist and cache the results
|
||||
|
|
@ -119,7 +167,7 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
// Only replace text for commands that actually exist
|
||||
// Only replace text for commands that actually exist (keep "see below" for commands)
|
||||
let parsedText = text
|
||||
for (const [match, commandName] of commandMatches) {
|
||||
if (validCommands.has(commandName)) {
|
||||
|
|
@ -127,16 +175,17 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
// Second pass: handle regular mentions
|
||||
// Second pass: handle regular mentions - replace with clean references
|
||||
// Content will be provided as separate blocks that look like read_file results
|
||||
parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => {
|
||||
mentions.add(mention)
|
||||
if (mention.startsWith("http")) {
|
||||
// Keep old style for URLs (still XML-based)
|
||||
return `'${mention}' (see below for site content)`
|
||||
} else if (mention.startsWith("/")) {
|
||||
// Clean path reference - no "see below" since we format like tool results
|
||||
const mentionPath = mention.slice(1)
|
||||
return mentionPath.endsWith("/")
|
||||
? `'${mentionPath}' (see below for folder content)`
|
||||
: `'${mentionPath}' (see below for file content)`
|
||||
return mentionPath.endsWith("/") ? `'${mentionPath}'` : `'${mentionPath}'`
|
||||
} else if (mention === "problems") {
|
||||
return `Workspace Problems (see below for diagnostics)`
|
||||
} else if (mention === "git-changes") {
|
||||
|
|
@ -189,31 +238,26 @@ export async function parseMentions(
|
|||
result = `Error fetching content: ${rawErrorMessage}`
|
||||
}
|
||||
}
|
||||
// URLs still use XML format (appended to text for backwards compat)
|
||||
parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`
|
||||
} else if (mention.startsWith("/")) {
|
||||
const mentionPath = mention.slice(1)
|
||||
try {
|
||||
const content = await getFileOrFolderContent(
|
||||
const fileResult = await getFileOrFolderContentWithMetadata(
|
||||
mentionPath,
|
||||
cwd,
|
||||
rooIgnoreController,
|
||||
showRooIgnoredFiles,
|
||||
maxReadFileLine,
|
||||
fileContextTracker,
|
||||
)
|
||||
if (mention.endsWith("/")) {
|
||||
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
|
||||
} else {
|
||||
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
|
||||
if (fileContextTracker) {
|
||||
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
|
||||
}
|
||||
}
|
||||
contentBlocks.push(fileResult)
|
||||
} catch (error) {
|
||||
if (mention.endsWith("/")) {
|
||||
parsedText += `\n\n<folder_content path="${mentionPath}">\nError fetching content: ${error.message}\n</folder_content>`
|
||||
} else {
|
||||
parsedText += `\n\n<file_content path="${mentionPath}">\nError fetching content: ${error.message}\n</file_content>`
|
||||
}
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
contentBlocks.push({
|
||||
type: mention.endsWith("/") ? "folder" : "file",
|
||||
path: mentionPath,
|
||||
content: `[read_file for '${mentionPath}']\nError: ${errorMsg}`,
|
||||
})
|
||||
}
|
||||
} else if (mention === "problems") {
|
||||
try {
|
||||
|
|
@ -269,18 +313,28 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
return { text: parsedText, mode: commandMode, slashCommandHelp: slashCommandHelp.trim() || undefined }
|
||||
return {
|
||||
text: parsedText,
|
||||
contentBlocks,
|
||||
mode: commandMode,
|
||||
slashCommandHelp: slashCommandHelp.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFileOrFolderContent(
|
||||
/**
|
||||
* Gets file or folder content and returns it as a MentionContentBlock
|
||||
* formatted to look like a read_file tool result.
|
||||
*/
|
||||
async function getFileOrFolderContentWithMetadata(
|
||||
mentionPath: string,
|
||||
cwd: string,
|
||||
rooIgnoreController?: any,
|
||||
showRooIgnoredFiles: boolean = false,
|
||||
maxReadFileLine?: number,
|
||||
): Promise<string> {
|
||||
fileContextTracker?: FileContextTracker,
|
||||
): Promise<MentionContentBlock> {
|
||||
const unescapedPath = unescapeSpaces(mentionPath)
|
||||
const absPath = path.resolve(cwd, unescapedPath)
|
||||
const isFolder = mentionPath.endsWith("/")
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(absPath)
|
||||
|
|
@ -290,21 +344,50 @@ async function getFileOrFolderContent(
|
|||
// Image mentions are handled separately via image attachment flow.
|
||||
const isBinary = await isBinaryFile(absPath).catch(() => false)
|
||||
if (isBinary) {
|
||||
return `(Binary file ${mentionPath} omitted)`
|
||||
return {
|
||||
type: "file",
|
||||
path: mentionPath,
|
||||
content: `[read_file for '${mentionPath}']\nNote: Binary file omitted from context.`,
|
||||
}
|
||||
}
|
||||
if (rooIgnoreController && !rooIgnoreController.validateAccess(unescapedPath)) {
|
||||
return `(File ${mentionPath} is ignored by .rooignore)`
|
||||
return {
|
||||
type: "file",
|
||||
path: mentionPath,
|
||||
content: `[read_file for '${mentionPath}']\nNote: File is ignored by .rooignore.`,
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await extractTextFromFile(absPath, maxReadFileLine)
|
||||
return content
|
||||
const result = await extractTextFromFileWithMetadata(absPath)
|
||||
|
||||
// Track file context
|
||||
if (fileContextTracker) {
|
||||
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
|
||||
}
|
||||
|
||||
return {
|
||||
type: "file",
|
||||
path: mentionPath,
|
||||
content: formatFileReadResult(mentionPath, result),
|
||||
metadata: {
|
||||
totalLines: result.totalLines,
|
||||
returnedLines: result.returnedLines,
|
||||
wasTruncated: result.wasTruncated,
|
||||
linesShown: result.linesShown,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return `(Failed to read contents of ${mentionPath}): ${error.message}`
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
type: "file",
|
||||
path: mentionPath,
|
||||
content: `[read_file for '${mentionPath}']\nError: ${errorMsg}`,
|
||||
}
|
||||
}
|
||||
} else if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(absPath, { withFileTypes: true })
|
||||
let folderContent = ""
|
||||
const fileContentPromises: Promise<string | undefined>[] = []
|
||||
let folderListing = ""
|
||||
const fileReadResults: string[] = []
|
||||
const LOCK_SYMBOL = "🔒"
|
||||
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
|
|
@ -325,38 +408,48 @@ async function getFileOrFolderContent(
|
|||
const displayName = isIgnored ? `${LOCK_SYMBOL} ${entry.name}` : entry.name
|
||||
|
||||
if (entry.isFile()) {
|
||||
folderContent += `${linePrefix}${displayName}\n`
|
||||
folderListing += `${linePrefix}${displayName}\n`
|
||||
if (!isIgnored) {
|
||||
const filePath = path.join(mentionPath, entry.name)
|
||||
const absoluteFilePath = path.resolve(absPath, entry.name)
|
||||
fileContentPromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false)
|
||||
if (isBinary) {
|
||||
return undefined
|
||||
}
|
||||
const content = await extractTextFromFile(absoluteFilePath, maxReadFileLine)
|
||||
return `<file_content path="${filePath.toPosix()}">\n${content}\n</file_content>`
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
)
|
||||
try {
|
||||
const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
const result = await extractTextFromFileWithMetadata(absoluteFilePath)
|
||||
fileReadResults.push(formatFileReadResult(filePath.toPosix(), result))
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
folderContent += `${linePrefix}${displayName}/\n`
|
||||
folderListing += `${linePrefix}${displayName}/\n`
|
||||
} else {
|
||||
folderContent += `${linePrefix}${displayName}\n`
|
||||
folderListing += `${linePrefix}${displayName}\n`
|
||||
}
|
||||
}
|
||||
const fileContents = (await Promise.all(fileContentPromises)).filter((content) => content)
|
||||
return `${folderContent}\n${fileContents.join("\n\n")}`.trim()
|
||||
|
||||
// Format folder content similar to read_file output
|
||||
let content = `[read_file for folder '${mentionPath}']\nFolder listing:\n${folderListing}`
|
||||
if (fileReadResults.length > 0) {
|
||||
content += `\n\n--- File Contents ---\n\n${fileReadResults.join("\n\n")}`
|
||||
}
|
||||
|
||||
return {
|
||||
type: "folder",
|
||||
path: mentionPath,
|
||||
content,
|
||||
}
|
||||
} else {
|
||||
return `(Failed to read contents of ${mentionPath})`
|
||||
return {
|
||||
type: isFolder ? "folder" : "file",
|
||||
path: mentionPath,
|
||||
content: `[read_file for '${mentionPath}']\nError: Unable to read (not a file or directory)`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to access path "${mentionPath}": ${error.message}`)
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to access path "${mentionPath}": ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { parseMentions, ParseMentionsResult } from "./index"
|
||||
import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index"
|
||||
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
|
||||
|
|
@ -9,7 +9,23 @@ export interface ProcessUserContentMentionsResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Process mentions in user content, specifically within task and feedback tags
|
||||
* Converts MentionContentBlocks to Anthropic text blocks.
|
||||
* Each file/folder mention becomes a separate text block formatted
|
||||
* to look like a read_file tool result.
|
||||
*/
|
||||
function contentBlocksToAnthropicBlocks(contentBlocks: MentionContentBlock[]): Anthropic.Messages.TextBlockParam[] {
|
||||
return contentBlocks.map((block) => ({
|
||||
type: "text" as const,
|
||||
text: block.content,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Process mentions in user content, specifically within task and feedback tags.
|
||||
*
|
||||
* File/folder @ mentions are now returned as separate text blocks that
|
||||
* look like read_file tool results, making it clear to the model that
|
||||
* the file has already been read.
|
||||
*/
|
||||
export async function processUserContentMentions({
|
||||
userContent,
|
||||
|
|
@ -20,7 +36,6 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles = false,
|
||||
includeDiagnosticMessages = true,
|
||||
maxDiagnosticMessages = 50,
|
||||
maxReadFileLine,
|
||||
}: {
|
||||
userContent: Anthropic.Messages.ContentBlockParam[]
|
||||
cwd: string
|
||||
|
|
@ -30,7 +45,6 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles?: boolean
|
||||
includeDiagnosticMessages?: boolean
|
||||
maxDiagnosticMessages?: number
|
||||
maxReadFileLine?: number
|
||||
}): Promise<ProcessUserContentMentionsResult> {
|
||||
// Track the first mode found from slash commands
|
||||
let commandMode: string | undefined
|
||||
|
|
@ -58,18 +72,28 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
commandMode = result.mode
|
||||
}
|
||||
|
||||
// Build the blocks array:
|
||||
// 1. User's text (with @ mentions replaced by clean paths)
|
||||
// 2. File/folder content blocks (formatted like read_file results)
|
||||
// 3. Slash command help (if any)
|
||||
const blocks: Anthropic.Messages.ContentBlockParam[] = [
|
||||
{
|
||||
...block,
|
||||
text: result.text,
|
||||
},
|
||||
]
|
||||
|
||||
// Add file/folder content as separate blocks
|
||||
if (result.contentBlocks.length > 0) {
|
||||
blocks.push(...contentBlocksToAnthropicBlocks(result.contentBlocks))
|
||||
}
|
||||
|
||||
if (result.slashCommandHelp) {
|
||||
blocks.push({
|
||||
type: "text" as const,
|
||||
|
|
@ -92,30 +116,38 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
commandMode = result.mode
|
||||
}
|
||||
if (result.slashCommandHelp) {
|
||||
return {
|
||||
...block,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: result.text,
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
text: result.slashCommandHelp,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Build content array with file blocks included
|
||||
const contentParts: Array<{ type: "text"; text: string }> = [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: result.text,
|
||||
},
|
||||
]
|
||||
|
||||
// Add file/folder content blocks
|
||||
for (const contentBlock of result.contentBlocks) {
|
||||
contentParts.push({
|
||||
type: "text" as const,
|
||||
text: contentBlock.content,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.slashCommandHelp) {
|
||||
contentParts.push({
|
||||
type: "text" as const,
|
||||
text: result.slashCommandHelp,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
content: result.text,
|
||||
content: contentParts,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,18 +166,28 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
commandMode = result.mode
|
||||
}
|
||||
const blocks = [
|
||||
|
||||
// Build blocks array with file content
|
||||
const blocks: Array<{ type: "text"; text: string }> = [
|
||||
{
|
||||
...contentBlock,
|
||||
text: result.text,
|
||||
},
|
||||
]
|
||||
|
||||
// Add file/folder content blocks
|
||||
for (const cb of result.contentBlocks) {
|
||||
blocks.push({
|
||||
type: "text" as const,
|
||||
text: cb.content,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.slashCommandHelp) {
|
||||
blocks.push({
|
||||
type: "text" as const,
|
||||
|
|
|
|||
|
|
@ -1,127 +0,0 @@
|
|||
You are Roo, an experienced technical leader who is inquisitive and an excellent planner. Your goal is to gather information and get context to create a detailed plan for accomplishing the user's task, which the user will review and approve before they switch into another mode to implement the solution.
|
||||
|
||||
====
|
||||
|
||||
MARKDOWN RULES
|
||||
|
||||
ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion
|
||||
|
||||
====
|
||||
|
||||
TOOL USE
|
||||
|
||||
You have access to a set of tools that are executed upon the user's approval. Use the provider-native tool-calling mechanism. Do not include XML markup or examples. You must call at least one tool per assistant response. Prefer calling as many tools as are reasonably needed in a single response to reduce back-and-forth and complete tasks faster.
|
||||
|
||||
# Tool Use Guidelines
|
||||
|
||||
1. Assess what information you already have and what information you need to proceed with the task.
|
||||
2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task.
|
||||
3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result.
|
||||
|
||||
By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
|
||||
|
||||
====
|
||||
|
||||
CAPABILITIES
|
||||
|
||||
- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more.
|
||||
- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.
|
||||
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
|
||||
|
||||
====
|
||||
|
||||
MODES
|
||||
|
||||
- Test modes section
|
||||
|
||||
====
|
||||
|
||||
RULES
|
||||
|
||||
- The project base directory is: /test/path
|
||||
- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command.
|
||||
- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path.
|
||||
- Do not use the ~ character or $HOME to refer to the home directory.
|
||||
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`.
|
||||
- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode.
|
||||
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
|
||||
* For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$"
|
||||
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
|
||||
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
|
||||
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
|
||||
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you.
|
||||
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
|
||||
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.
|
||||
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
|
||||
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages.
|
||||
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
|
||||
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
|
||||
- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal.
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.
|
||||
|
||||
====
|
||||
|
||||
SYSTEM INFORMATION
|
||||
|
||||
Operating System: Linux
|
||||
Default Shell: /bin/zsh
|
||||
Home Directory: /home/user
|
||||
Current Workspace Directory: /test/path
|
||||
|
||||
The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.
|
||||
|
||||
====
|
||||
|
||||
OBJECTIVE
|
||||
|
||||
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
|
||||
|
||||
1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.
|
||||
2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.
|
||||
3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided.
|
||||
4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user.
|
||||
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.
|
||||
|
||||
|
||||
====
|
||||
|
||||
USER'S CUSTOM INSTRUCTIONS
|
||||
|
||||
The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines.
|
||||
|
||||
Language Preference:
|
||||
You should always speak and think in the "en" language.
|
||||
|
||||
Mode-specific Instructions:
|
||||
1. Do some information gathering (using provided tools) to get more context about the task.
|
||||
|
||||
2. You should also ask the user clarifying questions to get a better understanding of the task.
|
||||
|
||||
3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and create a todo list using the `update_todo_list` tool. Each todo item should be:
|
||||
- Specific and actionable
|
||||
- Listed in logical execution order
|
||||
- Focused on a single, well-defined outcome
|
||||
- Clear enough that another mode could execute it independently
|
||||
|
||||
**Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.
|
||||
|
||||
4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.
|
||||
|
||||
5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.
|
||||
|
||||
6. Include Mermaid diagrams if they help clarify complex workflows or system architecture. Please avoid using double quotes ("") and parentheses () inside square brackets ([]) in Mermaid diagrams, as this can cause parsing errors.
|
||||
|
||||
7. Use the switch_mode tool to request that the user switch to another mode to implement the solution.
|
||||
|
||||
**IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents. Use the todo list as your primary planning tool to track and organize the work that needs to be done.**
|
||||
|
||||
**CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.**
|
||||
|
||||
Unless told otherwise, if you want to save a plan file, put it in the /plans directory
|
||||
|
||||
Rules:
|
||||
# Rules from .clinerules-architect:
|
||||
Mock mode-specific rules
|
||||
# Rules from .clinerules:
|
||||
Mock generic rules
|
||||
|
|
@ -264,27 +264,6 @@ describe("addCustomInstructions", () => {
|
|||
expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap")
|
||||
})
|
||||
|
||||
it("should include partial read instructions when partialReadsEnabled is true", async () => {
|
||||
const prompt = await SYSTEM_PROMPT(
|
||||
mockContext,
|
||||
"/test/path",
|
||||
false, // supportsImages
|
||||
undefined, // mcpHub
|
||||
undefined, // diffStrategy
|
||||
undefined, // browserViewportSize
|
||||
defaultModeSlug, // mode
|
||||
undefined, // customModePrompts
|
||||
undefined, // customModes,
|
||||
undefined, // globalCustomInstructions
|
||||
undefined, // experiments
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
true, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/partial-reads-enabled.snap")
|
||||
})
|
||||
|
||||
it("should prioritize mode-specific rules for code mode", async () => {
|
||||
const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug)
|
||||
expect(instructions).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/code-mode-rules.snap")
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ describe("getRulesSection", () => {
|
|||
|
||||
it("includes vendor confidentiality section when isStealthModel is true", () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -88,7 +87,6 @@ describe("getRulesSection", () => {
|
|||
|
||||
it("excludes vendor confidentiality section when isStealthModel is false", () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -103,7 +101,6 @@ describe("getRulesSection", () => {
|
|||
|
||||
it("excludes vendor confidentiality section when isStealthModel is undefined", () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
|
|||
|
|
@ -228,7 +228,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/consistent-system-prompt.snap")
|
||||
|
|
@ -249,7 +248,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-computer-use-support.snap")
|
||||
|
|
@ -272,7 +270,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-mcp-hub-provided.snap")
|
||||
|
|
@ -293,7 +290,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-undefined-mcp-hub.snap")
|
||||
|
|
@ -314,7 +310,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-different-viewport-size.snap")
|
||||
|
|
@ -362,7 +357,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
undefined, // experiments
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
expect(prompt).toContain("Language Preference:")
|
||||
|
|
@ -421,7 +415,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
// Role definition should be at the top
|
||||
|
|
@ -457,7 +450,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
undefined, // experiments
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
// Role definition from promptComponent should be at the top
|
||||
|
|
@ -488,7 +480,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
undefined, // experiments
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
)
|
||||
|
||||
// Should use the default mode's role definition
|
||||
|
|
@ -497,7 +488,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
|
||||
it("should exclude update_todo_list tool when todoListEnabled is false", async () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: false,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -517,7 +507,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
settings, // settings
|
||||
)
|
||||
|
||||
|
|
@ -528,7 +517,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
|
||||
it("should include update_todo_list tool when todoListEnabled is true", async () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -548,7 +536,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
settings, // settings
|
||||
)
|
||||
|
||||
|
|
@ -559,7 +546,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
|
||||
it("should include update_todo_list tool when todoListEnabled is undefined", async () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -579,7 +565,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
settings, // settings
|
||||
)
|
||||
|
||||
|
|
@ -590,7 +575,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
|
||||
it("should include native tool instructions", async () => {
|
||||
const settings = {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -610,7 +594,6 @@ describe("SYSTEM_PROMPT", () => {
|
|||
experiments,
|
||||
undefined, // language
|
||||
undefined, // rooIgnoreInstructions
|
||||
undefined, // partialReadsEnabled
|
||||
settings, // settings
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -543,7 +543,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -575,7 +574,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: false,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -636,7 +634,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -682,7 +679,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -750,7 +746,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -802,7 +797,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -856,7 +850,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
@ -902,7 +895,6 @@ describe("addCustomInstructions", () => {
|
|||
"test-mode",
|
||||
{
|
||||
settings: {
|
||||
maxConcurrentFileReads: 5,
|
||||
todoListEnabled: true,
|
||||
useAgentRules: true,
|
||||
newTaskRequireTodos: false,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ async function generatePrompt(
|
|||
experiments?: Record<string, boolean>,
|
||||
language?: string,
|
||||
rooIgnoreInstructions?: string,
|
||||
partialReadsEnabled?: boolean,
|
||||
settings?: SystemPromptSettings,
|
||||
todoList?: TodoItem[],
|
||||
modelId?: string,
|
||||
|
|
@ -128,7 +127,6 @@ export const SYSTEM_PROMPT = async (
|
|||
experiments?: Record<string, boolean>,
|
||||
language?: string,
|
||||
rooIgnoreInstructions?: string,
|
||||
partialReadsEnabled?: boolean,
|
||||
settings?: SystemPromptSettings,
|
||||
todoList?: TodoItem[],
|
||||
modelId?: string,
|
||||
|
|
@ -196,7 +194,6 @@ ${customInstructions}`
|
|||
experiments,
|
||||
language,
|
||||
rooIgnoreInstructions,
|
||||
partialReadsEnabled,
|
||||
settings,
|
||||
todoList,
|
||||
modelId,
|
||||
|
|
|
|||
|
|
@ -80,27 +80,27 @@ describe("converters", () => {
|
|||
const openAITool: OpenAI.Chat.ChatCompletionTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read files",
|
||||
name: "process_data",
|
||||
description: "Process data with filters",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
name: { type: "string" },
|
||||
tags: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "string", pattern: "^[0-9]+-[0-9]+$" },
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["path", "line_ranges"],
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["files"],
|
||||
required: ["items"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type OpenAI from "openai"
|
||||
import { createReadFileTool, type ReadFileToolOptions } from "../read_file"
|
||||
import { createReadFileTool } from "../read_file"
|
||||
|
||||
// Helper type to access function tools
|
||||
type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" }
|
||||
|
|
@ -8,91 +8,46 @@ type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" }
|
|||
const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function
|
||||
|
||||
describe("createReadFileTool", () => {
|
||||
describe("maxConcurrentFileReads documentation", () => {
|
||||
it("should include default maxConcurrentFileReads limit (5) in description", () => {
|
||||
describe("single-file-per-call documentation", () => {
|
||||
it("should indicate single-file-per-call and suggest parallel tool calls", () => {
|
||||
const tool = createReadFileTool()
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("maximum of 5 files")
|
||||
expect(description).toContain("If you need to read more files, use multiple sequential read_file requests")
|
||||
})
|
||||
|
||||
it("should include custom maxConcurrentFileReads limit in description", () => {
|
||||
const tool = createReadFileTool({ maxConcurrentFileReads: 3 })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("maximum of 3 files")
|
||||
expect(description).toContain("within 3-file limit")
|
||||
})
|
||||
|
||||
it("should indicate single file reads only when maxConcurrentFileReads is 1", () => {
|
||||
const tool = createReadFileTool({ maxConcurrentFileReads: 1 })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("Multiple file reads are currently disabled")
|
||||
expect(description).toContain("only read one file at a time")
|
||||
expect(description).not.toContain("Example multiple files")
|
||||
})
|
||||
|
||||
it("should use singular 'Read a file' in base description when maxConcurrentFileReads is 1", () => {
|
||||
const tool = createReadFileTool({ maxConcurrentFileReads: 1 })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toMatch(/^Read a file/)
|
||||
expect(description).not.toContain("Read one or more files")
|
||||
})
|
||||
|
||||
it("should use plural 'Read one or more files' in base description when maxConcurrentFileReads is > 1", () => {
|
||||
const tool = createReadFileTool({ maxConcurrentFileReads: 5 })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toMatch(/^Read one or more files/)
|
||||
})
|
||||
|
||||
it("should not show multiple files example when maxConcurrentFileReads is 1", () => {
|
||||
const tool = createReadFileTool({ maxConcurrentFileReads: 1, partialReadsEnabled: true })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).not.toContain("Example multiple files")
|
||||
})
|
||||
|
||||
it("should show multiple files example when maxConcurrentFileReads is > 1", () => {
|
||||
const tool = createReadFileTool({ maxConcurrentFileReads: 5, partialReadsEnabled: true })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("Example multiple files")
|
||||
expect(description).toContain("exactly one file per call")
|
||||
expect(description).toContain("multiple parallel read_file calls")
|
||||
})
|
||||
})
|
||||
|
||||
describe("partialReadsEnabled option", () => {
|
||||
it("should include line_ranges in description when partialReadsEnabled is true", () => {
|
||||
const tool = createReadFileTool({ partialReadsEnabled: true })
|
||||
describe("indentation mode", () => {
|
||||
it("should always include indentation mode in description", () => {
|
||||
const tool = createReadFileTool()
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("line_ranges")
|
||||
expect(description).toContain("Example with line ranges")
|
||||
expect(description).toContain("indentation")
|
||||
})
|
||||
|
||||
it("should not include line_ranges in description when partialReadsEnabled is false", () => {
|
||||
const tool = createReadFileTool({ partialReadsEnabled: false })
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).not.toContain("line_ranges")
|
||||
expect(description).not.toContain("Example with line ranges")
|
||||
})
|
||||
|
||||
it("should include line_ranges parameter in schema when partialReadsEnabled is true", () => {
|
||||
const tool = createReadFileTool({ partialReadsEnabled: true })
|
||||
it("should always include indentation parameter in schema", () => {
|
||||
const tool = createReadFileTool()
|
||||
const schema = getFunctionDef(tool).parameters as any
|
||||
|
||||
expect(schema.properties.files.items.properties).toHaveProperty("line_ranges")
|
||||
expect(schema.properties).toHaveProperty("indentation")
|
||||
})
|
||||
|
||||
it("should not include line_ranges parameter in schema when partialReadsEnabled is false", () => {
|
||||
const tool = createReadFileTool({ partialReadsEnabled: false })
|
||||
it("should include mode parameter in schema", () => {
|
||||
const tool = createReadFileTool()
|
||||
const schema = getFunctionDef(tool).parameters as any
|
||||
|
||||
expect(schema.properties.files.items.properties).not.toHaveProperty("line_ranges")
|
||||
expect(schema.properties).toHaveProperty("mode")
|
||||
expect(schema.properties.mode.enum).toContain("slice")
|
||||
expect(schema.properties.mode.enum).toContain("indentation")
|
||||
})
|
||||
|
||||
it("should include offset and limit parameters in schema", () => {
|
||||
const tool = createReadFileTool()
|
||||
const schema = getFunctionDef(tool).parameters as any
|
||||
|
||||
expect(schema.properties).toHaveProperty("offset")
|
||||
expect(schema.properties).toHaveProperty("limit")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -138,75 +93,6 @@ describe("createReadFileTool", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("combined options", () => {
|
||||
it("should correctly combine low maxConcurrentFileReads with partialReadsEnabled", () => {
|
||||
const tool = createReadFileTool({
|
||||
maxConcurrentFileReads: 2,
|
||||
partialReadsEnabled: true,
|
||||
})
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("maximum of 2 files")
|
||||
expect(description).toContain("line_ranges")
|
||||
expect(description).toContain("within 2-file limit")
|
||||
})
|
||||
|
||||
it("should correctly handle maxConcurrentFileReads of 1 with partialReadsEnabled false", () => {
|
||||
const tool = createReadFileTool({
|
||||
maxConcurrentFileReads: 1,
|
||||
partialReadsEnabled: false,
|
||||
})
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("only read one file at a time")
|
||||
expect(description).not.toContain("line_ranges")
|
||||
expect(description).not.toContain("Example multiple files")
|
||||
})
|
||||
|
||||
it("should correctly combine partialReadsEnabled and supportsImages", () => {
|
||||
const tool = createReadFileTool({
|
||||
partialReadsEnabled: true,
|
||||
supportsImages: true,
|
||||
})
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
// Should have both line_ranges and image support
|
||||
expect(description).toContain("line_ranges")
|
||||
expect(description).toContain(
|
||||
"Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis",
|
||||
)
|
||||
})
|
||||
|
||||
it("should work with partialReadsEnabled=false and supportsImages=true", () => {
|
||||
const tool = createReadFileTool({
|
||||
partialReadsEnabled: false,
|
||||
supportsImages: true,
|
||||
})
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
// Should have image support but no line_ranges
|
||||
expect(description).not.toContain("line_ranges")
|
||||
expect(description).toContain(
|
||||
"Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis",
|
||||
)
|
||||
})
|
||||
|
||||
it("should correctly combine all three options", () => {
|
||||
const tool = createReadFileTool({
|
||||
maxConcurrentFileReads: 3,
|
||||
partialReadsEnabled: true,
|
||||
supportsImages: true,
|
||||
})
|
||||
const description = getFunctionDef(tool).description
|
||||
|
||||
expect(description).toContain("maximum of 3 files")
|
||||
expect(description).toContain("line_ranges")
|
||||
expect(description).toContain(
|
||||
"Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool structure", () => {
|
||||
it("should have correct tool name", () => {
|
||||
const tool = createReadFileTool()
|
||||
|
|
@ -226,18 +112,11 @@ describe("createReadFileTool", () => {
|
|||
expect(getFunctionDef(tool).strict).toBe(true)
|
||||
})
|
||||
|
||||
it("should require files parameter", () => {
|
||||
it("should require path parameter", () => {
|
||||
const tool = createReadFileTool()
|
||||
const schema = getFunctionDef(tool).parameters as any
|
||||
|
||||
expect(schema.required).toContain("files")
|
||||
})
|
||||
|
||||
it("should require path in file objects", () => {
|
||||
const tool = createReadFileTool({ partialReadsEnabled: false })
|
||||
const schema = getFunctionDef(tool).parameters as any
|
||||
|
||||
expect(schema.properties.files.items.required).toContain("path")
|
||||
expect(schema.required).toContain("path")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ export type { ReadFileToolOptions } from "./read_file"
|
|||
* Options for customizing the native tools array.
|
||||
*/
|
||||
export interface NativeToolsOptions {
|
||||
/** Whether to include line_ranges support in read_file tool (default: true) */
|
||||
partialReadsEnabled?: boolean
|
||||
/** Maximum number of files that can be read in a single read_file request (default: 5) */
|
||||
maxConcurrentFileReads?: number
|
||||
/** Whether the model supports image processing (default: false) */
|
||||
supportsImages?: boolean
|
||||
}
|
||||
|
|
@ -45,11 +41,9 @@ export interface NativeToolsOptions {
|
|||
* @returns Array of native tool definitions
|
||||
*/
|
||||
export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] {
|
||||
const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options
|
||||
const { supportsImages = false } = options
|
||||
|
||||
const readFileOptions: ReadFileToolOptions = {
|
||||
partialReadsEnabled,
|
||||
maxConcurrentFileReads,
|
||||
supportsImages,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import type OpenAI from "openai"
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Default maximum lines to return per file (Codex-inspired predictable limit) */
|
||||
export const DEFAULT_LINE_LIMIT = 2000
|
||||
|
||||
/** Maximum characters per line before truncation */
|
||||
export const MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Default indentation levels to include above anchor (0 = unlimited) */
|
||||
export const DEFAULT_MAX_LEVELS = 0
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates the file support note, optionally including image format support.
|
||||
*
|
||||
|
|
@ -13,86 +26,117 @@ function getReadFileSupportsNote(supportsImages: boolean): string {
|
|||
return `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.`
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Options for creating the read_file tool definition.
|
||||
*/
|
||||
export interface ReadFileToolOptions {
|
||||
/** Whether to include line_ranges parameter (default: true) */
|
||||
partialReadsEnabled?: boolean
|
||||
/** Maximum number of files that can be read in a single request (default: 5) */
|
||||
maxConcurrentFileReads?: number
|
||||
/** Whether the model supports image processing (default: false) */
|
||||
supportsImages?: boolean
|
||||
}
|
||||
|
||||
// ─── Schema Builder ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates the read_file tool definition, optionally including line_ranges support
|
||||
* based on whether partial reads are enabled.
|
||||
* Creates the read_file tool definition with Codex-inspired modes.
|
||||
*
|
||||
* Two reading modes are supported:
|
||||
*
|
||||
* 1. **Slice Mode** (default): Simple offset/limit reading
|
||||
* - Reads contiguous lines starting from `offset` (1-based, default: 1)
|
||||
* - Limited to `limit` lines (default: 2000)
|
||||
* - Predictable and efficient for agent planning
|
||||
*
|
||||
* 2. **Indentation Mode**: Semantic code block extraction
|
||||
* - Anchored on a specific line number (1-based)
|
||||
* - Extracts the block containing that line plus context
|
||||
* - Respects code structure based on indentation hierarchy
|
||||
* - Useful for extracting functions, classes, or logical blocks
|
||||
*
|
||||
* @param options - Configuration options for the tool
|
||||
* @returns Native tool definition for read_file
|
||||
*/
|
||||
export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool {
|
||||
const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options
|
||||
const isMultipleReadsEnabled = maxConcurrentFileReads > 1
|
||||
const { supportsImages = false } = options
|
||||
|
||||
// Build description intro with concurrent reads limit message
|
||||
const descriptionIntro = isMultipleReadsEnabled
|
||||
? `Read one or more files and return their contents with line numbers for diffing or discussion. IMPORTANT: You can read a maximum of ${maxConcurrentFileReads} files in a single request. If you need to read more files, use multiple sequential read_file requests. `
|
||||
: "Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: Multiple file reads are currently disabled. You can only read one file at a time. "
|
||||
// Build description based on capabilities
|
||||
const descriptionIntro =
|
||||
"Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: This tool reads exactly one file per call. If you need multiple files, issue multiple parallel read_file calls."
|
||||
|
||||
const baseDescription =
|
||||
descriptionIntro +
|
||||
"Structure: { files: [{ path: 'relative/path.ts'" +
|
||||
(partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") +
|
||||
" }] }. " +
|
||||
"The 'path' is required and relative to workspace. "
|
||||
const modeDescription =
|
||||
` Supports two modes: 'slice' (default) reads lines sequentially with offset/limit; 'indentation' extracts complete semantic code blocks around an anchor line based on indentation hierarchy.` +
|
||||
` Slice mode is ideal for initial file exploration, understanding overall structure, reading configuration/data files, or when you need a specific line range. Use it when you don't have a target line number.` +
|
||||
` PREFER indentation mode when you have a specific line number from search results, error messages, or definition lookups - it guarantees complete, syntactically valid code blocks without mid-function truncation.` +
|
||||
` IMPORTANT: Indentation mode requires anchor_line to be useful. Without it, only header content (imports) is returned.`
|
||||
|
||||
const optionalRangesDescription = partialReadsEnabled
|
||||
? "The 'line_ranges' is optional for reading specific sections. Each range is a [start, end] tuple (1-based inclusive). "
|
||||
: ""
|
||||
|
||||
const examples = partialReadsEnabled
|
||||
? "Example single file: { files: [{ path: 'src/app.ts' }] }. " +
|
||||
"Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " +
|
||||
(isMultipleReadsEnabled
|
||||
? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }`
|
||||
: "")
|
||||
: "Example single file: { files: [{ path: 'src/app.ts' }] }. " +
|
||||
(isMultipleReadsEnabled
|
||||
? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }`
|
||||
: "")
|
||||
const limitNote = ` By default, returns up to ${DEFAULT_LINE_LIMIT} lines per file. Lines longer than ${MAX_LINE_LENGTH} characters are truncated.`
|
||||
|
||||
const description =
|
||||
baseDescription + optionalRangesDescription + getReadFileSupportsNote(supportsImages) + " " + examples
|
||||
descriptionIntro +
|
||||
modeDescription +
|
||||
limitNote +
|
||||
" " +
|
||||
getReadFileSupportsNote(supportsImages) +
|
||||
` Example: { path: 'src/app.ts' }` +
|
||||
` Example (indentation mode): { path: 'src/app.ts', mode: 'indentation', indentation: { anchor_line: 42 } }`
|
||||
|
||||
// Build the properties object conditionally
|
||||
const fileProperties: Record<string, any> = {
|
||||
const indentationProperties: Record<string, unknown> = {
|
||||
anchor_line: {
|
||||
type: "integer",
|
||||
description:
|
||||
"1-based line number to anchor the extraction. REQUIRED for meaningful indentation mode results. The extractor finds the semantic block (function, method, class) containing this line and returns it completely. Without anchor_line, indentation mode defaults to line 1 and returns only imports/header content. Obtain anchor_line from: search results, error stack traces, definition lookups, codebase_search results, or condensed file summaries (e.g., '14--28 | export class UserService' means anchor_line=14).",
|
||||
},
|
||||
max_levels: {
|
||||
type: "integer",
|
||||
description: `Maximum indentation levels to include above the anchor (indentation mode, 0 = unlimited (default)). Higher values include more parent context.`,
|
||||
},
|
||||
include_siblings: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Include sibling blocks at the same indentation level as the anchor block (indentation mode, default: false). Useful for seeing related methods in a class.",
|
||||
},
|
||||
include_header: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Include file header content (imports, module-level comments) at the top of output (indentation mode, default: true).",
|
||||
},
|
||||
max_lines: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Hard cap on lines returned for indentation mode. Acts as a separate limit from the top-level 'limit' parameter.",
|
||||
},
|
||||
}
|
||||
|
||||
const properties: Record<string, unknown> = {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Path to the file to read, relative to the workspace",
|
||||
},
|
||||
}
|
||||
|
||||
// Only include line_ranges if partial reads are enabled
|
||||
if (partialReadsEnabled) {
|
||||
fileProperties.line_ranges = {
|
||||
type: ["array", "null"],
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["slice", "indentation"],
|
||||
description:
|
||||
"Optional line ranges to read. Each range is a [start, end] tuple with 1-based inclusive line numbers. Use multiple ranges for non-contiguous sections.",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
}
|
||||
"Reading mode. 'slice' (default): read lines sequentially with offset/limit - use for general file exploration or when you don't have a target line number (may truncate code mid-function). 'indentation': extract complete semantic code blocks containing anchor_line - PREFERRED when you have a line number because it guarantees complete, valid code blocks. WARNING: Do not use indentation mode without specifying indentation.anchor_line, or you will only get header content.",
|
||||
},
|
||||
offset: {
|
||||
type: "integer",
|
||||
description: "1-based line offset to start reading from (slice mode, default: 1)",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: `Maximum number of lines to return (slice mode, default: ${DEFAULT_LINE_LIMIT})`,
|
||||
},
|
||||
indentation: {
|
||||
type: "object",
|
||||
description:
|
||||
"Indentation mode options. Only used when mode='indentation'. You MUST specify anchor_line for useful results - it determines which code block to extract.",
|
||||
properties: indentationProperties,
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}
|
||||
|
||||
// When using strict mode, ALL properties must be in the required array
|
||||
// Optional properties are handled by having type: ["...", "null"]
|
||||
const fileRequiredProperties = partialReadsEnabled ? ["path", "line_ranges"] : ["path"]
|
||||
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
|
|
@ -101,24 +145,15 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch
|
|||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
description: "List of files to read; request related files together when allowed",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: fileProperties,
|
||||
required: fileRequiredProperties,
|
||||
additionalProperties: false,
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ["files"],
|
||||
properties,
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
} satisfies OpenAI.Chat.ChatCompletionTool
|
||||
}
|
||||
|
||||
export const read_file = createReadFileTool({ partialReadsEnabled: false })
|
||||
/**
|
||||
* Default read_file tool with all parameters
|
||||
*/
|
||||
export const read_file = createReadFileTool()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
* Settings passed to system prompt generation functions
|
||||
*/
|
||||
export interface SystemPromptSettings {
|
||||
maxConcurrentFileReads: number
|
||||
todoListEnabled: boolean
|
||||
browserToolEnabled?: boolean
|
||||
useAgentRules: boolean
|
||||
|
|
|
|||
|
|
@ -1775,8 +1775,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
customModes: state?.customModes,
|
||||
experiments: state?.experiments,
|
||||
apiConfiguration,
|
||||
maxReadFileLine: state?.maxReadFileLine ?? -1,
|
||||
maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5,
|
||||
browserToolEnabled: state?.browserToolEnabled ?? true,
|
||||
modelInfo,
|
||||
includeAllToolsWithRestrictions: false,
|
||||
|
|
@ -2709,7 +2707,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
showRooIgnoredFiles = false,
|
||||
includeDiagnosticMessages = true,
|
||||
maxDiagnosticMessages = 50,
|
||||
maxReadFileLine = -1,
|
||||
} = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
|
||||
const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({
|
||||
|
|
@ -2721,7 +2718,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
})
|
||||
|
||||
// Switch mode if specified in a slash command's frontmatter
|
||||
|
|
@ -3781,8 +3777,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
experiments,
|
||||
browserToolEnabled,
|
||||
language,
|
||||
maxConcurrentFileReads,
|
||||
maxReadFileLine,
|
||||
apiConfiguration,
|
||||
enableSubfolderRules,
|
||||
} = state ?? {}
|
||||
|
|
@ -3819,9 +3813,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
experiments,
|
||||
language,
|
||||
rooIgnoreInstructions,
|
||||
maxReadFileLine !== -1,
|
||||
{
|
||||
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
|
||||
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
|
||||
browserToolEnabled: browserToolEnabled ?? true,
|
||||
useAgentRules:
|
||||
|
|
@ -3884,8 +3876,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
customModes: state?.customModes,
|
||||
experiments: state?.experiments,
|
||||
apiConfiguration,
|
||||
maxReadFileLine: state?.maxReadFileLine ?? -1,
|
||||
maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5,
|
||||
browserToolEnabled: state?.browserToolEnabled ?? true,
|
||||
modelInfo,
|
||||
includeAllToolsWithRestrictions: false,
|
||||
|
|
@ -4100,8 +4090,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
customModes: state?.customModes,
|
||||
experiments: state?.experiments,
|
||||
apiConfiguration,
|
||||
maxReadFileLine: state?.maxReadFileLine ?? -1,
|
||||
maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5,
|
||||
browserToolEnabled: state?.browserToolEnabled ?? true,
|
||||
modelInfo,
|
||||
includeAllToolsWithRestrictions: false,
|
||||
|
|
@ -4266,8 +4254,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
customModes: state?.customModes,
|
||||
experiments: state?.experiments,
|
||||
apiConfiguration,
|
||||
maxReadFileLine: state?.maxReadFileLine ?? -1,
|
||||
maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5,
|
||||
browserToolEnabled: state?.browserToolEnabled ?? true,
|
||||
modelInfo,
|
||||
includeAllToolsWithRestrictions: supportsAllowedFunctionNames,
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ vi.mock("vscode", () => {
|
|||
|
||||
vi.mock("../../mentions", () => ({
|
||||
parseMentions: vi.fn().mockImplementation((text) => {
|
||||
return Promise.resolve({ text: `processed: ${text}`, mode: undefined })
|
||||
return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] })
|
||||
}),
|
||||
openMention: vi.fn(),
|
||||
getLatestTerminalOutput: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ vi.mock("vscode", () => {
|
|||
|
||||
vi.mock("../../mentions", () => ({
|
||||
parseMentions: vi.fn().mockImplementation((text) => {
|
||||
return Promise.resolve(`processed: ${text}`)
|
||||
return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] })
|
||||
}),
|
||||
openMention: vi.fn(),
|
||||
getLatestTerminalOutput: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ vi.mock("vscode", () => {
|
|||
|
||||
vi.mock("../../mentions", () => ({
|
||||
parseMentions: vi.fn().mockImplementation((text) => {
|
||||
return Promise.resolve(`processed: ${text}`)
|
||||
return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] })
|
||||
}),
|
||||
openMention: vi.fn(),
|
||||
getLatestTerminalOutput: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ vi.mock("fs/promises", () => ({
|
|||
|
||||
// Mock mentions
|
||||
vi.mock("../../mentions", () => ({
|
||||
parseMentions: vi.fn().mockImplementation((text) => Promise.resolve(text)),
|
||||
parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })),
|
||||
openMention: vi.fn(),
|
||||
getLatestTerminalOutput: vi.fn(),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ vi.mock("fs/promises", () => ({
|
|||
|
||||
// Mock mentions
|
||||
vi.mock("../../mentions", () => ({
|
||||
parseMentions: vi.fn().mockImplementation((text) => Promise.resolve(text)),
|
||||
parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })),
|
||||
openMention: vi.fn(),
|
||||
getLatestTerminalOutput: vi.fn(),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ interface BuildToolsOptions {
|
|||
customModes: ModeConfig[] | undefined
|
||||
experiments: Record<string, boolean> | undefined
|
||||
apiConfiguration: ProviderSettings | undefined
|
||||
maxReadFileLine: number
|
||||
maxConcurrentFileReads: number
|
||||
browserToolEnabled: boolean
|
||||
modelInfo?: ModelInfo
|
||||
/**
|
||||
|
|
@ -89,8 +87,6 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
|
|||
customModes,
|
||||
experiments,
|
||||
apiConfiguration,
|
||||
maxReadFileLine,
|
||||
maxConcurrentFileReads,
|
||||
browserToolEnabled,
|
||||
modelInfo,
|
||||
includeAllToolsWithRestrictions,
|
||||
|
|
@ -109,16 +105,11 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
|
|||
modelInfo,
|
||||
}
|
||||
|
||||
// Determine if partial reads are enabled based on maxReadFileLine setting.
|
||||
const partialReadsEnabled = maxReadFileLine !== -1
|
||||
|
||||
// Check if the model supports images for read_file tool description.
|
||||
const supportsImages = modelInfo?.supportsImages ?? false
|
||||
|
||||
// Build native tools with dynamic read_file tool based on settings.
|
||||
const nativeTools = getNativeTools({
|
||||
partialReadsEnabled,
|
||||
maxConcurrentFileReads,
|
||||
supportsImages,
|
||||
})
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -575,7 +575,7 @@ describe("ToolRepetitionDetector", () => {
|
|||
params: {}, // Empty for native protocol
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
files: [{ path: "file1.ts" }],
|
||||
path: "file1.ts",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -585,7 +585,7 @@ describe("ToolRepetitionDetector", () => {
|
|||
params: {}, // Empty for native protocol
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
files: [{ path: "file2.ts" }],
|
||||
path: "file2.ts",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -609,7 +609,7 @@ describe("ToolRepetitionDetector", () => {
|
|||
params: {}, // Empty for native protocol
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
files: [{ path: "same-file.ts" }],
|
||||
path: "same-file.ts",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -625,7 +625,7 @@ describe("ToolRepetitionDetector", () => {
|
|||
expect(result.askUser).toBeDefined()
|
||||
})
|
||||
|
||||
it("should differentiate read_file calls with multiple files in different orders", () => {
|
||||
it("should treat different slice offsets as distinct read_file calls", () => {
|
||||
const detector = new ToolRepetitionDetector(2)
|
||||
|
||||
const readFile1: ToolUse = {
|
||||
|
|
@ -634,7 +634,9 @@ describe("ToolRepetitionDetector", () => {
|
|||
params: {},
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
files: [{ path: "a.ts" }, { path: "b.ts" }],
|
||||
path: "a.ts",
|
||||
offset: 1,
|
||||
limit: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -644,11 +646,13 @@ describe("ToolRepetitionDetector", () => {
|
|||
params: {},
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
files: [{ path: "b.ts" }, { path: "a.ts" }],
|
||||
path: "a.ts",
|
||||
offset: 2001,
|
||||
limit: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
// Different order should be treated as different calls
|
||||
// Different offsets should be treated as different calls
|
||||
expect(detector.check(readFile1).allowExecution).toBe(true)
|
||||
expect(detector.check(readFile2).allowExecution).toBe(true)
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,160 +0,0 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { truncateDefinitionsToLineLimit } from "../truncateDefinitions"
|
||||
|
||||
describe("truncateDefinitionsToLineLimit", () => {
|
||||
it("should not truncate when maxReadFileLine is -1 (no limit)", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, -1)
|
||||
expect(result).toBe(definitions)
|
||||
})
|
||||
|
||||
it("should not truncate when maxReadFileLine is 0 (definitions only mode)", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 0)
|
||||
expect(result).toBe(definitions)
|
||||
})
|
||||
|
||||
it("should truncate definitions beyond the line limit", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should include definitions that start within limit even if they end beyond it", () => {
|
||||
const definitions = `# test.ts
|
||||
10--50 | function foo() {
|
||||
60--80 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 30)
|
||||
const expected = `# test.ts
|
||||
10--50 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle single-line definitions", () => {
|
||||
const definitions = `# test.ts
|
||||
10 | const foo = 1
|
||||
20 | const bar = 2
|
||||
30 | const baz = 3`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10 | const foo = 1
|
||||
20 | const bar = 2`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should preserve header line when all definitions are beyond limit", () => {
|
||||
const definitions = `# test.ts
|
||||
100--200 | function foo() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 50)
|
||||
const expected = `# test.ts`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle empty definitions", () => {
|
||||
const definitions = `# test.ts`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 50)
|
||||
expect(result).toBe(definitions)
|
||||
})
|
||||
|
||||
it("should handle definitions without header", () => {
|
||||
const definitions = `10--20 | function foo() {
|
||||
30--40 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should not preserve empty lines (only definition lines)", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
|
||||
30--40 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle mixed single and range definitions", () => {
|
||||
const definitions = `# test.ts
|
||||
5 | const x = 1
|
||||
10--20 | function foo() {
|
||||
25 | const y = 2
|
||||
30--40 | function bar() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 26)
|
||||
const expected = `# test.ts
|
||||
5 | const x = 1
|
||||
10--20 | function foo() {
|
||||
25 | const y = 2`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle definitions at exactly the limit", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 30)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle definitions with leading whitespace", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 25)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
it("should handle definitions with mixed whitespace patterns", () => {
|
||||
const definitions = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {
|
||||
50--60 | function baz() {`
|
||||
|
||||
const result = truncateDefinitionsToLineLimit(definitions, 35)
|
||||
const expected = `# test.ts
|
||||
10--20 | function foo() {
|
||||
30--40 | function bar() {`
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
// Re-export the new incremental token-based file reader
|
||||
export { readFileWithTokenBudget } from "../../../integrations/misc/read-file-with-budget"
|
||||
export type { ReadWithBudgetResult, ReadWithBudgetOptions } from "../../../integrations/misc/read-file-with-budget"
|
||||
|
||||
/**
|
||||
* Percentage of available context to reserve for file reading.
|
||||
* The remaining percentage is reserved for the model's response and overhead.
|
||||
*/
|
||||
export const FILE_READ_BUDGET_PERCENT = 0.6 // 60% for file, 40% for response
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Truncate code definitions to only include those within the line limit
|
||||
* @param definitions - The full definitions string from parseSourceCodeDefinitionsForFile
|
||||
* @param maxReadFileLine - Maximum line number to include (-1 for no limit, 0 for definitions only)
|
||||
* @returns Truncated definitions string
|
||||
*/
|
||||
export function truncateDefinitionsToLineLimit(definitions: string, maxReadFileLine: number): string {
|
||||
// If no limit or definitions-only mode (0), return as-is
|
||||
if (maxReadFileLine <= 0) {
|
||||
return definitions
|
||||
}
|
||||
|
||||
const lines = definitions.split("\n")
|
||||
const result: string[] = []
|
||||
let startIndex = 0
|
||||
|
||||
// Keep the header line (e.g., "# filename.ts")
|
||||
if (lines.length > 0 && lines[0].startsWith("#")) {
|
||||
result.push(lines[0])
|
||||
startIndex = 1
|
||||
}
|
||||
|
||||
// Process definition lines
|
||||
for (let i = startIndex; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Match definition format: "startLine--endLine | content" or "lineNumber | content"
|
||||
// Allow optional leading whitespace to handle indented output or CRLF artifacts
|
||||
const rangeMatch = line.match(/^\s*(\d+)(?:--(\d+))?\s*\|/)
|
||||
|
||||
if (rangeMatch) {
|
||||
const startLine = parseInt(rangeMatch[1], 10)
|
||||
|
||||
// Only include definitions that start within the truncated range
|
||||
if (startLine <= maxReadFileLine) {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
// Note: We don't preserve empty lines or other non-definition content
|
||||
// as they're not part of the actual code definitions
|
||||
}
|
||||
|
||||
return result.join("\n")
|
||||
}
|
||||
|
|
@ -2041,7 +2041,6 @@ export class ClineProvider
|
|||
showRooIgnoredFiles,
|
||||
enableSubfolderRules,
|
||||
language,
|
||||
maxReadFileLine,
|
||||
maxImageFileSize,
|
||||
maxTotalImageSize,
|
||||
historyPreviewCollapsed,
|
||||
|
|
@ -2053,7 +2052,6 @@ export class ClineProvider
|
|||
publicSharingEnabled,
|
||||
organizationAllowList,
|
||||
organizationSettingsVersion,
|
||||
maxConcurrentFileReads,
|
||||
customCondensingPrompt,
|
||||
codebaseIndexConfig,
|
||||
codebaseIndexModels,
|
||||
|
|
@ -2183,10 +2181,8 @@ export class ClineProvider
|
|||
enableSubfolderRules: enableSubfolderRules ?? false,
|
||||
language: language ?? formatLanguage(vscode.env.language),
|
||||
renderContext: this.renderContext,
|
||||
maxReadFileLine: maxReadFileLine ?? -1,
|
||||
maxImageFileSize: maxImageFileSize ?? 5,
|
||||
maxTotalImageSize: maxTotalImageSize ?? 20,
|
||||
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
|
||||
settingsImportedAt: this.settingsImportedAt,
|
||||
hasSystemPromptOverride,
|
||||
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
|
||||
|
|
@ -2423,10 +2419,8 @@ export class ClineProvider
|
|||
telemetrySetting: stateValues.telemetrySetting || "unset",
|
||||
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false,
|
||||
enableSubfolderRules: stateValues.enableSubfolderRules ?? false,
|
||||
maxReadFileLine: stateValues.maxReadFileLine ?? -1,
|
||||
maxImageFileSize: stateValues.maxImageFileSize ?? 5,
|
||||
maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,
|
||||
maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5,
|
||||
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
|
||||
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
|
||||
enterBehavior: stateValues.enterBehavior ?? "send",
|
||||
|
|
|
|||
|
|
@ -568,7 +568,6 @@ describe("ClineProvider", () => {
|
|||
showRooIgnoredFiles: false,
|
||||
enableSubfolderRules: false,
|
||||
renderContext: "sidebar",
|
||||
maxReadFileLine: 500,
|
||||
maxImageFileSize: 5,
|
||||
maxTotalImageSize: 20,
|
||||
cloudUserInfo: null,
|
||||
|
|
|
|||
|
|
@ -62,8 +62,6 @@ function makeProviderStub() {
|
|||
experiments: {},
|
||||
browserToolEnabled: true, // critical: enabled in settings
|
||||
language: "en",
|
||||
maxReadFileLine: -1,
|
||||
maxConcurrentFileReads: 5,
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
|
|||
experiments,
|
||||
browserToolEnabled,
|
||||
language,
|
||||
maxReadFileLine,
|
||||
maxConcurrentFileReads,
|
||||
enableSubfolderRules,
|
||||
} = await provider.getState()
|
||||
|
||||
|
|
@ -70,9 +68,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
|
|||
experiments,
|
||||
language,
|
||||
rooIgnoreInstructions,
|
||||
maxReadFileLine !== -1,
|
||||
{
|
||||
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
|
||||
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
|
||||
useAgentRules: vscode.workspace.getConfiguration(Package.name).get<boolean>("useAgentRules") ?? true,
|
||||
enableSubfolderRules: enableSubfolderRules ?? false,
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
// npx vitest run integrations/misc/__tests__/extract-text-large-files.spec.ts
|
||||
|
||||
import * as fs from "fs/promises"
|
||||
|
||||
import { extractTextFromFile } from "../extract-text"
|
||||
import { countFileLines } from "../line-counter"
|
||||
import { readLines } from "../read-lines"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock("fs/promises")
|
||||
vi.mock("../line-counter")
|
||||
vi.mock("../read-lines")
|
||||
vi.mock("isbinaryfile")
|
||||
|
||||
describe("extractTextFromFile - Large File Handling", () => {
|
||||
// Type the mocks
|
||||
const mockedFs = vi.mocked(fs)
|
||||
const mockedCountFileLines = vi.mocked(countFileLines)
|
||||
const mockedReadLines = vi.mocked(readLines)
|
||||
const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Set default mock behavior
|
||||
mockedFs.access.mockResolvedValue(undefined)
|
||||
mockedIsBinaryFile.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it("should truncate files that exceed maxReadFileLine limit", async () => {
|
||||
const largeFileContent = Array(150)
|
||||
.fill(null)
|
||||
.map((_, i) => `Line ${i + 1}: This is a test line with some content`)
|
||||
.join("\n")
|
||||
|
||||
mockedCountFileLines.mockResolvedValue(150)
|
||||
mockedReadLines.mockResolvedValue(
|
||||
Array(100)
|
||||
.fill(null)
|
||||
.map((_, i) => `Line ${i + 1}: This is a test line with some content`)
|
||||
.join("\n"),
|
||||
)
|
||||
|
||||
const result = await extractTextFromFile("/test/large-file.ts", 100)
|
||||
|
||||
// Should only include first 100 lines with line numbers
|
||||
expect(result).toContain(" 1 | Line 1: This is a test line with some content")
|
||||
expect(result).toContain("100 | Line 100: This is a test line with some content")
|
||||
expect(result).not.toContain("101 | Line 101: This is a test line with some content")
|
||||
|
||||
// Should include truncation message
|
||||
expect(result).toContain(
|
||||
"[File truncated: showing 100 of 150 total lines. The file is too large and may exhaust the context window if read in full.]",
|
||||
)
|
||||
})
|
||||
|
||||
it("should not truncate files within the maxReadFileLine limit", async () => {
|
||||
const smallFileContent = Array(50)
|
||||
.fill(null)
|
||||
.map((_, i) => `Line ${i + 1}: This is a test line`)
|
||||
.join("\n")
|
||||
|
||||
mockedCountFileLines.mockResolvedValue(50)
|
||||
mockedFs.readFile.mockResolvedValue(smallFileContent as any)
|
||||
|
||||
const result = await extractTextFromFile("/test/small-file.ts", 100)
|
||||
|
||||
// Should include all lines with line numbers
|
||||
expect(result).toContain(" 1 | Line 1: This is a test line")
|
||||
expect(result).toContain("50 | Line 50: This is a test line")
|
||||
|
||||
// Should not include truncation message
|
||||
expect(result).not.toContain("[File truncated:")
|
||||
})
|
||||
|
||||
it("should handle files with exactly maxReadFileLine lines", async () => {
|
||||
const exactFileContent = Array(100)
|
||||
.fill(null)
|
||||
.map((_, i) => `Line ${i + 1}`)
|
||||
.join("\n")
|
||||
|
||||
mockedCountFileLines.mockResolvedValue(100)
|
||||
mockedFs.readFile.mockResolvedValue(exactFileContent as any)
|
||||
|
||||
const result = await extractTextFromFile("/test/exact-file.ts", 100)
|
||||
|
||||
// Should include all lines with line numbers
|
||||
expect(result).toContain(" 1 | Line 1")
|
||||
expect(result).toContain("100 | Line 100")
|
||||
|
||||
// Should not include truncation message
|
||||
expect(result).not.toContain("[File truncated:")
|
||||
})
|
||||
|
||||
it("should handle undefined maxReadFileLine by not truncating", async () => {
|
||||
const largeFileContent = Array(200)
|
||||
.fill(null)
|
||||
.map((_, i) => `Line ${i + 1}`)
|
||||
.join("\n")
|
||||
|
||||
mockedFs.readFile.mockResolvedValue(largeFileContent as any)
|
||||
|
||||
const result = await extractTextFromFile("/test/large-file.ts", undefined)
|
||||
|
||||
// Should include all lines with line numbers when maxReadFileLine is undefined
|
||||
expect(result).toContain(" 1 | Line 1")
|
||||
expect(result).toContain("200 | Line 200")
|
||||
|
||||
// Should not include truncation message
|
||||
expect(result).not.toContain("[File truncated:")
|
||||
})
|
||||
|
||||
it("should handle empty files", async () => {
|
||||
mockedFs.readFile.mockResolvedValue("" as any)
|
||||
|
||||
const result = await extractTextFromFile("/test/empty-file.ts", 100)
|
||||
|
||||
expect(result).toBe("")
|
||||
expect(result).not.toContain("[File truncated:")
|
||||
})
|
||||
|
||||
it("should handle files with only newlines", async () => {
|
||||
const newlineOnlyContent = "\n\n\n\n\n"
|
||||
|
||||
mockedCountFileLines.mockResolvedValue(6) // 5 newlines = 6 lines
|
||||
mockedReadLines.mockResolvedValue("\n\n")
|
||||
|
||||
const result = await extractTextFromFile("/test/newline-file.ts", 3)
|
||||
|
||||
// Should truncate at line 3
|
||||
expect(result).toContain("[File truncated: showing 3 of 6 total lines")
|
||||
})
|
||||
|
||||
it("should handle very large files efficiently", async () => {
|
||||
// Simulate a 10,000 line file
|
||||
mockedCountFileLines.mockResolvedValue(10000)
|
||||
mockedReadLines.mockResolvedValue(
|
||||
Array(500)
|
||||
.fill(null)
|
||||
.map((_, i) => `Line ${i + 1}: Some content here`)
|
||||
.join("\n"),
|
||||
)
|
||||
|
||||
const result = await extractTextFromFile("/test/very-large-file.ts", 500)
|
||||
|
||||
// Should only include first 500 lines with line numbers
|
||||
expect(result).toContain(" 1 | Line 1: Some content here")
|
||||
expect(result).toContain("500 | Line 500: Some content here")
|
||||
expect(result).not.toContain("501 | Line 501: Some content here")
|
||||
|
||||
// Should show truncation message
|
||||
expect(result).toContain("[File truncated: showing 500 of 10000 total lines")
|
||||
})
|
||||
|
||||
it("should handle maxReadFileLine of 0 by throwing an error", async () => {
|
||||
const fileContent = "Line 1\nLine 2\nLine 3"
|
||||
|
||||
mockedFs.readFile.mockResolvedValue(fileContent as any)
|
||||
|
||||
// maxReadFileLine of 0 should throw an error
|
||||
await expect(extractTextFromFile("/test/file.ts", 0)).rejects.toThrow(
|
||||
"Invalid maxReadFileLine: 0. Must be a positive integer or -1 for unlimited.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle negative maxReadFileLine by treating as undefined", async () => {
|
||||
const fileContent = "Line 1\nLine 2\nLine 3"
|
||||
|
||||
mockedFs.readFile.mockResolvedValue(fileContent as any)
|
||||
|
||||
const result = await extractTextFromFile("/test/file.ts", -1)
|
||||
|
||||
// Should include all content with line numbers when negative
|
||||
expect(result).toContain("1 | Line 1")
|
||||
expect(result).toContain("2 | Line 2")
|
||||
expect(result).toContain("3 | Line 3")
|
||||
expect(result).not.toContain("[File truncated:")
|
||||
})
|
||||
|
||||
it("should preserve file content structure when truncating", async () => {
|
||||
const structuredContent = [
|
||||
"function example() {",
|
||||
" const x = 1;",
|
||||
" const y = 2;",
|
||||
" return x + y;",
|
||||
"}",
|
||||
"",
|
||||
"// More code below",
|
||||
].join("\n")
|
||||
|
||||
mockedCountFileLines.mockResolvedValue(7)
|
||||
mockedReadLines.mockResolvedValue(["function example() {", " const x = 1;", " const y = 2;"].join("\n"))
|
||||
|
||||
const result = await extractTextFromFile("/test/structured.ts", 3)
|
||||
|
||||
// Should preserve the first 3 lines with line numbers
|
||||
expect(result).toContain("1 | function example() {")
|
||||
expect(result).toContain("2 | const x = 1;")
|
||||
expect(result).toContain("3 | const y = 2;")
|
||||
expect(result).not.toContain("4 | return x + y;")
|
||||
|
||||
// Should include truncation info
|
||||
expect(result).toContain("[File truncated: showing 3 of 7 total lines")
|
||||
})
|
||||
|
||||
it("should handle binary files by throwing an error", async () => {
|
||||
mockedIsBinaryFile.mockResolvedValue(true)
|
||||
|
||||
await expect(extractTextFromFile("/test/binary.bin", 100)).rejects.toThrow(
|
||||
"Cannot read text for file type: .bin",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle file not found errors", async () => {
|
||||
mockedFs.access.mockRejectedValue(new Error("ENOENT"))
|
||||
|
||||
await expect(extractTextFromFile("/test/nonexistent.ts", 100)).rejects.toThrow(
|
||||
"File not found: /test/nonexistent.ts",
|
||||
)
|
||||
})
|
||||
})
|
||||
639
src/integrations/misc/__tests__/indentation-reader.spec.ts
Normal file
639
src/integrations/misc/__tests__/indentation-reader.spec.ts
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
parseLines,
|
||||
formatWithLineNumbers,
|
||||
readWithIndentation,
|
||||
readWithSlice,
|
||||
computeEffectiveIndents,
|
||||
type LineRecord,
|
||||
type IndentationReadResult,
|
||||
} from "../indentation-reader"
|
||||
|
||||
// ─── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
const PYTHON_CODE = `#!/usr/bin/env python3
|
||||
"""Module docstring."""
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
class Calculator:
|
||||
"""A simple calculator class."""
|
||||
|
||||
def __init__(self, value: int = 0):
|
||||
self.value = value
|
||||
|
||||
def add(self, n: int) -> int:
|
||||
"""Add a number."""
|
||||
self.value += n
|
||||
return self.value
|
||||
|
||||
def subtract(self, n: int) -> int:
|
||||
"""Subtract a number."""
|
||||
self.value -= n
|
||||
return self.value
|
||||
|
||||
def reset(self):
|
||||
"""Reset to zero."""
|
||||
self.value = 0
|
||||
|
||||
def main():
|
||||
calc = Calculator()
|
||||
calc.add(5)
|
||||
print(calc.value)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
`
|
||||
|
||||
const TYPESCRIPT_CODE = `import { something } from "./module"
|
||||
import type { SomeType } from "./types"
|
||||
|
||||
// Constants
|
||||
const MAX_VALUE = 100
|
||||
|
||||
interface Config {
|
||||
name: string
|
||||
value: number
|
||||
}
|
||||
|
||||
class Handler {
|
||||
private config: Config
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
process(input: string): string {
|
||||
// Process the input
|
||||
const result = input.toUpperCase()
|
||||
if (result.length > MAX_VALUE) {
|
||||
return result.slice(0, MAX_VALUE)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
validate(data: unknown): boolean {
|
||||
if (typeof data !== "string") {
|
||||
return false
|
||||
}
|
||||
return data.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
export function createHandler(config: Config): Handler {
|
||||
return new Handler(config)
|
||||
}
|
||||
`
|
||||
|
||||
const SIMPLE_CODE = `function outer() {
|
||||
function inner() {
|
||||
console.log("hello")
|
||||
}
|
||||
inner()
|
||||
}
|
||||
`
|
||||
|
||||
const CODE_WITH_BLANKS = `class Example:
|
||||
def method_one(self):
|
||||
x = 1
|
||||
|
||||
y = 2
|
||||
|
||||
return x + y
|
||||
|
||||
def method_two(self):
|
||||
return 42
|
||||
`
|
||||
|
||||
// ─── parseLines Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseLines", () => {
|
||||
it("should parse lines with correct line numbers", () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
const lines = parseLines(content)
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0].lineNumber).toBe(1)
|
||||
expect(lines[1].lineNumber).toBe(2)
|
||||
expect(lines[2].lineNumber).toBe(3)
|
||||
})
|
||||
|
||||
it("should calculate indentation levels correctly", () => {
|
||||
const content = "no indent\n one level\n two levels\n\t\ttab indent"
|
||||
const lines = parseLines(content)
|
||||
|
||||
expect(lines[0].indentLevel).toBe(0)
|
||||
expect(lines[1].indentLevel).toBe(1) // 4 spaces = 1 level
|
||||
expect(lines[2].indentLevel).toBe(2) // 8 spaces = 2 levels
|
||||
expect(lines[3].indentLevel).toBe(2) // 2 tabs = 2 levels (tabs = 4 spaces each)
|
||||
})
|
||||
|
||||
it("should identify blank lines", () => {
|
||||
const content = "content\n\n \nmore content"
|
||||
const lines = parseLines(content)
|
||||
|
||||
expect(lines[0].isBlank).toBe(false)
|
||||
expect(lines[1].isBlank).toBe(true) // empty
|
||||
expect(lines[2].isBlank).toBe(true) // whitespace only
|
||||
expect(lines[3].isBlank).toBe(false)
|
||||
})
|
||||
|
||||
it("should identify block starts (Python style)", () => {
|
||||
const content = "def foo():\n pass\nclass Bar:\n pass"
|
||||
const lines = parseLines(content)
|
||||
|
||||
expect(lines[0].isBlockStart).toBe(true) // def foo():
|
||||
expect(lines[1].isBlockStart).toBe(false) // pass
|
||||
expect(lines[2].isBlockStart).toBe(true) // class Bar:
|
||||
})
|
||||
|
||||
it("should identify block starts (C-style)", () => {
|
||||
const content = "function foo() {\n return\n}\nif (x) {"
|
||||
const lines = parseLines(content)
|
||||
|
||||
expect(lines[0].isBlockStart).toBe(true) // function foo() {
|
||||
expect(lines[1].isBlockStart).toBe(false) // return
|
||||
expect(lines[2].isBlockStart).toBe(false) // }
|
||||
expect(lines[3].isBlockStart).toBe(true) // if (x) {
|
||||
})
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const lines = parseLines("")
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].isBlank).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── computeEffectiveIndents Tests ────────────────────────────────────────────
|
||||
|
||||
describe("computeEffectiveIndents", () => {
|
||||
it("should return same indents for non-blank lines", () => {
|
||||
const content = "line1\n line2\n line3"
|
||||
const lines = parseLines(content)
|
||||
const effective = computeEffectiveIndents(lines)
|
||||
|
||||
expect(effective[0]).toBe(0)
|
||||
expect(effective[1]).toBe(1)
|
||||
expect(effective[2]).toBe(2)
|
||||
})
|
||||
|
||||
it("should inherit previous indent for blank lines", () => {
|
||||
const content = "line1\n line2\n\n line3"
|
||||
const lines = parseLines(content)
|
||||
const effective = computeEffectiveIndents(lines)
|
||||
|
||||
expect(effective[0]).toBe(0) // line1
|
||||
expect(effective[1]).toBe(1) // line2 (indent 1)
|
||||
expect(effective[2]).toBe(1) // blank line inherits from line2
|
||||
expect(effective[3]).toBe(1) // line3
|
||||
})
|
||||
|
||||
it("should handle multiple consecutive blank lines", () => {
|
||||
const content = " start\n\n\n\n end"
|
||||
const lines = parseLines(content)
|
||||
const effective = computeEffectiveIndents(lines)
|
||||
|
||||
expect(effective[0]).toBe(1) // start
|
||||
expect(effective[1]).toBe(1) // blank inherits
|
||||
expect(effective[2]).toBe(1) // blank inherits
|
||||
expect(effective[3]).toBe(1) // blank inherits
|
||||
expect(effective[4]).toBe(1) // end
|
||||
})
|
||||
|
||||
it("should handle blank line at start", () => {
|
||||
const content = "\n content"
|
||||
const lines = parseLines(content)
|
||||
const effective = computeEffectiveIndents(lines)
|
||||
|
||||
expect(effective[0]).toBe(0) // blank at start has no previous, defaults to 0
|
||||
expect(effective[1]).toBe(1) // content
|
||||
})
|
||||
})
|
||||
|
||||
// ─── formatWithLineNumbers Tests ──────────────────────────────────────────────
|
||||
|
||||
describe("formatWithLineNumbers", () => {
|
||||
it("should format lines with line numbers", () => {
|
||||
const lines: LineRecord[] = [
|
||||
{ lineNumber: 1, content: "first", indentLevel: 0, isBlank: false, isBlockStart: false },
|
||||
{ lineNumber: 2, content: "second", indentLevel: 0, isBlank: false, isBlockStart: false },
|
||||
]
|
||||
|
||||
const result = formatWithLineNumbers(lines)
|
||||
expect(result).toBe("1 | first\n2 | second")
|
||||
})
|
||||
|
||||
it("should pad line numbers for alignment", () => {
|
||||
const lines: LineRecord[] = [
|
||||
{ lineNumber: 1, content: "a", indentLevel: 0, isBlank: false, isBlockStart: false },
|
||||
{ lineNumber: 10, content: "b", indentLevel: 0, isBlank: false, isBlockStart: false },
|
||||
{ lineNumber: 100, content: "c", indentLevel: 0, isBlank: false, isBlockStart: false },
|
||||
]
|
||||
|
||||
const result = formatWithLineNumbers(lines)
|
||||
expect(result).toBe(" 1 | a\n 10 | b\n100 | c")
|
||||
})
|
||||
|
||||
it("should truncate long lines", () => {
|
||||
const longLine = "x".repeat(600)
|
||||
const lines: LineRecord[] = [
|
||||
{ lineNumber: 1, content: longLine, indentLevel: 0, isBlank: false, isBlockStart: false },
|
||||
]
|
||||
|
||||
const result = formatWithLineNumbers(lines, 100)
|
||||
expect(result.length).toBeLessThan(longLine.length)
|
||||
expect(result).toContain("...")
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const result = formatWithLineNumbers([])
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
// ─── readWithSlice Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe("readWithSlice", () => {
|
||||
it("should read from beginning with default offset", () => {
|
||||
const result = readWithSlice(SIMPLE_CODE, 0, 10)
|
||||
|
||||
expect(result.totalLines).toBe(7) // 6 lines + empty trailing
|
||||
expect(result.returnedLines).toBe(7)
|
||||
expect(result.wasTruncated).toBe(false)
|
||||
expect(result.content).toContain("1 | function outer()")
|
||||
})
|
||||
|
||||
it("should respect offset parameter", () => {
|
||||
const result = readWithSlice(SIMPLE_CODE, 2, 10)
|
||||
|
||||
expect(result.content).not.toContain("function outer()")
|
||||
expect(result.content).toContain("console.log")
|
||||
expect(result.includedRanges[0][0]).toBe(3) // 1-based, offset 2 = line 3
|
||||
})
|
||||
|
||||
it("should respect limit parameter", () => {
|
||||
const result = readWithSlice(TYPESCRIPT_CODE, 0, 5)
|
||||
|
||||
expect(result.returnedLines).toBe(5)
|
||||
expect(result.wasTruncated).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle offset beyond file end", () => {
|
||||
const result = readWithSlice(SIMPLE_CODE, 1000, 10)
|
||||
|
||||
expect(result.returnedLines).toBe(0)
|
||||
expect(result.content).toContain("Error")
|
||||
})
|
||||
|
||||
it("should handle negative offset", () => {
|
||||
const result = readWithSlice(SIMPLE_CODE, -5, 10)
|
||||
|
||||
// Should normalize to 0
|
||||
expect(result.includedRanges[0][0]).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── readWithIndentation Tests ────────────────────────────────────────────────
|
||||
|
||||
describe("readWithIndentation", () => {
|
||||
describe("basic block extraction", () => {
|
||||
it("should extract content around the anchor line", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15, // Inside add() method
|
||||
maxLevels: 0, // unlimited
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
expect(result.content).toContain("def add")
|
||||
expect(result.content).toContain("self.value += n")
|
||||
expect(result.content).toContain("return self.value")
|
||||
})
|
||||
|
||||
it("should handle anchor at first line", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 1,
|
||||
maxLevels: 0,
|
||||
includeHeader: false,
|
||||
})
|
||||
|
||||
expect(result.returnedLines).toBeGreaterThan(0)
|
||||
expect(result.content).toContain("function outer()")
|
||||
})
|
||||
|
||||
it("should handle anchor at last line", () => {
|
||||
const lines = PYTHON_CODE.trim().split("\n").length
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: lines,
|
||||
maxLevels: 0,
|
||||
includeHeader: false,
|
||||
})
|
||||
|
||||
expect(result.returnedLines).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("max_levels behavior", () => {
|
||||
it("should include all content when maxLevels=0 (unlimited)", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 3, // Inside inner()
|
||||
maxLevels: 0,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// With unlimited levels, should get the whole file
|
||||
expect(result.content).toContain("function outer()")
|
||||
expect(result.content).toContain("function inner()")
|
||||
expect(result.content).toContain("console.log")
|
||||
})
|
||||
|
||||
it("should limit expansion when maxLevels > 0", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 3, // Inside inner()
|
||||
maxLevels: 1,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// With 1 level, should include inner() context but may not reach outer()
|
||||
expect(result.content).toContain("console.log")
|
||||
})
|
||||
|
||||
it("should handle deeply nested code with unlimited levels", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15, // Inside add() method body
|
||||
maxLevels: 0, // unlimited
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// Should expand to include class context
|
||||
expect(result.content).toContain("class Calculator")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sibling blocks", () => {
|
||||
it("should exclude siblings when includeSiblings is false", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15, // Inside add() method
|
||||
maxLevels: 1,
|
||||
includeSiblings: false,
|
||||
includeHeader: false,
|
||||
})
|
||||
|
||||
// Should focus on add() but not include subtract() or other siblings
|
||||
expect(result.content).toContain("def add")
|
||||
})
|
||||
|
||||
it("should include siblings when includeSiblings is true", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15, // Inside add() method
|
||||
maxLevels: 1,
|
||||
includeSiblings: true,
|
||||
includeHeader: false,
|
||||
})
|
||||
|
||||
// Should include sibling methods
|
||||
expect(result.content).toContain("def add")
|
||||
// May include other siblings depending on limit
|
||||
})
|
||||
})
|
||||
|
||||
describe("file header (includeHeader option)", () => {
|
||||
it("should allow comment lines at min indent when includeHeader is true", () => {
|
||||
// The Codex algorithm's includeHeader option allows comment lines at the
|
||||
// minimum indent level to be included during upward expansion.
|
||||
// This is different from prepending the file's import header.
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15,
|
||||
maxLevels: 0, // unlimited - will expand to indent 0
|
||||
includeHeader: true,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// With unlimited levels, bidirectional expansion will include content
|
||||
// at indent level 0. includeHeader allows comment lines to be included.
|
||||
expect(result.returnedLines).toBeGreaterThan(0)
|
||||
expect(result.content).toContain("def add")
|
||||
})
|
||||
|
||||
it("should expand to top-level content with maxLevels=0", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15,
|
||||
maxLevels: 0, // unlimited
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// With unlimited levels, expansion goes to indent 0
|
||||
// which includes the class definition
|
||||
expect(result.content).toContain("class Calculator")
|
||||
})
|
||||
|
||||
it("should include class content when anchored inside a method", () => {
|
||||
const result = readWithIndentation(TYPESCRIPT_CODE, {
|
||||
anchorLine: 20, // Inside Handler class
|
||||
maxLevels: 0,
|
||||
includeHeader: true,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// Should include class context
|
||||
expect(result.content).toContain("class Handler")
|
||||
})
|
||||
})
|
||||
|
||||
describe("line limit and max_lines", () => {
|
||||
it("should truncate output when exceeding limit", () => {
|
||||
const result = readWithIndentation(TYPESCRIPT_CODE, {
|
||||
anchorLine: 15,
|
||||
maxLevels: 0,
|
||||
includeHeader: true,
|
||||
includeSiblings: true,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(result.returnedLines).toBeLessThanOrEqual(10)
|
||||
expect(result.wasTruncated).toBe(true)
|
||||
})
|
||||
|
||||
it("should not truncate when under limit", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 3,
|
||||
maxLevels: 1,
|
||||
includeHeader: false,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
expect(result.wasTruncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should respect maxLines as separate hard cap", () => {
|
||||
const result = readWithIndentation(TYPESCRIPT_CODE, {
|
||||
anchorLine: 20,
|
||||
maxLevels: 0,
|
||||
includeHeader: true,
|
||||
includeSiblings: true,
|
||||
limit: 100,
|
||||
maxLines: 5, // Hard cap at 5
|
||||
})
|
||||
|
||||
expect(result.returnedLines).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it("should use min of limit and maxLines", () => {
|
||||
const result = readWithIndentation(TYPESCRIPT_CODE, {
|
||||
anchorLine: 20,
|
||||
maxLevels: 0,
|
||||
includeHeader: true,
|
||||
includeSiblings: true,
|
||||
limit: 3, // More restrictive than maxLines
|
||||
maxLines: 10,
|
||||
})
|
||||
|
||||
expect(result.returnedLines).toBeLessThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("blank line handling", () => {
|
||||
it("should treat blank lines with inherited indentation", () => {
|
||||
const result = readWithIndentation(CODE_WITH_BLANKS, {
|
||||
anchorLine: 4, // blank line inside method_one
|
||||
maxLevels: 1,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// Blank line should inherit previous indent and be included in expansion
|
||||
expect(result.returnedLines).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should trim empty lines from edges of result", () => {
|
||||
const result = readWithIndentation(CODE_WITH_BLANKS, {
|
||||
anchorLine: 3, // x = 1
|
||||
maxLevels: 1,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// Check that result doesn't start or end with blank lines
|
||||
const lines = result.content.split("\n")
|
||||
if (lines.length > 0) {
|
||||
const firstLine = lines[0]
|
||||
const lastLine = lines[lines.length - 1]
|
||||
// Lines should have content after the line number prefix
|
||||
expect(firstLine).toMatch(/\d+\s*\|/)
|
||||
expect(lastLine).toMatch(/\d+\s*\|/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle invalid anchor line (too low)", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 0,
|
||||
maxLevels: 1,
|
||||
})
|
||||
|
||||
expect(result.content).toContain("Error")
|
||||
expect(result.returnedLines).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle invalid anchor line (too high)", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 9999,
|
||||
maxLevels: 1,
|
||||
})
|
||||
|
||||
expect(result.content).toContain("Error")
|
||||
expect(result.returnedLines).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bidirectional expansion", () => {
|
||||
it("should expand both up and down from anchor", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 3, // console.log("hello") - in the middle
|
||||
maxLevels: 0,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
// Should include lines both before and after anchor
|
||||
expect(result.content).toContain("function inner()")
|
||||
expect(result.content).toContain("console.log")
|
||||
})
|
||||
|
||||
it("should return single line when limit is 1", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 3,
|
||||
maxLevels: 0,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result.returnedLines).toBe(1)
|
||||
expect(result.content).toContain("console.log")
|
||||
})
|
||||
|
||||
it("should stop expansion when hitting lower indent", () => {
|
||||
const result = readWithIndentation(PYTHON_CODE, {
|
||||
anchorLine: 15, // Inside add() method body (return self.value)
|
||||
maxLevels: 2, // Only go up 2 levels from anchor indent
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
// Should include method but respect maxLevels
|
||||
expect(result.content).toContain("def add")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should extract a function with its context", () => {
|
||||
const result = readWithIndentation(TYPESCRIPT_CODE, {
|
||||
anchorLine: 37, // Inside createHandler function body (return statement)
|
||||
maxLevels: 0,
|
||||
includeHeader: true,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
expect(result.content).toContain("export function createHandler")
|
||||
expect(result.content).toContain("return new Handler")
|
||||
})
|
||||
|
||||
it("should extract a class method with class context", () => {
|
||||
const result = readWithIndentation(TYPESCRIPT_CODE, {
|
||||
anchorLine: 19, // Inside process() method
|
||||
maxLevels: 1,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
})
|
||||
|
||||
expect(result.content).toContain("process(input: string)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("includedRanges", () => {
|
||||
it("should return correct contiguous range", () => {
|
||||
const result = readWithIndentation(SIMPLE_CODE, {
|
||||
anchorLine: 3,
|
||||
maxLevels: 0,
|
||||
includeHeader: false,
|
||||
includeSiblings: false,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(result.includedRanges.length).toBeGreaterThan(0)
|
||||
// Each range should be [start, end] with start <= end
|
||||
for (const [start, end] of result.includedRanges) {
|
||||
expect(start).toBeLessThanOrEqual(end)
|
||||
expect(start).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
// npx vitest run integrations/misc/__tests__/read-file-tool.spec.ts
|
||||
|
||||
import type { Mock } from "vitest"
|
||||
import * as path from "path"
|
||||
import { countFileLines } from "../line-counter"
|
||||
import { readLines } from "../read-lines"
|
||||
import { extractTextFromFile, addLineNumbers } from "../extract-text"
|
||||
|
||||
// Mock the required functions
|
||||
vitest.mock("../line-counter")
|
||||
vitest.mock("../read-lines")
|
||||
vitest.mock("../extract-text")
|
||||
|
||||
describe("read_file tool with maxReadFileLine setting", () => {
|
||||
// Mock original implementation first to use in tests
|
||||
let originalCountFileLines: any
|
||||
let originalReadLines: any
|
||||
let originalExtractTextFromFile: any
|
||||
let originalAddLineNumbers: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Import actual implementations
|
||||
originalCountFileLines = ((await vitest.importActual("../line-counter")) as any).countFileLines
|
||||
originalReadLines = ((await vitest.importActual("../read-lines")) as any).readLines
|
||||
originalExtractTextFromFile = ((await vitest.importActual("../extract-text")) as any).extractTextFromFile
|
||||
originalAddLineNumbers = ((await vitest.importActual("../extract-text")) as any).addLineNumbers
|
||||
|
||||
vitest.resetAllMocks()
|
||||
// Reset mocks to simulate original behavior
|
||||
;(countFileLines as Mock).mockImplementation(originalCountFileLines)
|
||||
;(readLines as Mock).mockImplementation(originalReadLines)
|
||||
;(extractTextFromFile as Mock).mockImplementation(originalExtractTextFromFile)
|
||||
;(addLineNumbers as Mock).mockImplementation(originalAddLineNumbers)
|
||||
})
|
||||
|
||||
// Test for the case when file size is smaller than maxReadFileLine
|
||||
it("should read entire file when line count is less than maxReadFileLine", async () => {
|
||||
// Mock necessary functions
|
||||
;(countFileLines as Mock).mockResolvedValue(100)
|
||||
;(extractTextFromFile as Mock).mockResolvedValue("Small file content")
|
||||
|
||||
// Create mock implementation that would simulate the behavior
|
||||
// Note: We're not testing the Cline class directly as it would be too complex
|
||||
// We're testing the logic flow that would happen in the read_file implementation
|
||||
|
||||
const filePath = path.resolve("/test", "smallFile.txt")
|
||||
const maxReadFileLine = 500
|
||||
|
||||
// Check line count
|
||||
const lineCount = await countFileLines(filePath)
|
||||
expect(lineCount).toBeLessThan(maxReadFileLine)
|
||||
|
||||
// Should use extractTextFromFile for small files
|
||||
if (lineCount < maxReadFileLine) {
|
||||
await extractTextFromFile(filePath)
|
||||
}
|
||||
|
||||
expect(extractTextFromFile).toHaveBeenCalledWith(filePath)
|
||||
expect(readLines).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test for the case when file size is larger than maxReadFileLine
|
||||
it("should truncate file when line count exceeds maxReadFileLine", async () => {
|
||||
// Mock necessary functions
|
||||
;(countFileLines as Mock).mockResolvedValue(5000)
|
||||
;(readLines as Mock).mockResolvedValue("First 500 lines of large file")
|
||||
;(addLineNumbers as Mock).mockReturnValue("1 | First line\n2 | Second line\n...")
|
||||
|
||||
const filePath = path.resolve("/test", "largeFile.txt")
|
||||
const maxReadFileLine = 500
|
||||
|
||||
// Check line count
|
||||
const lineCount = await countFileLines(filePath)
|
||||
expect(lineCount).toBeGreaterThan(maxReadFileLine)
|
||||
|
||||
// Should use readLines for large files
|
||||
if (lineCount > maxReadFileLine) {
|
||||
const content = await readLines(filePath, maxReadFileLine - 1, 0)
|
||||
const numberedContent = addLineNumbers(content)
|
||||
|
||||
// Verify the truncation message is shown (simulated)
|
||||
const truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]`
|
||||
const fullResult = numberedContent + truncationMsg
|
||||
|
||||
expect(fullResult).toContain("File truncated")
|
||||
}
|
||||
|
||||
expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0)
|
||||
expect(addLineNumbers).toHaveBeenCalled()
|
||||
expect(extractTextFromFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test for the case when the file is a source code file
|
||||
it("should add source code file type info for large source code files", async () => {
|
||||
// Mock necessary functions
|
||||
;(countFileLines as Mock).mockResolvedValue(5000)
|
||||
;(readLines as Mock).mockResolvedValue("First 500 lines of large JavaScript file")
|
||||
;(addLineNumbers as Mock).mockReturnValue('1 | const foo = "bar";\n2 | function test() {...')
|
||||
|
||||
const filePath = path.resolve("/test", "largeFile.js")
|
||||
const maxReadFileLine = 500
|
||||
|
||||
// Check line count
|
||||
const lineCount = await countFileLines(filePath)
|
||||
expect(lineCount).toBeGreaterThan(maxReadFileLine)
|
||||
|
||||
// Check if the file is a source code file
|
||||
const fileExt = path.extname(filePath).toLowerCase()
|
||||
const isSourceCode = [
|
||||
".js",
|
||||
".ts",
|
||||
".jsx",
|
||||
".tsx",
|
||||
".py",
|
||||
".java",
|
||||
".c",
|
||||
".cpp",
|
||||
".cs",
|
||||
".go",
|
||||
".rb",
|
||||
".php",
|
||||
".swift",
|
||||
".rs",
|
||||
].includes(fileExt)
|
||||
expect(isSourceCode).toBeTruthy()
|
||||
|
||||
// Should use readLines for large files
|
||||
if (lineCount > maxReadFileLine) {
|
||||
const content = await readLines(filePath, maxReadFileLine - 1, 0)
|
||||
const numberedContent = addLineNumbers(content)
|
||||
|
||||
// Verify the truncation message and source code message are shown (simulated)
|
||||
let truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]`
|
||||
if (isSourceCode) {
|
||||
truncationMsg +=
|
||||
"\n\nThis appears to be a source code file. Consider using list_code_definition_names to understand its structure."
|
||||
}
|
||||
const fullResult = numberedContent + truncationMsg
|
||||
|
||||
expect(fullResult).toContain("source code file")
|
||||
expect(fullResult).toContain("list_code_definition_names")
|
||||
}
|
||||
|
||||
expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0)
|
||||
expect(addLineNumbers).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { readFileWithTokenBudget } from "../read-file-with-budget"
|
||||
|
||||
describe("readFileWithTokenBudget", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for test files
|
||||
tempDir = path.join(os.tmpdir(), `read-file-budget-test-${Date.now()}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temporary directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("Basic functionality", () => {
|
||||
test("reads entire small file when within budget", async () => {
|
||||
const filePath = path.join(tempDir, "small.txt")
|
||||
const content = "Line 1\nLine 2\nLine 3"
|
||||
await fs.writeFile(filePath, content)
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000, // Large budget
|
||||
})
|
||||
|
||||
expect(result.content).toBe(content)
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.tokenCount).toBeGreaterThan(0)
|
||||
expect(result.tokenCount).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
test("returns correct token count", async () => {
|
||||
const filePath = path.join(tempDir, "token-test.txt")
|
||||
const content = "This is a test file with some content."
|
||||
await fs.writeFile(filePath, content)
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
// Token count should be reasonable (rough estimate: 1 token per 3-4 chars)
|
||||
expect(result.tokenCount).toBeGreaterThan(5)
|
||||
expect(result.tokenCount).toBeLessThan(20)
|
||||
})
|
||||
|
||||
test("returns complete: true for files within budget", async () => {
|
||||
const filePath = path.join(tempDir, "within-budget.txt")
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.lineCount).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Truncation behavior", () => {
|
||||
test("stops reading when token budget reached", async () => {
|
||||
const filePath = path.join(tempDir, "large.txt")
|
||||
// Create a file with many lines
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `This is line number ${i + 1} with some content`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 50, // Small budget
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.lineCount).toBeLessThan(1000)
|
||||
expect(result.lineCount).toBeGreaterThan(0)
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
test("returns complete: false when truncated", async () => {
|
||||
const filePath = path.join(tempDir, "truncated.txt")
|
||||
const lines = Array.from({ length: 500 }, (_, i) => `Line ${i + 1}`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 20,
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(20)
|
||||
})
|
||||
|
||||
test("content ends at line boundary (no partial lines)", async () => {
|
||||
const filePath = path.join(tempDir, "line-boundary.txt")
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 30,
|
||||
})
|
||||
|
||||
// Content should not end mid-line
|
||||
const contentLines = result.content.split("\n")
|
||||
expect(contentLines.length).toBe(result.lineCount)
|
||||
// Last line should be complete (not cut off)
|
||||
expect(contentLines[contentLines.length - 1]).toMatch(/^Line \d+$/)
|
||||
})
|
||||
|
||||
test("works with different chunk sizes", async () => {
|
||||
const filePath = path.join(tempDir, "chunks.txt")
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
// Test with small chunk size
|
||||
const result1 = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 50,
|
||||
chunkLines: 10,
|
||||
})
|
||||
|
||||
// Test with large chunk size
|
||||
const result2 = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 50,
|
||||
chunkLines: 500,
|
||||
})
|
||||
|
||||
// Both should truncate, but may differ slightly in exact line count
|
||||
expect(result1.complete).toBe(false)
|
||||
expect(result2.complete).toBe(false)
|
||||
expect(result1.tokenCount).toBeLessThanOrEqual(50)
|
||||
expect(result2.tokenCount).toBeLessThanOrEqual(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("handles empty file", async () => {
|
||||
const filePath = path.join(tempDir, "empty.txt")
|
||||
await fs.writeFile(filePath, "")
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 100,
|
||||
})
|
||||
|
||||
expect(result.content).toBe("")
|
||||
expect(result.lineCount).toBe(0)
|
||||
expect(result.tokenCount).toBe(0)
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
|
||||
test("handles single line file", async () => {
|
||||
const filePath = path.join(tempDir, "single-line.txt")
|
||||
await fs.writeFile(filePath, "Single line content")
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 100,
|
||||
})
|
||||
|
||||
expect(result.content).toBe("Single line content")
|
||||
expect(result.lineCount).toBe(1)
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
|
||||
test("handles budget of 0 tokens", async () => {
|
||||
const filePath = path.join(tempDir, "zero-budget.txt")
|
||||
await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3")
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 0,
|
||||
})
|
||||
|
||||
expect(result.content).toBe("")
|
||||
expect(result.lineCount).toBe(0)
|
||||
expect(result.tokenCount).toBe(0)
|
||||
expect(result.complete).toBe(false)
|
||||
})
|
||||
|
||||
test("handles very small budget (fewer tokens than first line)", async () => {
|
||||
const filePath = path.join(tempDir, "tiny-budget.txt")
|
||||
const longLine = "This is a very long line with lots of content that will exceed a tiny token budget"
|
||||
await fs.writeFile(filePath, `${longLine}\nLine 2\nLine 3`)
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 2, // Very small budget
|
||||
})
|
||||
|
||||
// Should return empty since first line exceeds budget
|
||||
expect(result.content).toBe("")
|
||||
expect(result.lineCount).toBe(0)
|
||||
expect(result.complete).toBe(false)
|
||||
})
|
||||
|
||||
test("throws error for non-existent file", async () => {
|
||||
const filePath = path.join(tempDir, "does-not-exist.txt")
|
||||
|
||||
await expect(
|
||||
readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 100,
|
||||
}),
|
||||
).rejects.toThrow("File not found")
|
||||
})
|
||||
|
||||
test("handles file with no trailing newline", async () => {
|
||||
const filePath = path.join(tempDir, "no-trailing-newline.txt")
|
||||
await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3")
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
expect(result.content).toBe("Line 1\nLine 2\nLine 3")
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
|
||||
test("handles file with trailing newline", async () => {
|
||||
const filePath = path.join(tempDir, "trailing-newline.txt")
|
||||
await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3\n")
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
expect(result.content).toBe("Line 1\nLine 2\nLine 3")
|
||||
expect(result.lineCount).toBe(3)
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Token counting accuracy", () => {
|
||||
test("returned tokenCount matches actual tokens in content", async () => {
|
||||
const filePath = path.join(tempDir, "accuracy.txt")
|
||||
const content = "Hello world\nThis is a test\nWith some content"
|
||||
await fs.writeFile(filePath, content)
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
// Verify the token count is reasonable
|
||||
// Rough estimate: 1 token per 3-4 characters
|
||||
const minExpected = Math.floor(content.length / 5)
|
||||
const maxExpected = Math.ceil(content.length / 2)
|
||||
|
||||
expect(result.tokenCount).toBeGreaterThanOrEqual(minExpected)
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(maxExpected)
|
||||
})
|
||||
|
||||
test("handles special characters correctly", async () => {
|
||||
const filePath = path.join(tempDir, "special-chars.txt")
|
||||
const content = "Special chars: @#$%^&*()\nUnicode: 你好世界\nEmoji: 😀🎉"
|
||||
await fs.writeFile(filePath, content)
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
expect(result.content).toBe(content)
|
||||
expect(result.tokenCount).toBeGreaterThan(0)
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
|
||||
test("handles code content", async () => {
|
||||
const filePath = path.join(tempDir, "code.ts")
|
||||
const code = `function hello(name: string): string {\n return \`Hello, \${name}!\`\n}`
|
||||
await fs.writeFile(filePath, code)
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 1000,
|
||||
})
|
||||
|
||||
expect(result.content).toBe(code)
|
||||
expect(result.tokenCount).toBeGreaterThan(0)
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Performance", () => {
|
||||
test("handles large files efficiently", async () => {
|
||||
const filePath = path.join(tempDir, "large-file.txt")
|
||||
// Create a 1MB file
|
||||
const lines = Array.from({ length: 10000 }, (_, i) => `Line ${i + 1} with some additional content`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 100,
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
// Should complete in reasonable time (less than 5 seconds)
|
||||
expect(duration).toBeLessThan(5000)
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
test("early exits when budget is reached", async () => {
|
||||
const filePath = path.join(tempDir, "early-exit.txt")
|
||||
// Create a very large file
|
||||
const lines = Array.from({ length: 50000 }, (_, i) => `Line ${i + 1}`)
|
||||
await fs.writeFile(filePath, lines.join("\n"))
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const result = await readFileWithTokenBudget(filePath, {
|
||||
budgetTokens: 50, // Small budget should trigger early exit
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
|
||||
// Should be much faster than reading entire file (less than 2 seconds)
|
||||
expect(duration).toBeLessThan(2000)
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.lineCount).toBeLessThan(50000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -5,8 +5,8 @@ import mammoth from "mammoth"
|
|||
import fs from "fs/promises"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
import { extractTextFromXLSX } from "./extract-text-from-xlsx"
|
||||
import { countFileLines } from "./line-counter"
|
||||
import { readLines } from "./read-lines"
|
||||
import { readWithSlice } from "./indentation-reader"
|
||||
import { DEFAULT_LINE_LIMIT } from "../../core/prompts/tools/native-tools/read_file"
|
||||
|
||||
async function extractTextFromPDF(filePath: string): Promise<string> {
|
||||
const dataBuffer = await fs.readFile(filePath)
|
||||
|
|
@ -51,26 +51,34 @@ export function getSupportedBinaryFormats(): string[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from a file, with support for various formats including PDF, DOCX, XLSX, and plain text.
|
||||
* For large text files, can limit the number of lines read to prevent context exhaustion.
|
||||
* Result of extracting text with metadata about truncation
|
||||
*/
|
||||
export interface ExtractTextResult {
|
||||
/** The extracted content with line numbers */
|
||||
content: string
|
||||
/** Total lines in the file */
|
||||
totalLines: number
|
||||
/** Lines actually returned */
|
||||
returnedLines: number
|
||||
/** Whether output was truncated */
|
||||
wasTruncated: boolean
|
||||
/** Line range shown [start, end] (1-based) */
|
||||
linesShown?: [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from a file with truncation support.
|
||||
* Returns structured result with metadata about truncation.
|
||||
*
|
||||
* @param filePath - Path to the file to extract text from
|
||||
* @param maxReadFileLine - Maximum number of lines to read from text files.
|
||||
* Use UNLIMITED_LINES (-1) or undefined for no limit.
|
||||
* Must be a positive integer or UNLIMITED_LINES.
|
||||
* @returns Promise resolving to the extracted text content with line numbers
|
||||
* @throws {Error} If file not found, unsupported format, or invalid parameters
|
||||
* @param limit - Maximum lines to return (default: 2000)
|
||||
* @returns Promise resolving to extracted text with metadata
|
||||
* @throws {Error} If file not found or unsupported binary format
|
||||
*/
|
||||
export async function extractTextFromFile(filePath: string, maxReadFileLine?: number): Promise<string> {
|
||||
// Validate maxReadFileLine parameter
|
||||
if (maxReadFileLine !== undefined && maxReadFileLine !== -1) {
|
||||
if (!Number.isInteger(maxReadFileLine) || maxReadFileLine < 1) {
|
||||
throw new Error(
|
||||
`Invalid maxReadFileLine: ${maxReadFileLine}. Must be a positive integer or -1 for unlimited.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractTextFromFileWithMetadata(
|
||||
filePath: string,
|
||||
limit: number = DEFAULT_LINE_LIMIT,
|
||||
): Promise<ExtractTextResult> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch (error) {
|
||||
|
|
@ -82,33 +90,49 @@ export async function extractTextFromFile(filePath: string, maxReadFileLine?: nu
|
|||
// Check if we have a specific extractor for this format
|
||||
const extractor = SUPPORTED_BINARY_FORMATS[fileExtension as keyof typeof SUPPORTED_BINARY_FORMATS]
|
||||
if (extractor) {
|
||||
return extractor(filePath)
|
||||
// For binary formats, extract and count lines
|
||||
const content = await extractor(filePath)
|
||||
const lines = content.split("\n")
|
||||
return {
|
||||
content,
|
||||
totalLines: lines.length,
|
||||
returnedLines: lines.length,
|
||||
wasTruncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other files
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
|
||||
if (!isBinary) {
|
||||
// Check if we need to apply line limit
|
||||
if (maxReadFileLine !== undefined && maxReadFileLine !== -1) {
|
||||
const totalLines = await countFileLines(filePath)
|
||||
if (totalLines > maxReadFileLine) {
|
||||
// Read only up to maxReadFileLine (endLine is 0-based and inclusive)
|
||||
const content = await readLines(filePath, maxReadFileLine - 1, 0)
|
||||
const numberedContent = addLineNumbers(content)
|
||||
return (
|
||||
numberedContent +
|
||||
`\n\n[File truncated: showing ${maxReadFileLine} of ${totalLines} total lines. The file is too large and may exhaust the context window if read in full.]`
|
||||
)
|
||||
}
|
||||
const rawContent = await fs.readFile(filePath, "utf8")
|
||||
const result = readWithSlice(rawContent, 0, limit)
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
totalLines: result.totalLines,
|
||||
returnedLines: result.returnedLines,
|
||||
wasTruncated: result.wasTruncated,
|
||||
linesShown: result.includedRanges.length > 0 ? result.includedRanges[0] : undefined,
|
||||
}
|
||||
// Read the entire file if no limit or file is within limit
|
||||
return addLineNumbers(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
throw new Error(`Cannot read text for file type: ${fileExtension}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from a file, with support for various formats including PDF, DOCX, XLSX, and plain text.
|
||||
* Now uses truncation to limit large files to DEFAULT_LINE_LIMIT lines.
|
||||
*
|
||||
* @param filePath - Path to the file to extract text from
|
||||
* @returns Promise resolving to the extracted text content with line numbers
|
||||
* @throws {Error} If file not found or unsupported binary format
|
||||
*/
|
||||
export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
const result = await extractTextFromFileWithMetadata(filePath)
|
||||
return result.content
|
||||
}
|
||||
|
||||
export function addLineNumbers(content: string, startLine: number = 1): string {
|
||||
// If content is empty, return empty string - empty files should not have line numbers
|
||||
// If content is empty but startLine > 1, return "startLine | " because we know the file is not empty
|
||||
|
|
|
|||
469
src/integrations/misc/indentation-reader.ts
Normal file
469
src/integrations/misc/indentation-reader.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* Indentation-based semantic code block extraction.
|
||||
*
|
||||
* Inspired by Codex's indentation mode, this module extracts meaningful code blocks
|
||||
* based on indentation hierarchy rather than arbitrary line ranges.
|
||||
*
|
||||
* The algorithm uses bidirectional expansion from an anchor line:
|
||||
* 1. Parse the file to determine indentation level of each line
|
||||
* 2. Compute effective indents (blank lines inherit previous non-blank line's indent)
|
||||
* 3. Expand up and down from anchor simultaneously
|
||||
* 4. Apply sibling exclusion counters to limit scope
|
||||
* 5. Trim empty lines from edges
|
||||
* 6. Apply line limit
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_LINE_LIMIT,
|
||||
DEFAULT_MAX_LEVELS,
|
||||
MAX_LINE_LENGTH,
|
||||
} from "../../core/prompts/tools/native-tools/read_file"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LineRecord {
|
||||
/** 1-based line number */
|
||||
lineNumber: number
|
||||
/** Original line content */
|
||||
content: string
|
||||
/** Computed indentation level (number of leading whitespace units) */
|
||||
indentLevel: number
|
||||
/** Whether this line is blank (empty or whitespace only) */
|
||||
isBlank: boolean
|
||||
/** Whether this line starts a new block (has content followed by colon, brace, etc.) */
|
||||
isBlockStart: boolean
|
||||
}
|
||||
|
||||
export interface IndentationReadOptions {
|
||||
/** 1-based anchor line number */
|
||||
anchorLine: number
|
||||
/** Maximum indentation levels to include above anchor (0 = unlimited, default: 0) */
|
||||
maxLevels?: number
|
||||
/** Include sibling blocks at the same indentation level (default: false) */
|
||||
includeSiblings?: boolean
|
||||
/** Include file header content (imports, comments at top) (default: true) */
|
||||
includeHeader?: boolean
|
||||
/** Maximum lines to return from bidirectional expansion (default: 2000) */
|
||||
limit?: number
|
||||
/** Hard cap on lines returned, separate from limit (optional) */
|
||||
maxLines?: number
|
||||
}
|
||||
|
||||
export interface IndentationReadResult {
|
||||
/** The extracted content with line numbers */
|
||||
content: string
|
||||
/** Line ranges that were included [start, end] tuples (1-based) */
|
||||
includedRanges: Array<[number, number]>
|
||||
/** Total lines in the file */
|
||||
totalLines: number
|
||||
/** Lines actually returned */
|
||||
returnedLines: number
|
||||
/** Whether output was truncated due to limit */
|
||||
wasTruncated: boolean
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Indentation unit size (spaces) */
|
||||
const INDENT_SIZE = 4
|
||||
|
||||
/** Tab width for indent measurement (Codex standard) */
|
||||
const TAB_WIDTH = 4
|
||||
|
||||
/** Patterns that indicate a block start */
|
||||
const BLOCK_START_PATTERNS = [
|
||||
/:\s*$/, // Python-style (def foo():)
|
||||
/\{\s*$/, // C-style opening brace
|
||||
/=>\s*\{?\s*$/, // Arrow functions
|
||||
/\bthen\s*$/, // Lua/some languages
|
||||
/\bdo\s*$/, // Ruby, Lua
|
||||
]
|
||||
|
||||
/** Patterns for file header lines (imports, comments, etc.) */
|
||||
const HEADER_PATTERNS = [
|
||||
/^import\s/, // ES6 imports
|
||||
/^from\s.*import/, // Python imports
|
||||
/^const\s.*=\s*require/, // CommonJS requires
|
||||
/^#!/, // Shebang
|
||||
/^\/\*/, // Block comment start
|
||||
/^\*/, // Block comment continuation
|
||||
/^\s*\*\//, // Block comment end
|
||||
/^\/\//, // Line comment
|
||||
/^#(?!include)/, // Python/shell comment (not C #include)
|
||||
/^"""/, // Python docstring
|
||||
/^'''/, // Python docstring
|
||||
/^use\s/, // Rust use
|
||||
/^package\s/, // Go/Java package
|
||||
/^require\s/, // Lua require
|
||||
/^@/, // Decorators (Python, TypeScript)
|
||||
/^"use\s/, // "use strict", "use client"
|
||||
]
|
||||
|
||||
/** Comment prefixes for header detection (Codex standard) */
|
||||
const COMMENT_PREFIXES = ["#", "//", "--", "/*", "*", "'''", '"""']
|
||||
|
||||
// ─── Core Functions ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a file's lines into LineRecord objects with indentation information.
|
||||
*/
|
||||
export function parseLines(content: string): LineRecord[] {
|
||||
const lines = content.split("\n")
|
||||
return lines.map((line, index) => {
|
||||
const trimmed = line.trimStart()
|
||||
const leadingWhitespace = line.length - trimmed.length
|
||||
|
||||
// Calculate indent in spaces (tabs = TAB_WIDTH spaces each)
|
||||
let indentSpaces = 0
|
||||
for (let i = 0; i < leadingWhitespace; i++) {
|
||||
if (line[i] === "\t") {
|
||||
indentSpaces += TAB_WIDTH
|
||||
} else {
|
||||
indentSpaces += 1
|
||||
}
|
||||
}
|
||||
// Convert to indent level (number of INDENT_SIZE units)
|
||||
const indentLevel = Math.floor(indentSpaces / INDENT_SIZE)
|
||||
|
||||
const isBlank = trimmed.length === 0
|
||||
const isBlockStart = !isBlank && BLOCK_START_PATTERNS.some((pattern) => pattern.test(line))
|
||||
|
||||
return {
|
||||
lineNumber: index + 1,
|
||||
content: line,
|
||||
indentLevel,
|
||||
isBlank,
|
||||
isBlockStart,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective indents where blank lines inherit the previous non-blank line's indent.
|
||||
* This matches the Codex algorithm behavior.
|
||||
*/
|
||||
export function computeEffectiveIndents(lines: LineRecord[]): number[] {
|
||||
const effective: number[] = []
|
||||
let previousIndent = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.isBlank) {
|
||||
effective.push(previousIndent)
|
||||
} else {
|
||||
previousIndent = line.indentLevel
|
||||
effective.push(previousIndent)
|
||||
}
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a comment (for include_header behavior).
|
||||
*/
|
||||
function isComment(line: LineRecord): boolean {
|
||||
const trimmed = line.content.trim()
|
||||
return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim empty lines from the front and back of a line array.
|
||||
*/
|
||||
function trimEmptyLines(lines: LineRecord[]): void {
|
||||
// Trim from front
|
||||
while (lines.length > 0 && lines[0].isBlank) {
|
||||
lines.shift()
|
||||
}
|
||||
// Trim from back
|
||||
while (lines.length > 0 && lines[lines.length - 1].isBlank) {
|
||||
lines.pop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the file header (imports, top-level comments, etc.).
|
||||
* Returns the end index of the header section.
|
||||
*/
|
||||
function findHeaderEnd(lines: LineRecord[]): number {
|
||||
let lastHeaderIdx = -1
|
||||
let inBlockComment = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.content.trim()
|
||||
|
||||
// Track block comments
|
||||
if (trimmed.startsWith("/*")) inBlockComment = true
|
||||
if (trimmed.endsWith("*/")) {
|
||||
inBlockComment = false
|
||||
lastHeaderIdx = i
|
||||
continue
|
||||
}
|
||||
if (inBlockComment) {
|
||||
lastHeaderIdx = i
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a header line
|
||||
if (line.isBlank) {
|
||||
// Blank lines are part of header if we haven't seen content yet
|
||||
if (lastHeaderIdx === i - 1) {
|
||||
lastHeaderIdx = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const isHeader = HEADER_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||
if (isHeader) {
|
||||
lastHeaderIdx = i
|
||||
} else if (line.indentLevel === 0) {
|
||||
// Hit first non-header top-level content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lastHeaderIdx
|
||||
}
|
||||
|
||||
/**
|
||||
* Format lines with line numbers, applying truncation to long lines.
|
||||
*/
|
||||
export function formatWithLineNumbers(lines: LineRecord[], maxLineLength: number = MAX_LINE_LENGTH): string {
|
||||
if (lines.length === 0) return ""
|
||||
const maxLineNumWidth = String(lines[lines.length - 1]?.lineNumber || 1).length
|
||||
|
||||
return lines
|
||||
.map((line) => {
|
||||
const lineNum = String(line.lineNumber).padStart(maxLineNumWidth, " ")
|
||||
let content = line.content
|
||||
|
||||
// Truncate long lines
|
||||
if (content.length > maxLineLength) {
|
||||
content = content.substring(0, maxLineLength - 3) + "..."
|
||||
}
|
||||
|
||||
return `${lineNum} | ${content}`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a contiguous array of LineRecords into merged ranges for output.
|
||||
*/
|
||||
function computeIncludedRanges(lines: LineRecord[]): Array<[number, number]> {
|
||||
if (lines.length === 0) return []
|
||||
|
||||
const ranges: Array<[number, number]> = []
|
||||
let rangeStart = lines[0].lineNumber
|
||||
let rangeEnd = lines[0].lineNumber
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const lineNum = lines[i].lineNumber
|
||||
if (lineNum === rangeEnd + 1) {
|
||||
// Contiguous
|
||||
rangeEnd = lineNum
|
||||
} else {
|
||||
// Gap - save current range and start new one
|
||||
ranges.push([rangeStart, rangeEnd])
|
||||
rangeStart = lineNum
|
||||
rangeEnd = lineNum
|
||||
}
|
||||
}
|
||||
// Don't forget the last range
|
||||
ranges.push([rangeStart, rangeEnd])
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// ─── Main Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read a file using indentation-based semantic extraction (Codex algorithm).
|
||||
*
|
||||
* Uses bidirectional expansion from the anchor line with sibling exclusion counters.
|
||||
*
|
||||
* @param content - The file content to process
|
||||
* @param options - Extraction options
|
||||
* @returns The extracted content with metadata
|
||||
*/
|
||||
export function readWithIndentation(content: string, options: IndentationReadOptions): IndentationReadResult {
|
||||
const {
|
||||
anchorLine,
|
||||
maxLevels = DEFAULT_MAX_LEVELS,
|
||||
includeSiblings = false,
|
||||
includeHeader = true,
|
||||
limit = DEFAULT_LINE_LIMIT,
|
||||
maxLines,
|
||||
} = options
|
||||
|
||||
const lines = parseLines(content)
|
||||
const totalLines = lines.length
|
||||
|
||||
// Validate anchor line
|
||||
if (anchorLine < 1 || anchorLine > totalLines) {
|
||||
return {
|
||||
content: `Error: anchor_line ${anchorLine} is out of range (1-${totalLines})`,
|
||||
includedRanges: [],
|
||||
totalLines,
|
||||
returnedLines: 0,
|
||||
wasTruncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
const anchorIdx = anchorLine - 1 // Convert to 0-based
|
||||
const effectiveIndents = computeEffectiveIndents(lines)
|
||||
const anchorIndent = effectiveIndents[anchorIdx]
|
||||
|
||||
// Calculate minimum indent threshold
|
||||
// maxLevels = 0 means unlimited (minIndent = 0)
|
||||
// maxLevels > 0 means limit to that many levels above anchor
|
||||
let minIndent: number
|
||||
if (maxLevels === 0) {
|
||||
minIndent = 0
|
||||
} else {
|
||||
// Each "level" is INDENT_SIZE spaces worth of indentation
|
||||
// We subtract maxLevels from the anchor's indent level
|
||||
minIndent = Math.max(0, anchorIndent - maxLevels)
|
||||
}
|
||||
|
||||
// Calculate final limit (use maxLines as hard cap if provided)
|
||||
const guardLimit = maxLines ?? limit
|
||||
const finalLimit = Math.min(limit, guardLimit, totalLines)
|
||||
|
||||
// Edge case: if limit is 1, just return the anchor line
|
||||
if (finalLimit === 1) {
|
||||
const singleLine = [lines[anchorIdx]]
|
||||
return {
|
||||
content: formatWithLineNumbers(singleLine),
|
||||
includedRanges: [[anchorLine, anchorLine]],
|
||||
totalLines,
|
||||
returnedLines: 1,
|
||||
wasTruncated: totalLines > 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional expansion from anchor (Codex algorithm)
|
||||
const result: LineRecord[] = [lines[anchorIdx]]
|
||||
let i = anchorIdx - 1 // Up cursor
|
||||
let j = anchorIdx + 1 // Down cursor
|
||||
let iMinCount = 0 // Count of min-indent lines seen going up
|
||||
let jMinCount = 0 // Count of min-indent lines seen going down
|
||||
|
||||
while (result.length < finalLimit) {
|
||||
let progressed = false
|
||||
|
||||
// Expand upward
|
||||
if (i >= 0 && effectiveIndents[i] >= minIndent) {
|
||||
result.unshift(lines[i])
|
||||
progressed = true
|
||||
|
||||
// Handle sibling exclusion at min indent
|
||||
if (effectiveIndents[i] === minIndent && !includeSiblings) {
|
||||
const allowHeader = includeHeader && isComment(lines[i])
|
||||
const canTake = allowHeader || iMinCount === 0
|
||||
|
||||
if (canTake) {
|
||||
iMinCount++
|
||||
} else {
|
||||
// Reject this line - remove it and stop expanding up
|
||||
result.shift()
|
||||
progressed = false
|
||||
i = -1 // Stop expanding up
|
||||
}
|
||||
}
|
||||
|
||||
if (i >= 0) i--
|
||||
} else if (i >= 0) {
|
||||
i = -1 // Stop expanding up (hit lower indent)
|
||||
}
|
||||
|
||||
if (result.length >= finalLimit) break
|
||||
|
||||
// Expand downward
|
||||
if (j < lines.length && effectiveIndents[j] >= minIndent) {
|
||||
result.push(lines[j])
|
||||
progressed = true
|
||||
|
||||
// Handle sibling exclusion at min indent
|
||||
if (effectiveIndents[j] === minIndent && !includeSiblings) {
|
||||
if (jMinCount > 0) {
|
||||
// Already saw one min-indent block going down, reject this
|
||||
result.pop()
|
||||
progressed = false
|
||||
j = lines.length // Stop expanding down
|
||||
}
|
||||
jMinCount++
|
||||
}
|
||||
|
||||
if (j < lines.length) j++
|
||||
} else if (j < lines.length) {
|
||||
j = lines.length // Stop expanding down (hit lower indent)
|
||||
}
|
||||
|
||||
if (!progressed) break
|
||||
}
|
||||
|
||||
// Trim leading/trailing empty lines
|
||||
trimEmptyLines(result)
|
||||
|
||||
// Check if we were truncated
|
||||
const wasTruncated = result.length >= finalLimit || i >= 0 || j < lines.length
|
||||
|
||||
// Format output
|
||||
const formattedContent = formatWithLineNumbers(result)
|
||||
|
||||
// Compute included ranges
|
||||
const includedRanges = computeIncludedRanges(result)
|
||||
|
||||
return {
|
||||
content: formattedContent,
|
||||
includedRanges,
|
||||
totalLines,
|
||||
returnedLines: result.length,
|
||||
wasTruncated: wasTruncated && result.length < totalLines,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple slice mode reading - read lines with offset/limit.
|
||||
*
|
||||
* @param content - The file content to process
|
||||
* @param offset - 0-based line offset to start from (default: 0)
|
||||
* @param limit - Maximum lines to return (default: 2000)
|
||||
* @returns The extracted content with metadata
|
||||
*/
|
||||
export function readWithSlice(
|
||||
content: string,
|
||||
offset: number = 0,
|
||||
limit: number = DEFAULT_LINE_LIMIT,
|
||||
): IndentationReadResult {
|
||||
const lines = parseLines(content)
|
||||
const totalLines = lines.length
|
||||
|
||||
// Validate offset
|
||||
if (offset < 0) offset = 0
|
||||
if (offset >= totalLines) {
|
||||
return {
|
||||
content: `Error: offset ${offset} is beyond file end (${totalLines} lines)`,
|
||||
includedRanges: [],
|
||||
totalLines,
|
||||
returnedLines: 0,
|
||||
wasTruncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Slice lines
|
||||
const endIdx = Math.min(offset + limit, totalLines)
|
||||
const selectedLines = lines.slice(offset, endIdx)
|
||||
const wasTruncated = endIdx < totalLines
|
||||
|
||||
// Format output
|
||||
const formattedContent = formatWithLineNumbers(selectedLines)
|
||||
|
||||
return {
|
||||
content: formattedContent,
|
||||
includedRanges: [[offset + 1, endIdx]], // 1-based
|
||||
totalLines,
|
||||
returnedLines: selectedLines.length,
|
||||
wasTruncated,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { createReadStream } from "fs"
|
||||
import fs from "fs/promises"
|
||||
import { createInterface } from "readline"
|
||||
import { countTokens } from "../../utils/countTokens"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
export interface ReadWithBudgetResult {
|
||||
/** The content read up to the token budget */
|
||||
content: string
|
||||
/** Actual token count of returned content */
|
||||
tokenCount: number
|
||||
/** Total lines in the returned content */
|
||||
lineCount: number
|
||||
/** Whether the entire file was read (false if truncated) */
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
export interface ReadWithBudgetOptions {
|
||||
/** Maximum tokens allowed. Required. */
|
||||
budgetTokens: number
|
||||
/** Number of lines to buffer before token counting (default: 256) */
|
||||
chunkLines?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file while incrementally counting tokens, stopping when budget is reached.
|
||||
*
|
||||
* Unlike validateFileTokenBudget + extractTextFromFile, this is a single-pass
|
||||
* operation that returns the actual content up to the token limit.
|
||||
*
|
||||
* @param filePath - Path to the file to read
|
||||
* @param options - Budget and chunking options
|
||||
* @returns Content read, token count, and completion status
|
||||
*/
|
||||
export async function readFileWithTokenBudget(
|
||||
filePath: string,
|
||||
options: ReadWithBudgetOptions,
|
||||
): Promise<ReadWithBudgetResult> {
|
||||
const { budgetTokens, chunkLines = 256 } = options
|
||||
|
||||
// Verify file exists
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let content = ""
|
||||
let lineCount = 0
|
||||
let tokenCount = 0
|
||||
let lineBuffer: string[] = []
|
||||
let complete = true
|
||||
let isProcessing = false
|
||||
let shouldClose = false
|
||||
|
||||
const readStream = createReadStream(filePath)
|
||||
const rl = createInterface({
|
||||
input: readStream,
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
const processBuffer = async (): Promise<boolean> => {
|
||||
if (lineBuffer.length === 0) return true
|
||||
|
||||
const bufferText = lineBuffer.join("\n")
|
||||
const currentBuffer = [...lineBuffer]
|
||||
lineBuffer = []
|
||||
|
||||
// Count tokens for this chunk
|
||||
let chunkTokens: number
|
||||
try {
|
||||
const contentBlocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: bufferText }]
|
||||
chunkTokens = await countTokens(contentBlocks)
|
||||
} catch {
|
||||
// Fallback: conservative estimate (2 chars per token)
|
||||
chunkTokens = Math.ceil(bufferText.length / 2)
|
||||
}
|
||||
|
||||
// Check if adding this chunk would exceed budget
|
||||
if (tokenCount + chunkTokens > budgetTokens) {
|
||||
// Need to find cutoff within this chunk using binary search
|
||||
let low = 0
|
||||
let high = currentBuffer.length
|
||||
let bestFit = 0
|
||||
let bestTokens = 0
|
||||
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high + 1) / 2)
|
||||
const testContent = currentBuffer.slice(0, mid).join("\n")
|
||||
let testTokens: number
|
||||
try {
|
||||
const blocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: testContent }]
|
||||
testTokens = await countTokens(blocks)
|
||||
} catch {
|
||||
testTokens = Math.ceil(testContent.length / 2)
|
||||
}
|
||||
|
||||
if (tokenCount + testTokens <= budgetTokens) {
|
||||
bestFit = mid
|
||||
bestTokens = testTokens
|
||||
low = mid
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
// Add best fit lines
|
||||
if (bestFit > 0) {
|
||||
const fitContent = currentBuffer.slice(0, bestFit).join("\n")
|
||||
content += (content.length > 0 ? "\n" : "") + fitContent
|
||||
tokenCount += bestTokens
|
||||
lineCount += bestFit
|
||||
}
|
||||
complete = false
|
||||
return false
|
||||
}
|
||||
|
||||
// Entire chunk fits - add it all
|
||||
content += (content.length > 0 ? "\n" : "") + bufferText
|
||||
tokenCount += chunkTokens
|
||||
lineCount += currentBuffer.length
|
||||
return true
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
lineBuffer.push(line)
|
||||
|
||||
if (lineBuffer.length >= chunkLines && !isProcessing) {
|
||||
isProcessing = true
|
||||
rl.pause()
|
||||
|
||||
processBuffer()
|
||||
.then((continueReading) => {
|
||||
isProcessing = false
|
||||
if (!continueReading) {
|
||||
shouldClose = true
|
||||
rl.close()
|
||||
readStream.destroy()
|
||||
} else if (!shouldClose) {
|
||||
rl.resume()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
isProcessing = false
|
||||
shouldClose = true
|
||||
rl.close()
|
||||
readStream.destroy()
|
||||
reject(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
rl.on("close", async () => {
|
||||
// Wait for any ongoing processing with timeout
|
||||
const maxWaitTime = 30000 // 30 seconds
|
||||
const startWait = Date.now()
|
||||
while (isProcessing) {
|
||||
if (Date.now() - startWait > maxWaitTime) {
|
||||
reject(new Error("Timeout waiting for buffer processing to complete"))
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
|
||||
// Process remaining buffer
|
||||
if (!shouldClose) {
|
||||
try {
|
||||
await processBuffer()
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ content, tokenCount, lineCount, complete })
|
||||
})
|
||||
|
||||
rl.on("error", reject)
|
||||
readStream.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
ToolProgressStatus,
|
||||
ToolGroup,
|
||||
ToolName,
|
||||
FileEntry,
|
||||
BrowserActionParams,
|
||||
GenerateImageParams,
|
||||
} from "@roo-code/types"
|
||||
|
|
@ -66,7 +65,7 @@ export const toolParamNames = [
|
|||
"todos",
|
||||
"prompt",
|
||||
"image",
|
||||
"files", // Native protocol parameter for read_file
|
||||
// read_file parameters (native protocol)
|
||||
"operations", // search_and_replace parameter for multiple operations
|
||||
"patch", // apply_patch parameter
|
||||
"file_path", // search_replace and edit_file parameter
|
||||
|
|
@ -75,8 +74,18 @@ export const toolParamNames = [
|
|||
"expected_replacements", // edit_file parameter for multiple occurrences
|
||||
"artifact_id", // read_command_output parameter
|
||||
"search", // read_command_output parameter for grep-like search
|
||||
"offset", // read_command_output parameter for pagination
|
||||
"limit", // read_command_output parameter for max bytes to return
|
||||
"offset", // read_command_output and read_file parameter
|
||||
"limit", // read_command_output and read_file parameter
|
||||
// read_file indentation mode parameters
|
||||
"indentation",
|
||||
"anchor_line",
|
||||
"max_levels",
|
||||
"include_siblings",
|
||||
"include_header",
|
||||
"max_lines",
|
||||
// read_file legacy format parameter (backward compatibility)
|
||||
"files",
|
||||
"line_ranges",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
|
@ -87,7 +96,7 @@ export type ToolParamName = (typeof toolParamNames)[number]
|
|||
*/
|
||||
export type NativeToolArgs = {
|
||||
access_mcp_resource: { server_name: string; uri: string }
|
||||
read_file: { files: FileEntry[] }
|
||||
read_file: import("@roo-code/types").ReadFileToolParams
|
||||
read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number }
|
||||
attempt_completion: { result: string }
|
||||
execute_command: { command: string; cwd?: string }
|
||||
|
|
@ -135,6 +144,11 @@ export interface ToolUse<TName extends ToolName = ToolName> {
|
|||
partial: boolean
|
||||
// nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never
|
||||
nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never
|
||||
/**
|
||||
* Flag indicating whether the tool call used a legacy/deprecated format.
|
||||
* Used for telemetry tracking to monitor migration from old formats.
|
||||
*/
|
||||
usedLegacyFormat?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -165,7 +179,23 @@ export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> {
|
|||
|
||||
export interface ReadFileToolUse extends ToolUse<"read_file"> {
|
||||
name: "read_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "args" | "path" | "start_line" | "end_line" | "files">>
|
||||
params: Partial<
|
||||
Pick<
|
||||
Record<ToolParamName, string>,
|
||||
| "args"
|
||||
| "path"
|
||||
| "start_line"
|
||||
| "end_line"
|
||||
| "mode"
|
||||
| "offset"
|
||||
| "limit"
|
||||
| "indentation"
|
||||
| "anchor_line"
|
||||
| "max_levels"
|
||||
| "include_siblings"
|
||||
| "include_header"
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
export interface WriteToFileToolUse extends ToolUse<"write_to_file"> {
|
||||
|
|
|
|||
|
|
@ -86,9 +86,9 @@ describe("normalizeToolSchema", () => {
|
|||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
tags: {
|
||||
type: ["array", "null"],
|
||||
items: { type: "integer" },
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -104,8 +104,8 @@ describe("normalizeToolSchema", () => {
|
|||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }],
|
||||
tags: {
|
||||
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
|
|
@ -123,7 +123,7 @@ describe("normalizeToolSchema", () => {
|
|||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
line_ranges: {
|
||||
ranges: {
|
||||
type: ["array", "null"],
|
||||
items: {
|
||||
type: "array",
|
||||
|
|
@ -131,7 +131,7 @@ describe("normalizeToolSchema", () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
required: ["path", "line_ranges"],
|
||||
required: ["path", "ranges"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -144,7 +144,7 @@ describe("normalizeToolSchema", () => {
|
|||
const filesItems = properties.files.items as Record<string, unknown>
|
||||
const filesItemsProps = filesItems.properties as Record<string, Record<string, unknown>>
|
||||
// Array-specific properties (items) should be moved inside the array variant
|
||||
expect(filesItemsProps.line_ranges.anyOf).toEqual([
|
||||
expect(filesItemsProps.ranges.anyOf).toEqual([
|
||||
{ type: "array", items: { type: "array", items: { type: "integer" } } },
|
||||
{ type: "null" },
|
||||
])
|
||||
|
|
@ -224,60 +224,32 @@ describe("normalizeToolSchema", () => {
|
|||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
description: "List of files to read",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Path to the file",
|
||||
},
|
||||
line_ranges: {
|
||||
type: ["array", "null"],
|
||||
description: "Optional line ranges",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Path to the file",
|
||||
},
|
||||
indentation: {
|
||||
type: ["object", "null"],
|
||||
properties: {
|
||||
anchor_line: {
|
||||
type: ["integer", "null"],
|
||||
},
|
||||
required: ["path", "line_ranges"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ["files"],
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const result = normalizeToolSchema(input)
|
||||
|
||||
// Verify the line_ranges was transformed with items inside the array variant
|
||||
const files = (result.properties as Record<string, unknown>).files as Record<string, unknown>
|
||||
const items = files.items as Record<string, unknown>
|
||||
const props = items.properties as Record<string, Record<string, unknown>>
|
||||
// Array-specific properties (items, minItems, maxItems) should be moved inside the array variant
|
||||
expect(props.line_ranges.anyOf).toEqual([
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
])
|
||||
// items should NOT be at root level anymore
|
||||
expect(props.line_ranges.items).toBeUndefined()
|
||||
// Other properties are preserved at root level
|
||||
expect(props.line_ranges.description).toBe("Optional line ranges")
|
||||
// Verify nested nullable objects are transformed correctly
|
||||
const props = result.properties as Record<string, Record<string, unknown>>
|
||||
expect(props.indentation.anyOf).toEqual([{ type: "object" }, { type: "null" }])
|
||||
expect(props.indentation.additionalProperties).toBe(false)
|
||||
expect((props.indentation.properties as Record<string, unknown>).anchor_line).toEqual({
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
})
|
||||
})
|
||||
|
||||
describe("format field handling", () => {
|
||||
|
|
|
|||
|
|
@ -649,7 +649,13 @@ export const ChatRowContent = ({
|
|||
<ToolUseBlock>
|
||||
<ToolUseBlockHeader
|
||||
className="group"
|
||||
onClick={() => vscode.postMessage({ type: "openFile", text: tool.content })}>
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "openFile",
|
||||
text: tool.content,
|
||||
values: tool.startLine ? { line: tool.startLine } : undefined,
|
||||
})
|
||||
}>
|
||||
{tool.path?.startsWith(".") && <span>.</span>}
|
||||
<PathTooltip content={formatPathTooltip(tool.path, tool.reason)}>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis text-left mr-2 rtl">
|
||||
|
|
|
|||
|
|
@ -1136,7 +1136,76 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
const groupedMessages = useMemo(() => {
|
||||
// Only filter out the launch ask and result messages - browser actions appear in chat
|
||||
const result: ClineMessage[] = visibleMessages.filter((msg) => !isBrowserSessionMessage(msg))
|
||||
const filtered: ClineMessage[] = visibleMessages.filter((msg) => !isBrowserSessionMessage(msg))
|
||||
|
||||
// Helper to check if a message is a read_file ask that should be batched
|
||||
const isReadFileAsk = (msg: ClineMessage): boolean => {
|
||||
if (msg.type !== "ask" || msg.ask !== "tool") return false
|
||||
try {
|
||||
const tool = JSON.parse(msg.text || "{}")
|
||||
return tool.tool === "readFile" && !tool.batchFiles // Don't re-batch already batched
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Consolidate consecutive read_file ask messages into batches
|
||||
const result: ClineMessage[] = []
|
||||
let i = 0
|
||||
while (i < filtered.length) {
|
||||
const msg = filtered[i]
|
||||
|
||||
// Check if this starts a sequence of read_file asks
|
||||
if (isReadFileAsk(msg)) {
|
||||
// Collect all consecutive read_file asks
|
||||
const batch: ClineMessage[] = [msg]
|
||||
let j = i + 1
|
||||
while (j < filtered.length && isReadFileAsk(filtered[j])) {
|
||||
batch.push(filtered[j])
|
||||
j++
|
||||
}
|
||||
|
||||
if (batch.length > 1) {
|
||||
// Create a synthetic batch message
|
||||
const batchFiles = batch.map((batchMsg) => {
|
||||
try {
|
||||
const tool = JSON.parse(batchMsg.text || "{}")
|
||||
return {
|
||||
path: tool.path || "",
|
||||
lineSnippet: tool.reason || "",
|
||||
isOutsideWorkspace: tool.isOutsideWorkspace || false,
|
||||
key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`,
|
||||
content: tool.content || "",
|
||||
}
|
||||
} catch {
|
||||
return { path: "", lineSnippet: "", key: "", content: "" }
|
||||
}
|
||||
})
|
||||
|
||||
// Use the first message as the base, but add batchFiles
|
||||
const firstTool = JSON.parse(msg.text || "{}")
|
||||
const syntheticMessage: ClineMessage = {
|
||||
...msg,
|
||||
text: JSON.stringify({
|
||||
...firstTool,
|
||||
batchFiles,
|
||||
}),
|
||||
// Store original messages for response handling
|
||||
_batchedMessages: batch,
|
||||
} as ClineMessage & { _batchedMessages: ClineMessage[] }
|
||||
|
||||
result.push(syntheticMessage)
|
||||
i = j // Skip past all batched messages
|
||||
} else {
|
||||
// Single read_file ask, keep as-is
|
||||
result.push(msg)
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
result.push(msg)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (isCondensing) {
|
||||
result.push({
|
||||
|
|
|
|||
|
|
@ -33,10 +33,8 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
maxWorkspaceFiles: number
|
||||
showRooIgnoredFiles?: boolean
|
||||
enableSubfolderRules?: boolean
|
||||
maxReadFileLine?: number
|
||||
maxImageFileSize?: number
|
||||
maxTotalImageSize?: number
|
||||
maxConcurrentFileReads?: number
|
||||
profileThresholds?: Record<string, number>
|
||||
includeDiagnosticMessages?: boolean
|
||||
maxDiagnosticMessages?: number
|
||||
|
|
@ -53,10 +51,8 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
| "maxWorkspaceFiles"
|
||||
| "showRooIgnoredFiles"
|
||||
| "enableSubfolderRules"
|
||||
| "maxReadFileLine"
|
||||
| "maxImageFileSize"
|
||||
| "maxTotalImageSize"
|
||||
| "maxConcurrentFileReads"
|
||||
| "profileThresholds"
|
||||
| "includeDiagnosticMessages"
|
||||
| "maxDiagnosticMessages"
|
||||
|
|
@ -76,10 +72,8 @@ export const ContextManagementSettings = ({
|
|||
showRooIgnoredFiles,
|
||||
enableSubfolderRules,
|
||||
setCachedStateField,
|
||||
maxReadFileLine,
|
||||
maxImageFileSize,
|
||||
maxTotalImageSize,
|
||||
maxConcurrentFileReads,
|
||||
profileThresholds = {},
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
|
|
@ -218,29 +212,6 @@ export const ContextManagementSettings = ({
|
|||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
settingId="context-max-concurrent-file-reads"
|
||||
section="contextManagement"
|
||||
label={t("settings:contextManagement.maxConcurrentFileReads.label")}>
|
||||
<span className="block font-medium mb-1">
|
||||
{t("settings:contextManagement.maxConcurrentFileReads.label")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[Math.max(1, maxConcurrentFileReads ?? 5)]}
|
||||
onValueChange={([value]) => setCachedStateField("maxConcurrentFileReads", value)}
|
||||
data-testid="max-concurrent-file-reads-slider"
|
||||
/>
|
||||
<span className="w-10 text-sm">{Math.max(1, maxConcurrentFileReads ?? 5)}</span>
|
||||
</div>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
|
||||
{t("settings:contextManagement.maxConcurrentFileReads.description")}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
settingId="context-show-rooignored-files"
|
||||
section="contextManagement"
|
||||
|
|
@ -275,45 +246,6 @@ export const ContextManagementSettings = ({
|
|||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
settingId="context-max-read-file"
|
||||
section="contextManagement"
|
||||
label={t("settings:contextManagement.maxReadFile.label")}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-medium">{t("settings:contextManagement.maxReadFile.label")}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="number"
|
||||
pattern="-?[0-9]*"
|
||||
className="w-24 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border px-2 py-1 rounded text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50"
|
||||
value={maxReadFileLine ?? -1}
|
||||
min={-1}
|
||||
onChange={(e) => {
|
||||
const newValue = parseInt(e.target.value, 10)
|
||||
if (!isNaN(newValue) && newValue >= -1) {
|
||||
setCachedStateField("maxReadFileLine", newValue)
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
data-testid="max-read-file-line-input"
|
||||
disabled={maxReadFileLine === -1}
|
||||
/>
|
||||
<span>{t("settings:contextManagement.maxReadFile.lines")}</span>
|
||||
<VSCodeCheckbox
|
||||
checked={maxReadFileLine === -1}
|
||||
onChange={(e: any) =>
|
||||
setCachedStateField("maxReadFileLine", e.target.checked ? -1 : 500)
|
||||
}
|
||||
data-testid="max-read-file-always-full-checkbox">
|
||||
{t("settings:contextManagement.maxReadFile.always_full_read")}
|
||||
</VSCodeCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-2">
|
||||
{t("settings:contextManagement.maxReadFile.description")}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
settingId="context-max-image-file-size"
|
||||
section="contextManagement"
|
||||
|
|
|
|||
|
|
@ -195,10 +195,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
showRooIgnoredFiles,
|
||||
enableSubfolderRules,
|
||||
remoteBrowserEnabled,
|
||||
maxReadFileLine,
|
||||
maxImageFileSize,
|
||||
maxTotalImageSize,
|
||||
maxConcurrentFileReads,
|
||||
customSupportPrompts,
|
||||
profileThresholds,
|
||||
alwaysAllowFollowupQuestions,
|
||||
|
|
@ -406,10 +404,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500),
|
||||
showRooIgnoredFiles: showRooIgnoredFiles ?? true,
|
||||
enableSubfolderRules: enableSubfolderRules ?? false,
|
||||
maxReadFileLine: maxReadFileLine ?? -1,
|
||||
maxImageFileSize: maxImageFileSize ?? 5,
|
||||
maxTotalImageSize: maxTotalImageSize ?? 20,
|
||||
maxConcurrentFileReads: cachedState.maxConcurrentFileReads ?? 5,
|
||||
includeDiagnosticMessages:
|
||||
includeDiagnosticMessages !== undefined ? includeDiagnosticMessages : true,
|
||||
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
|
||||
|
|
@ -855,10 +851,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
maxWorkspaceFiles={maxWorkspaceFiles ?? 200}
|
||||
showRooIgnoredFiles={showRooIgnoredFiles}
|
||||
enableSubfolderRules={enableSubfolderRules}
|
||||
maxReadFileLine={maxReadFileLine}
|
||||
maxImageFileSize={maxImageFileSize}
|
||||
maxTotalImageSize={maxTotalImageSize}
|
||||
maxConcurrentFileReads={maxConcurrentFileReads}
|
||||
profileThresholds={profileThresholds}
|
||||
includeDiagnosticMessages={includeDiagnosticMessages}
|
||||
maxDiagnosticMessages={maxDiagnosticMessages}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,6 @@ describe("ContextManagementSettings", () => {
|
|||
maxOpenTabsContext: 20,
|
||||
maxWorkspaceFiles: 200,
|
||||
showRooIgnoredFiles: false,
|
||||
maxReadFileLine: -1,
|
||||
maxConcurrentFileReads: 5,
|
||||
profileThresholds: {},
|
||||
includeDiagnosticMessages: true,
|
||||
maxDiagnosticMessages: 50,
|
||||
|
|
@ -199,7 +197,6 @@ describe("ContextManagementSettings", () => {
|
|||
// Check for other sliders
|
||||
expect(screen.getByTestId("open-tabs-limit-slider")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("workspace-files-limit-slider")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("max-concurrent-file-reads-slider")).toBeInTheDocument()
|
||||
|
||||
// Check for checkboxes
|
||||
expect(screen.getByTestId("show-rooignored-files-checkbox")).toBeInTheDocument()
|
||||
|
|
@ -320,50 +317,6 @@ describe("ContextManagementSettings", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("renders max read file line controls", () => {
|
||||
const propsWithMaxReadFileLine = {
|
||||
...defaultProps,
|
||||
maxReadFileLine: 500,
|
||||
}
|
||||
render(<ContextManagementSettings {...propsWithMaxReadFileLine} />)
|
||||
|
||||
// Max read file line input
|
||||
const maxReadFileInput = screen.getByTestId("max-read-file-line-input")
|
||||
expect(maxReadFileInput).toBeInTheDocument()
|
||||
expect(maxReadFileInput).toHaveValue(500)
|
||||
|
||||
// Always full read checkbox
|
||||
const alwaysFullReadCheckbox = screen.getByTestId("max-read-file-always-full-checkbox")
|
||||
expect(alwaysFullReadCheckbox).toBeInTheDocument()
|
||||
expect(alwaysFullReadCheckbox).not.toBeChecked()
|
||||
})
|
||||
|
||||
it("updates max read file line setting", () => {
|
||||
const propsWithMaxReadFileLine = {
|
||||
...defaultProps,
|
||||
maxReadFileLine: 500,
|
||||
}
|
||||
render(<ContextManagementSettings {...propsWithMaxReadFileLine} />)
|
||||
|
||||
const input = screen.getByTestId("max-read-file-line-input")
|
||||
fireEvent.change(input, { target: { value: "1000" } })
|
||||
|
||||
expect(defaultProps.setCachedStateField).toHaveBeenCalledWith("maxReadFileLine", 1000)
|
||||
})
|
||||
|
||||
it("toggles always full read setting", () => {
|
||||
const propsWithMaxReadFileLine = {
|
||||
...defaultProps,
|
||||
maxReadFileLine: 500,
|
||||
}
|
||||
render(<ContextManagementSettings {...propsWithMaxReadFileLine} />)
|
||||
|
||||
const checkbox = screen.getByTestId("max-read-file-always-full-checkbox")
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(defaultProps.setCachedStateField).toHaveBeenCalledWith("maxReadFileLine", -1)
|
||||
})
|
||||
|
||||
it("renders with autoCondenseContext enabled", () => {
|
||||
const propsWithAutoCondense = {
|
||||
...defaultProps,
|
||||
|
|
@ -440,18 +393,6 @@ describe("ContextManagementSettings", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("renders max read file line controls with -1 value", () => {
|
||||
const propsWithMaxReadFileLine = {
|
||||
...defaultProps,
|
||||
maxReadFileLine: -1,
|
||||
}
|
||||
render(<ContextManagementSettings {...propsWithMaxReadFileLine} />)
|
||||
|
||||
const checkbox = screen.getByTestId("max-read-file-always-full-checkbox")
|
||||
const input = checkbox.querySelector('input[type="checkbox"]')
|
||||
expect(input).toBeChecked()
|
||||
})
|
||||
|
||||
it("handles boundary values for sliders", () => {
|
||||
const mockSetCachedStateField = vitest.fn()
|
||||
const props = {
|
||||
|
|
@ -478,7 +419,6 @@ describe("ContextManagementSettings", () => {
|
|||
const propsWithUndefined = {
|
||||
...defaultProps,
|
||||
showRooIgnoredFiles: undefined,
|
||||
maxReadFileLine: undefined,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
|
|
@ -501,24 +441,6 @@ describe("ContextManagementSettings", () => {
|
|||
// When auto condense is false, threshold slider should not be visible
|
||||
expect(screen.queryByTestId("condense-threshold-slider")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders max read file controls with default value when maxReadFileLine is undefined", () => {
|
||||
const propsWithoutMaxReadFile = {
|
||||
...defaultProps,
|
||||
maxReadFileLine: undefined,
|
||||
}
|
||||
render(<ContextManagementSettings {...propsWithoutMaxReadFile} />)
|
||||
|
||||
// Controls should still be rendered with default value of -1
|
||||
const input = screen.getByTestId("max-read-file-line-input")
|
||||
const checkbox = screen.getByTestId("max-read-file-always-full-checkbox")
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue(-1)
|
||||
expect(input).not.toBeDisabled() // Input is not disabled when maxReadFileLine is undefined (only when explicitly set to -1)
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
expect(checkbox).not.toBeChecked() // Checkbox is not checked when maxReadFileLine is undefined (only when explicitly set to -1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Accessibility", () => {
|
||||
|
|
@ -537,17 +459,11 @@ describe("ContextManagementSettings", () => {
|
|||
})
|
||||
|
||||
it("has proper test ids for all interactive elements", () => {
|
||||
const propsWithMaxReadFile = {
|
||||
...defaultProps,
|
||||
maxReadFileLine: 500,
|
||||
}
|
||||
render(<ContextManagementSettings {...propsWithMaxReadFile} />)
|
||||
render(<ContextManagementSettings {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId("open-tabs-limit-slider")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("workspace-files-limit-slider")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("show-rooignored-files-checkbox")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("max-read-file-line-input")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("max-read-file-always-full-checkbox")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,6 @@ describe("SettingsView - Change Detection Fix", () => {
|
|||
maxReadFileLine: -1,
|
||||
maxImageFileSize: 5,
|
||||
maxTotalImageSize: 20,
|
||||
maxConcurrentFileReads: 5,
|
||||
customCondensingPrompt: "",
|
||||
customSupportPrompts: {},
|
||||
profileThresholds: {},
|
||||
|
|
|
|||
|
|
@ -223,7 +223,6 @@ describe("SettingsView - Unsaved Changes Detection", () => {
|
|||
maxReadFileLine: -1,
|
||||
maxImageFileSize: 5,
|
||||
maxTotalImageSize: 20,
|
||||
maxConcurrentFileReads: 5,
|
||||
customCondensingPrompt: "",
|
||||
customSupportPrompts: {},
|
||||
profileThresholds: {},
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
cloudOrganizations?: CloudOrganizationMembership[]
|
||||
sharingEnabled: boolean
|
||||
publicSharingEnabled: boolean
|
||||
maxConcurrentFileReads?: number
|
||||
mdmCompliant?: boolean
|
||||
hasOpenedModeSelector: boolean // New property to track if user has opened mode selector
|
||||
setHasOpenedModeSelector: (value: boolean) => void // Setter for the new property
|
||||
|
|
@ -128,8 +127,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setRemoteBrowserEnabled: (value: boolean) => void
|
||||
awsUsePromptCache?: boolean
|
||||
setAwsUsePromptCache: (value: boolean) => void
|
||||
maxReadFileLine: number
|
||||
setMaxReadFileLine: (value: number) => void
|
||||
maxImageFileSize: number
|
||||
setMaxImageFileSize: (value: number) => void
|
||||
maxTotalImageSize: number
|
||||
|
|
@ -232,12 +229,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
|
||||
enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories
|
||||
renderContext: "sidebar",
|
||||
maxReadFileLine: -1, // Default max read file line limit
|
||||
maxImageFileSize: 5, // Default max image file size in MB
|
||||
maxTotalImageSize: 20, // Default max total image size in MB
|
||||
pinnedApiConfigs: {}, // Empty object for pinned API configs
|
||||
terminalZshOhMy: false, // Default Oh My Zsh integration setting
|
||||
maxConcurrentFileReads: 5, // Default concurrent file reads
|
||||
terminalZshP10k: false, // Default Powerlevel10k integration setting
|
||||
terminalZdotdir: false, // Default ZDOTDIR handling setting
|
||||
historyPreviewCollapsed: false, // Initialize the new state (default to expanded)
|
||||
|
|
@ -571,7 +566,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setEnableSubfolderRules: (value) => setState((prevState) => ({ ...prevState, enableSubfolderRules: value })),
|
||||
setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })),
|
||||
setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })),
|
||||
setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })),
|
||||
setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })),
|
||||
setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })),
|
||||
setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })),
|
||||
|
|
|
|||
|
|
@ -202,7 +202,6 @@ describe("mergeExtensionState", () => {
|
|||
showRooIgnoredFiles: true,
|
||||
enableSubfolderRules: false,
|
||||
renderContext: "sidebar",
|
||||
maxReadFileLine: 500,
|
||||
cloudUserInfo: null,
|
||||
organizationAllowList: { allowAll: true, providers: {} },
|
||||
autoCondenseContext: true,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function formatPathTooltip(path?: string, additionalContent?: string): st
|
|||
const formattedPath = removeLeadingNonAlphanumeric(path) + "\u200E"
|
||||
|
||||
if (additionalContent) {
|
||||
return formattedPath + additionalContent
|
||||
return formattedPath + " " + additionalContent
|
||||
}
|
||||
|
||||
return formattedPath
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue