@@ -2,44 +2,67 @@ import { readFile, writeFile } from "node:fs/promises";
22import { hasCode } from "@intx/types" ;
33import type { ToolDefinition } from "@intx/types/runtime" ;
44
5- export type EditFileSubstringMode = {
5+ export interface EditFileSubstringMode {
66 kind : "substring" ;
77 path : string ;
88 old_string : string ;
99 new_string : string ;
1010 replace_all : boolean ;
11- } ;
11+ }
1212
13- export type EditFileLineRangeMode = {
13+ export interface EditFileLineRangeMode {
1414 kind : "line_range" ;
1515 path : string ;
1616 start_line : number ;
1717 end_line : number ;
1818 new_string : string ;
19- } ;
19+ }
2020
2121export type EditFileModeParse =
22- | EditFileSubstringMode
23- | EditFileLineRangeMode
24- | { kind : "invalid" ; message : string } ;
22+ EditFileSubstringMode | EditFileLineRangeMode | { kind : "invalid" ; message : string } ;
2523
2624function optionalInt ( value : unknown ) : number | undefined {
2725 return typeof value === "number" && Number . isFinite ( value ) && Number . isInteger ( value )
2826 ? value
2927 : undefined ;
3028}
3129
30+ // Models pad the unused mode's fields with fillers (old_string: "", start_line: 0).
31+ // For mode selection a filler counts as absent: "" is never a valid old_string and
32+ // 0/null/non-integers are never valid 1-based lines. Treating them as present made
33+ // filler-padded calls look like "both edit modes" and rejected them (CL-6900).
3234function hasOldStringArg ( args : Record < string , unknown > ) : boolean {
33- return typeof args . old_string === "string" ;
35+ return typeof args . old_string === "string" && args . old_string . length > 0 ;
36+ }
37+
38+ function validLineNumber ( value : unknown ) : number | undefined {
39+ const n = optionalInt ( value ) ;
40+ return n !== undefined && n >= 1 ? n : undefined ;
3441}
3542
3643function hasLineRangeArgs ( args : Record < string , unknown > ) : boolean {
37- return optionalInt ( args . start_line ) !== undefined || optionalInt ( args . end_line ) !== undefined ;
44+ return (
45+ validLineNumber ( args . start_line ) !== undefined || validLineNumber ( args . end_line ) !== undefined
46+ ) ;
3847}
3948
40- const MIXED_MODE_MESSAGE =
41- "edit_file: received both old_string and start_line/end_line; only one edit mode is allowed. " +
42- "Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode." ;
49+ function describeReceived ( args : Record < string , unknown > ) : string {
50+ const old = args . old_string ;
51+ return [
52+ typeof old === "string" ? `old_string (len ${ old . length } )` : "no old_string" ,
53+ args . start_line === undefined
54+ ? "no start_line"
55+ : `start_line=${ JSON . stringify ( args . start_line ) } ` ,
56+ args . end_line === undefined ? "no end_line" : `end_line=${ JSON . stringify ( args . end_line ) } ` ,
57+ ] . join ( ", " ) ;
58+ }
59+
60+ function mixedModeMessage ( args : Record < string , unknown > ) : string {
61+ return (
62+ `edit_file: received ${ describeReceived ( args ) } ; only one edit mode is allowed. ` +
63+ "Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode."
64+ ) ;
65+ }
4366
4467export function parseLineRangeFields (
4568 path : string ,
@@ -51,14 +74,18 @@ export function parseLineRangeFields(
5174 if ( start_line === undefined || end_line === undefined ) {
5275 return {
5376 kind : "invalid" ,
54- message : "edit_file line-range mode requires both start_line and end_line (1-based inclusive)" ,
77+ message :
78+ "edit_file line-range mode requires both start_line and end_line (1-based inclusive)" ,
5579 } ;
5680 }
5781 if ( start_line < 1 || end_line < 1 ) {
5882 return { kind : "invalid" , message : "start_line and end_line must be >= 1" } ;
5983 }
6084 if ( start_line > end_line ) {
61- return { kind : "invalid" , message : `start_line (${ start_line } ) must be <= end_line (${ end_line } )` } ;
85+ return {
86+ kind : "invalid" ,
87+ message : `start_line (${ start_line } ) must be <= end_line (${ end_line } )` ,
88+ } ;
6289 }
6390 return { kind : "line_range" , path, start_line, end_line, new_string } ;
6491}
@@ -86,51 +113,48 @@ export function parseEditFileMode(args: Record<string, unknown>): EditFileModePa
86113 // slice, producing a confusing "old_string does not match" error even when the caller
87114 // meant plain substring mode (CL-4399). One explicit error beats a wrong guess.
88115 if ( substring && lineRange ) {
89- return { kind : "invalid" , message : MIXED_MODE_MESSAGE } ;
116+ return { kind : "invalid" , message : mixedModeMessage ( args ) } ;
90117 }
91118
92119 if ( lineRange ) {
93120 return parseLineRangeFields ( path , new_string , args ) ;
94121 }
95122
96123 if ( ! substring ) {
124+ const emptyOldString = typeof args . old_string === "string" ;
97125 return {
98126 kind : "invalid" ,
99- message :
100- 'edit_file requires old_string (substring mode) or start_line and end_line (line-range mode)' ,
127+ message : emptyOldString
128+ ? "edit_file: old_string is empty; provide the exact text to replace (substring mode), " +
129+ "or omit it and send start_line/end_line >= 1 (line-range mode). " +
130+ `Received ${ describeReceived ( args ) } .`
131+ : "edit_file requires old_string (substring mode) or start_line and end_line (line-range mode); " +
132+ `received ${ describeReceived ( args ) } ` ,
101133 } ;
102134 }
103135
104- const old_string = String ( args . old_string ) ;
105- if ( old_string . length === 0 ) {
106- return { kind : "invalid" , message : "old_string must not be empty" } ;
107- }
108-
109136 return {
110137 kind : "substring" ,
111138 path,
112- old_string,
139+ old_string : String ( args . old_string ) ,
113140 new_string,
114141 replace_all : Boolean ( args . replace_all ) ,
115142 } ;
116143}
117144
118- export type SplitFileLines = {
145+ export interface SplitFileLines {
119146 lines : string [ ] ;
120147 newline : "\n" | "\r\n" ;
121148 /** File ended with a newline before the edit. */
122149 trailingNewline : boolean ;
123- } ;
150+ }
124151
125152export function splitFileLines ( content : string ) : SplitFileLines {
126153 const newline : "\n" | "\r\n" = content . includes ( "\r\n" ) ? "\r\n" : "\n" ;
127154 const trailingNewline =
128155 content . length > 0 && ( newline === "\r\n" ? content . endsWith ( "\r\n" ) : content . endsWith ( "\n" ) ) ;
129156
130- let lines =
131- newline === "\r\n"
132- ? content . split ( "\r\n" )
133- : content . split ( "\n" ) ;
157+ let lines = newline === "\r\n" ? content . split ( "\r\n" ) : content . split ( "\n" ) ;
134158
135159 if ( trailingNewline && lines . length > 0 && lines [ lines . length - 1 ] === "" ) {
136160 lines = lines . slice ( 0 , - 1 ) ;
@@ -149,7 +173,11 @@ function splitNewStringLines(newString: string): string[] {
149173 return parts ;
150174}
151175
152- export function joinFileLines ( lines : string [ ] , newline : "\n" | "\r\n" , trailingNewline : boolean ) : string {
176+ export function joinFileLines (
177+ lines : string [ ] ,
178+ newline : "\n" | "\r\n" ,
179+ trailingNewline : boolean ,
180+ ) : string {
153181 if ( lines . length === 0 ) {
154182 return trailingNewline ? newline : "" ;
155183 }
@@ -286,4 +314,4 @@ export function advertiseEditFileLineRange(definition: ToolDefinition): ToolDefi
286314 required : [ "path" , "new_string" ] ,
287315 } ,
288316 } ;
289- }
317+ }
0 commit comments