@@ -16,13 +16,14 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR
1616import { isLegacyReadFileParams , type ClineSayTool } from "@roo-code/types"
1717
1818import { Task } from "../task/Task"
19+ import { versionTokenOfStat } from "../../utils/versionToken"
1920import { formatResponse } from "../prompts/responses"
2021import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
2122import { isPathOutsideWorkspace } from "../../utils/pathUtils"
2223import { getReadablePath } from "../../utils/path"
2324import { extractTextFromFile , addLineNumbers , getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
2425import { readWithIndentation , readWithSlice } from "../../integrations/misc/indentation-reader"
25- import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
26+ import { DEFAULT_LINE_LIMIT , MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file"
2627import type { ToolUse , PushToolResult } from "../../shared/tools"
2728
2829import {
@@ -214,14 +215,36 @@ export class ReadFileTool extends BaseTool<"read_file"> {
214215 // Read text file content with lossy UTF-8 conversion
215216 // Reading as Buffer first allows graceful handling of non-UTF8 bytes
216217 // (they become U+FFFD replacement characters instead of throwing)
218+ // A2 (epic #1375): capture the on-disk token before the read so a mutation
219+ // landing mid-read is detected by the post-read stat below.
220+ const preReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
217221 const buffer = await fs . readFile ( fullPath )
218222 const fileContent = buffer . toString ( "utf-8" )
219- const result = this . processTextFile ( fileContent , entry )
223+ // A lossy decode is not the whole file: the model never saw those bytes.
224+ const lossyDecode = ! Buffer . from ( fileContent ) . equals ( buffer )
225+ // S4b follow-up (#46 / epic #1375): processTextFile reports whether the
226+ // returned content is the whole file; the observation below records that
227+ // scope so the write guard can deny full-file updates built on a partial view.
228+ const processed = this . processTextFile ( fileContent , entry )
220229
221230 await task . fileContextTracker . trackFileContext ( relPath , "read_tool" as RecordSource )
222231
232+ // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard.
233+ // The token is captured before AND after the read; the target is observed only
234+ // when both match — a mutation between the two stats means the content the model
235+ // received is not the on-disk state, and observing it would let a later write
236+ // match a token the model never saw. A stat failure leaves the target
237+ // unobserved and never fails the read.
238+ const postReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
239+ if ( preReadStats && postReadStats ) {
240+ const preReadToken = versionTokenOfStat ( preReadStats )
241+ if ( preReadToken === versionTokenOfStat ( postReadStats ) ) {
242+ task . observationRegistry . observe ( fullPath , preReadToken , processed . complete && ! lossyDecode )
243+ }
244+ }
245+
223246 updateFileResult ( relPath , {
224- nativeContent : `File: ${ relPath } \n${ result } ` ,
247+ nativeContent : `File: ${ relPath } \n${ processed . content } ` ,
225248 } )
226249 } catch ( error ) {
227250 const errorMsg = error instanceof Error ? error . message : String ( error )
@@ -265,8 +288,14 @@ export class ReadFileTool extends BaseTool<"read_file"> {
265288
266289 /**
267290 * Process a text file according to the requested mode.
291+ *
292+ * Returns the content string plus whether that content is the complete
293+ * file (S4b follow-up #46 / epic #1375): slice mode is complete only
294+ * when it starts at line 1, returns every line, and was not truncated;
295+ * indentation mode is never complete because it returns semantic blocks
296+ * of the file, not the file itself.
268297 */
269- private processTextFile ( content : string , entry : InternalFileEntry ) : string {
298+ private processTextFile ( content : string , entry : InternalFileEntry ) : { content : string ; complete : boolean } {
270299 const mode = entry . mode || "slice"
271300
272301 if ( mode === "indentation" ) {
@@ -299,7 +328,8 @@ export class ReadFileTool extends BaseTool<"read_file"> {
299328 output += `\n\nIncluded ranges: ${ rangeStr } (total: ${ result . totalLines } lines)`
300329 }
301330
302- return output
331+ // Indentation mode returns semantic blocks: never a complete file view.
332+ return { content : output , complete : false }
303333 }
304334
305335 // Slice mode (default): simple offset/limit reading
@@ -322,11 +352,28 @@ export class ReadFileTool extends BaseTool<"read_file"> {
322352 To read more: Use the read_file tool with offset=${ nextOffset } and limit=${ limit } .
323353
324354 ${ result . content } `
355+ if ( result . hasClippedLines ) {
356+ // The slice cut lines off and also clipped long lines inside it, so both
357+ // notices belong to the response.
358+ output += `\nNote: Some lines in this view exceed ${ MAX_LINE_LENGTH } characters and were clipped in this view.`
359+ }
360+ } else if ( result . hasClippedLines ) {
361+ // Every line was returned, so there is no later offset to read: report the
362+ // clipping without a next-offset hint, and keep the read incomplete so a
363+ // full-file replacement cannot be built from a clipped line.
364+ output = `IMPORTANT: Some lines exceed ${ MAX_LINE_LENGTH } characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full.
365+ ${ result . content } `
325366 } else if ( result . returnedLines === 0 ) {
326367 output = "Note: File is empty"
327368 }
328369
329- return output
370+ // Complete only when the slice starts at line 1, returned every line, and
371+ // showed every line in full (returnedLines === totalLines follows from the
372+ // first two conditions): a partial start, a truncated tail, or a clipped
373+ // line means the model did not see the whole file.
374+ const complete = offset0 === 0 && ! result . wasTruncated && ! result . hasClippedLines
375+
376+ return { content : output , complete }
330377 }
331378
332379 /**
@@ -768,9 +815,20 @@ export class ReadFileTool extends BaseTool<"read_file"> {
768815 }
769816
770817 // Read text file
771- const rawContent = await fs . readFile ( fullPath , "utf8" )
818+ // A2 (epic #1375): capture the on-disk token before the read so a mutation
819+ // landing mid-read is detected by the post-read stat below.
820+ const preReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
821+ const rawBuffer = await fs . readFile ( fullPath )
822+ const rawContent = rawBuffer . toString ( "utf-8" )
823+ // Same contract: a lossy decode is a partial view.
824+ const lossyDecode = ! Buffer . from ( rawContent ) . equals ( rawBuffer )
772825
773826 // Handle line ranges if specified
827+ // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested
828+ // ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of
829+ // the file — record such observations as partial so the write guard denies a
830+ // full-file update built on them.
831+ let readComplete = false
774832 let content : string
775833 if ( entry . lineRanges && entry . lineRanges . length > 0 ) {
776834 const lines = rawContent . split ( "\n" )
@@ -790,15 +848,36 @@ export class ReadFileTool extends BaseTool<"read_file"> {
790848 // Read with default limits using slice mode
791849 const result = readWithSlice ( rawContent , 0 , DEFAULT_LINE_LIMIT )
792850 content = result . content
851+ readComplete = ! result . wasTruncated && ! result . hasClippedLines
793852 if ( result . wasTruncated ) {
794853 content += `\n\n[File truncated: showing ${ result . returnedLines } of ${ result . totalLines } total lines]`
854+ if ( result . hasClippedLines ) {
855+ // Both notices: the slice was truncated and a line inside it was
856+ // clipped.
857+ content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]`
858+ }
859+ } else if ( result . hasClippedLines ) {
860+ content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]`
795861 }
796862 }
797863
798864 results . push ( `File: ${ relPath } \n${ content } ` )
799865
800866 // Track file in context
801867 await task . fileContextTracker . trackFileContext ( relPath , "read_tool" )
868+
869+ // A2 (plan #33 / epic #1375): mirror the native path — record the observed
870+ // on-disk version so legacy-format reads also feed the future write guard.
871+ // Observe only when the pre-read and post-read tokens match (a mutation between
872+ // them means the returned content is not the on-disk state). A stat failure
873+ // leaves the target unobserved and never fails the read.
874+ const postReadStats = await fs . stat ( fullPath , { bigint : true } ) . catch ( ( ) => undefined )
875+ if ( preReadStats && postReadStats ) {
876+ const preReadToken = versionTokenOfStat ( preReadStats )
877+ if ( preReadToken === versionTokenOfStat ( postReadStats ) ) {
878+ task . observationRegistry . observe ( fullPath , preReadToken , readComplete && ! lossyDecode )
879+ }
880+ }
802881 } catch ( error ) {
803882 const errorMsg = error instanceof Error ? error . message : String ( error )
804883 results . push ( `File: ${ relPath } \nError: ${ errorMsg } ` )
0 commit comments