@@ -17,13 +17,14 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR
1717import { isLegacyReadFileParams , type ClineSayTool } from "@roo-code/types"
1818
1919import { Task } from "../task/Task"
20+ import { versionTokenOfStat } from "../../utils/versionToken"
2021import { formatResponse } from "../prompts/responses"
2122import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
2223import { isPathOutsideWorkspace } from "../../utils/pathUtils"
2324import { getReadablePath } from "../../utils/path"
2425import { extractTextFromFile , addLineNumbers , getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
2526import { readWithIndentation , readWithSlice } from "../../integrations/misc/indentation-reader"
26- import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
27+ import { DEFAULT_LINE_LIMIT , MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file"
2728import type { ToolUse , PushToolResult } from "../../shared/tools"
2829
2930import {
@@ -215,14 +216,36 @@ export class ReadFileTool extends BaseTool<"read_file"> {
215216 // Read text file content with lossy UTF-8 conversion
216217 // Reading as Buffer first allows graceful handling of non-UTF8 bytes
217218 // (they become U+FFFD replacement characters instead of throwing)
219+ // A2 (epic #1375): capture the on-disk token before the read so a mutation
220+ // landing mid-read is detected by the post-read stat below.
221+ const preReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
218222 const buffer = await fs . readFile ( fullPath )
219223 const fileContent = buffer . toString ( "utf-8" )
220- const result = this . processTextFile ( fileContent , entry )
224+ // A lossy decode is not the whole file: the model never saw those bytes.
225+ const lossyDecode = ! Buffer . from ( fileContent ) . equals ( buffer )
226+ // S4b follow-up (#46 / epic #1375): processTextFile reports whether the
227+ // returned content is the whole file; the observation below records that
228+ // scope so the write guard can deny full-file updates built on a partial view.
229+ const processed = this . processTextFile ( fileContent , entry )
221230
222231 await task . fileContextTracker . trackFileContext ( relPath , "read_tool" as RecordSource )
223232
233+ // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard.
234+ // The token is captured before AND after the read; the target is observed only
235+ // when both match — a mutation between the two stats means the content the model
236+ // received is not the on-disk state, and observing it would let a later write
237+ // match a token the model never saw. A stat failure leaves the target
238+ // unobserved and never fails the read.
239+ const postReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
240+ if ( preReadStats && postReadStats ) {
241+ const preReadToken = versionTokenOfStat ( preReadStats )
242+ if ( preReadToken === versionTokenOfStat ( postReadStats ) ) {
243+ task . observationRegistry . observe ( fullPath , preReadToken , processed . complete && ! lossyDecode )
244+ }
245+ }
246+
224247 updateFileResult ( relPath , {
225- nativeContent : `File: ${ relPath } \n${ result } ` ,
248+ nativeContent : `File: ${ relPath } \n${ processed . content } ` ,
226249 } )
227250 } catch ( error ) {
228251 const errorMsg = error instanceof Error ? error . message : String ( error )
@@ -266,8 +289,14 @@ export class ReadFileTool extends BaseTool<"read_file"> {
266289
267290 /**
268291 * Process a text file according to the requested mode.
292+ *
293+ * Returns the content string plus whether that content is the complete
294+ * file (S4b follow-up #46 / epic #1375): slice mode is complete only
295+ * when it starts at line 1, returns every line, and was not truncated;
296+ * indentation mode is never complete because it returns semantic blocks
297+ * of the file, not the file itself.
269298 */
270- private processTextFile ( content : string , entry : InternalFileEntry ) : string {
299+ private processTextFile ( content : string , entry : InternalFileEntry ) : { content : string ; complete : boolean } {
271300 const mode = entry . mode || "slice"
272301
273302 if ( mode === "indentation" ) {
@@ -300,7 +329,8 @@ export class ReadFileTool extends BaseTool<"read_file"> {
300329 output += `\n\nIncluded ranges: ${ rangeStr } (total: ${ result . totalLines } lines)`
301330 }
302331
303- return output
332+ // Indentation mode returns semantic blocks: never a complete file view.
333+ return { content : output , complete : false }
304334 }
305335
306336 // Slice mode (default): simple offset/limit reading
@@ -323,11 +353,35 @@ export class ReadFileTool extends BaseTool<"read_file"> {
323353 To read more: Use the read_file tool with offset=${ nextOffset } and limit=${ limit } .
324354
325355 ${ result . content } `
356+ if ( result . hasClippedLines ) {
357+ // The slice cut lines off and also clipped long lines inside it, so both
358+ // notices belong to the response.
359+ output += `\nNote: Some lines in this view exceed ${ MAX_LINE_LENGTH } characters and were clipped in this view.`
360+ }
361+ } else if ( result . hasClippedLines ) {
362+ // The slice reached the end of the file, so there is no later offset to read: report
363+ // the clipping without a next-offset hint, and keep the read incomplete so a full-file
364+ // replacement cannot be built from a clipped line. "Read in full" is only true from the
365+ // requested offset onward, and a model that reads it literally would think the lines
366+ // before that offset were included.
367+ const coverage =
368+ offset0 === 0 ? "The file was read in full" : `The file was read from line ${ offset1 } onward`
369+ output = `IMPORTANT: Some lines exceed ${ MAX_LINE_LENGTH } characters and were clipped in this view. ${ coverage } , but the clipped lines were not shown in full.
370+ ${ result . content } `
326371 } else if ( result . returnedLines === 0 && result . totalLines === 0 ) {
372+ // Only a truly empty file is reported as empty. A slice that starts past the last line
373+ // returns the producer's beyond-EOF text (src/integrations/misc/indentation-reader.ts),
374+ // which must reach the model instead of being replaced by an empty-file note.
327375 output = "Note: File is empty"
328376 }
329377
330- return output
378+ // Complete only when the slice starts at line 1, returned every line, and
379+ // showed every line in full (returnedLines === totalLines follows from the
380+ // first two conditions): a partial start, a truncated tail, or a clipped
381+ // line means the model did not see the whole file.
382+ const complete = offset0 === 0 && ! result . wasTruncated && ! result . hasClippedLines
383+
384+ return { content : output , complete }
331385 }
332386
333387 /**
@@ -770,9 +824,20 @@ export class ReadFileTool extends BaseTool<"read_file"> {
770824 }
771825
772826 // Read text file
773- const rawContent = await fs . readFile ( fullPath , "utf8" )
827+ // A2 (epic #1375): capture the on-disk token before the read so a mutation
828+ // landing mid-read is detected by the post-read stat below.
829+ const preReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
830+ const rawBuffer = await fs . readFile ( fullPath )
831+ const rawContent = rawBuffer . toString ( "utf-8" )
832+ // Same contract: a lossy decode is a partial view.
833+ const lossyDecode = ! Buffer . from ( rawContent ) . equals ( rawBuffer )
774834
775835 // Handle line ranges if specified
836+ // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested
837+ // ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of
838+ // the file — record such observations as partial so the write guard denies a
839+ // full-file update built on them.
840+ let readComplete = false
776841 let content : string
777842 if ( entry . lineRanges && entry . lineRanges . length > 0 ) {
778843 const lines = rawContent . split ( "\n" )
@@ -792,15 +857,36 @@ export class ReadFileTool extends BaseTool<"read_file"> {
792857 // Read with default limits using slice mode
793858 const result = readWithSlice ( rawContent , 0 , DEFAULT_LINE_LIMIT )
794859 content = result . content
860+ readComplete = ! result . wasTruncated && ! result . hasClippedLines
795861 if ( result . wasTruncated ) {
796862 content += `\n\n[File truncated: showing ${ result . returnedLines } of ${ result . totalLines } total lines]`
863+ if ( result . hasClippedLines ) {
864+ // Both notices: the slice was truncated and a line inside it was
865+ // clipped.
866+ content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]`
867+ }
868+ } else if ( result . hasClippedLines ) {
869+ content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]`
797870 }
798871 }
799872
800873 results . push ( `File: ${ relPath } \n${ content } ` )
801874
802875 // Track file in context
803876 await task . fileContextTracker . trackFileContext ( relPath , "read_tool" )
877+
878+ // A2 (plan #33 / epic #1375): mirror the native path — record the observed
879+ // on-disk version so legacy-format reads also feed the future write guard.
880+ // Observe only when the pre-read and post-read tokens match (a mutation between
881+ // them means the returned content is not the on-disk state). A stat failure
882+ // leaves the target unobserved and never fails the read.
883+ const postReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
884+ if ( preReadStats && postReadStats ) {
885+ const preReadToken = versionTokenOfStat ( preReadStats )
886+ if ( preReadToken === versionTokenOfStat ( postReadStats ) ) {
887+ task . observationRegistry . observe ( fullPath , preReadToken , readComplete && ! lossyDecode )
888+ }
889+ }
804890 } catch ( error ) {
805891 const errorMsg = error instanceof Error ? error . message : String ( error )
806892 results . push ( `File: ${ relPath } \nError: ${ errorMsg } ` )
0 commit comments