Skip to content

Commit 6029495

Browse files
committed
feat: add backward compatibility for legacy read_file format
- Restore convertFileEntries() function in NativeToolCallParser - Detect and handle both old { files: [...] } and new { path, mode, ... } formats - Handle double-stringified JSON from models (files as string vs array) - Add executeLegacy() method in ReadFileTool for multi-file batch processing - Add READ_FILE_LEGACY_FORMAT_USED telemetry event - Add console.warn indicator when legacy format is detected (for testing) - Add 7 new tests for backward compatibility scenarios - Add legacy param names (files, line_ranges) to toolParamNames
1 parent e75e047 commit 6029495

File tree

7 files changed

+558
-10
lines changed

7 files changed

+558
-10
lines changed

packages/types/src/telemetry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export enum TelemetryEventName {
7373
CODE_INDEX_ERROR = "Code Index Error",
7474
TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed",
7575
MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response",
76+
READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used",
7677
}
7778

7879
/**
@@ -203,6 +204,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
203204
TelemetryEventName.TAB_SHOWN,
204205
TelemetryEventName.MODE_SETTINGS_CHANGED,
205206
TelemetryEventName.CUSTOM_MODE_CREATED,
207+
TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED,
206208
]),
207209
properties: telemetryPropertiesSchema,
208210
}),

packages/types/src/tool-params.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface IndentationParams {
2626
}
2727

2828
/**
29-
* Parameters for the read_file tool.
29+
* Parameters for the read_file tool (new format).
3030
*
3131
* NOTE: This is the canonical, single-file-per-call shape.
3232
*/
@@ -43,6 +43,55 @@ export interface ReadFileParams {
4343
indentation?: IndentationParams
4444
}
4545

46+
// ─── Legacy Format Types (Backward Compatibility) ─────────────────────────────
47+
48+
/**
49+
* Line range specification for legacy read_file format.
50+
* Represents a contiguous range of lines [start, end] (1-based, inclusive).
51+
*/
52+
export interface LineRange {
53+
start: number
54+
end: number
55+
}
56+
57+
/**
58+
* File entry for legacy read_file format.
59+
* Supports reading multiple disjoint line ranges from a single file.
60+
*/
61+
export interface FileEntry {
62+
/** Path to the file, relative to workspace */
63+
path: string
64+
/** Optional list of line ranges to read (if omitted, reads entire file) */
65+
lineRanges?: LineRange[]
66+
}
67+
68+
/**
69+
* Legacy parameters for the read_file tool (pre-refactor format).
70+
* Supports reading multiple files in a single call with optional line ranges.
71+
*
72+
* @deprecated Use ReadFileParams instead. This format is maintained for
73+
* backward compatibility with existing chat histories.
74+
*/
75+
export interface LegacyReadFileParams {
76+
/** Array of file entries to read */
77+
files: FileEntry[]
78+
/** Discriminant flag for type narrowing */
79+
_legacyFormat: true
80+
}
81+
82+
/**
83+
* Union type for read_file tool parameters.
84+
* Supports both new single-file format and legacy multi-file format.
85+
*/
86+
export type ReadFileToolParams = ReadFileParams | LegacyReadFileParams
87+
88+
/**
89+
* Type guard to check if params are in legacy format.
90+
*/
91+
export function isLegacyReadFileParams(params: ReadFileToolParams): params is LegacyReadFileParams {
92+
return "_legacyFormat" in params && params._legacyFormat === true
93+
}
94+
4695
export interface Coordinate {
4796
x: number
4897
y: number

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { parseJSON } from "partial-json"
22

3-
import { type ToolName, toolNames } from "@roo-code/types"
3+
import { type ToolName, toolNames, type FileEntry } from "@roo-code/types"
44
import { customToolRegistry } from "@roo-code/core"
55

66
import {
@@ -326,6 +326,47 @@ export class NativeToolCallParser {
326326
return undefined
327327
}
328328

329+
/**
330+
* Convert raw file entries from API (with line_ranges) to FileEntry objects
331+
* (with lineRanges). Handles multiple formats for backward compatibility:
332+
*
333+
* New tuple format: { path: string, line_ranges: [[1, 50], [100, 150]] }
334+
* Object format: { path: string, line_ranges: [{ start: 1, end: 50 }] }
335+
* Legacy string format: { path: string, line_ranges: ["1-50"] }
336+
*
337+
* Returns: { path: string, lineRanges: [{ start: 1, end: 50 }] }
338+
*/
339+
private static convertFileEntries(files: unknown[]): FileEntry[] {
340+
return files.map((file: unknown) => {
341+
const f = file as Record<string, unknown>
342+
const entry: FileEntry = { path: f.path as string }
343+
if (f.line_ranges && Array.isArray(f.line_ranges)) {
344+
entry.lineRanges = (f.line_ranges as unknown[])
345+
.map((range: unknown) => {
346+
// Handle tuple format: [start, end]
347+
if (Array.isArray(range) && range.length >= 2) {
348+
return { start: Number(range[0]), end: Number(range[1]) }
349+
}
350+
// Handle object format: { start: number, end: number }
351+
if (typeof range === "object" && range !== null && "start" in range && "end" in range) {
352+
const r = range as { start: unknown; end: unknown }
353+
return { start: Number(r.start), end: Number(r.end) }
354+
}
355+
// Handle legacy string format: "1-50"
356+
if (typeof range === "string") {
357+
const match = range.match(/^(\d+)-(\d+)$/)
358+
if (match) {
359+
return { start: parseInt(match[1], 10), end: parseInt(match[2], 10) }
360+
}
361+
}
362+
return null
363+
})
364+
.filter((r): r is { start: number; end: number } => r !== null)
365+
}
366+
return entry
367+
})
368+
}
369+
329370
/**
330371
* Create a partial ToolUse from currently parsed arguments.
331372
* Used during streaming to show progress.
@@ -352,9 +393,40 @@ export class NativeToolCallParser {
352393
// Build partial nativeArgs based on what we have so far
353394
let nativeArgs: any = undefined
354395

396+
// Track if legacy format was used (for telemetry)
397+
let usedLegacyFormat = false
398+
355399
switch (name) {
356400
case "read_file":
357-
if (partialArgs.path !== undefined) {
401+
// Check for legacy format first: { files: [...] }
402+
// Handle both array and stringified array (some models double-stringify)
403+
if (partialArgs.files !== undefined) {
404+
let filesArray: unknown[] | null = null
405+
406+
if (Array.isArray(partialArgs.files)) {
407+
filesArray = partialArgs.files
408+
} else if (typeof partialArgs.files === "string") {
409+
// Handle double-stringified case: files is a string containing JSON array
410+
try {
411+
const parsed = JSON.parse(partialArgs.files)
412+
if (Array.isArray(parsed)) {
413+
filesArray = parsed
414+
}
415+
} catch {
416+
// Not valid JSON, ignore
417+
}
418+
}
419+
420+
if (filesArray && filesArray.length > 0) {
421+
usedLegacyFormat = true
422+
nativeArgs = {
423+
files: this.convertFileEntries(filesArray),
424+
_legacyFormat: true as const,
425+
}
426+
}
427+
}
428+
// New format: { path: "...", mode: "..." }
429+
if (!nativeArgs && partialArgs.path !== undefined) {
358430
nativeArgs = {
359431
path: partialArgs.path,
360432
mode: partialArgs.mode,
@@ -589,6 +661,11 @@ export class NativeToolCallParser {
589661
result.originalName = originalName
590662
}
591663

664+
// Track legacy format usage for telemetry
665+
if (usedLegacyFormat) {
666+
result.usedLegacyFormat = true
667+
}
668+
592669
return result
593670
}
594671

@@ -652,9 +729,40 @@ export class NativeToolCallParser {
652729
// nativeArgs object. If validation fails, we treat the tool call as invalid and fail fast.
653730
let nativeArgs: NativeArgsFor<TName> | undefined = undefined
654731

732+
// Track if legacy format was used (for telemetry)
733+
let usedLegacyFormat = false
734+
655735
switch (resolvedName) {
656736
case "read_file":
657-
if (args.path !== undefined) {
737+
// Check for legacy format first: { files: [...] }
738+
// Handle both array and stringified array (some models double-stringify)
739+
if (args.files !== undefined) {
740+
let filesArray: unknown[] | null = null
741+
742+
if (Array.isArray(args.files)) {
743+
filesArray = args.files
744+
} else if (typeof args.files === "string") {
745+
// Handle double-stringified case: files is a string containing JSON array
746+
try {
747+
const parsed = JSON.parse(args.files)
748+
if (Array.isArray(parsed)) {
749+
filesArray = parsed
750+
}
751+
} catch {
752+
// Not valid JSON, ignore
753+
}
754+
}
755+
756+
if (filesArray && filesArray.length > 0) {
757+
usedLegacyFormat = true
758+
nativeArgs = {
759+
files: this.convertFileEntries(filesArray),
760+
_legacyFormat: true as const,
761+
} as NativeArgsFor<TName>
762+
}
763+
}
764+
// New format: { path: "...", mode: "..." }
765+
if (!nativeArgs && args.path !== undefined) {
658766
nativeArgs = {
659767
path: args.path,
660768
mode: args.mode,
@@ -921,6 +1029,11 @@ export class NativeToolCallParser {
9211029
result.originalName = toolCall.name
9221030
}
9231031

1032+
// Track legacy format usage for telemetry
1033+
if (usedLegacyFormat) {
1034+
result.usedLegacyFormat = true
1035+
}
1036+
9241037
return result
9251038
} catch (error) {
9261039
console.error(

0 commit comments

Comments
 (0)