@@ -15,6 +15,7 @@ import {
1515 readFileBounded ,
1616 readFileGuardPlugin ,
1717} from "./read-file-guard-plugin.js" ;
18+ import { resultTruncationPlugin } from "./result-truncation-plugin.js" ;
1819
1920const neverAbort = ( ) => new AbortController ( ) . signal ;
2021
@@ -291,6 +292,234 @@ describe("readFileBounded", () => {
291292 } ) ;
292293} ) ;
293294
295+ describe ( "CL-8979 large-file pagination" , ( ) => {
296+ const BIG_LINES = 45_000 ;
297+ const bigRow = ( i : number ) : string => `L${ i } -` + "p" . repeat ( 243 ) ;
298+
299+ async function bigFixture ( name : string ) : Promise < string > {
300+ const rows = Array . from ( { length : BIG_LINES } , ( _ , i ) => bigRow ( i ) ) ;
301+ return fixture ( name , `${ rows . join ( "\n" ) } \n` ) ;
302+ }
303+
304+ function continueOffset ( content : string ) : number | null {
305+ const match = / U s e o f f s e t = ( \d + ) t o c o n t i n u e / . exec ( content ) ;
306+ return match === null ? null : Number ( match [ 1 ] ) ;
307+ }
308+
309+ function bodyRows ( content : string ) : string [ ] {
310+ const body = content . split ( "\n\n" ) [ 0 ] ?? "" ;
311+ return body
312+ . split ( "\n" )
313+ . filter ( ( line ) => line . trim ( ) . length > 0 )
314+ . map ( ( line ) => line . replace ( / ^ \s * \d + \t / , "" ) ) ;
315+ }
316+
317+ function chainRunner ( ) : (
318+ id : string ,
319+ args : Record < string , unknown > ,
320+ ) => Promise < ToolResult > {
321+ const plugin = readFileGuardPlugin ( dir , { } ) ;
322+ const middleware = plugin . middleware ;
323+ if ( middleware === undefined ) throw new Error ( "expected middleware" ) ;
324+ const fallback = async ( call : ToolCall ) : Promise < ToolResult > => ( {
325+ callId : call . id ,
326+ content : "FALLBACK" ,
327+ } ) ;
328+ return ( id , args ) =>
329+ middleware ( fallback ) (
330+ { id, name : "read_file" , arguments : args } ,
331+ neverAbort ( ) ,
332+ ) ;
333+ }
334+
335+ function blobChainRunner (
336+ readBlob : ( key : string ) => Promise < Uint8Array > ,
337+ ) : ( id : string , args : Record < string , unknown > ) => Promise < ToolResult > {
338+ const plugin = readFileGuardPlugin ( dir , {
339+ blobReader : createBlobReader ( { readBlob } ) ,
340+ } ) ;
341+ const middleware = plugin . middleware ;
342+ if ( middleware === undefined ) throw new Error ( "expected middleware" ) ;
343+ const fallback = async ( call : ToolCall ) : Promise < ToolResult > => ( {
344+ callId : call . id ,
345+ content : "FALLBACK" ,
346+ } ) ;
347+ return ( id , args ) =>
348+ middleware ( fallback ) (
349+ { id, name : "read_file" , arguments : args } ,
350+ neverAbort ( ) ,
351+ ) ;
352+ }
353+
354+ test ( "reads a deep page of a file larger than the scan ceiling" , async ( ) => {
355+ const p = await bigFixture ( "cl8979-big.txt" ) ;
356+ const res = await readFileBounded ( p , 43_000 , 5 , neverAbort ( ) ) ;
357+ expect ( res . isError ) . toBeUndefined ( ) ;
358+ expect ( String ( res . content ) ) . toContain ( bigRow ( 43_000 ) ) ;
359+ expect ( String ( res . content ) ) . not . toContain ( "scan limit" ) ;
360+ } ) ;
361+
362+ test (
363+ "chains plain path+offset continuation on one path through to the last line" ,
364+ async ( ) => {
365+ const name = "cl8979-chain.txt" ;
366+ await bigFixture ( name ) ;
367+ const run = chainRunner ( ) ;
368+ const collected : string [ ] = [ ] ;
369+ let offset = 0 ;
370+ let hops = 0 ;
371+ for ( ; ; ) {
372+ const result = await run ( `chain-${ hops } ` , {
373+ path : name ,
374+ limit : 200 ,
375+ offset,
376+ } ) ;
377+ hops += 1 ;
378+ const content = String ( result . content ) ;
379+ expect ( result . isError ) . toBeFalsy ( ) ;
380+ expect ( content ) . not . toContain ( "scan limit" ) ;
381+ collected . push ( ...bodyRows ( content ) ) ;
382+ const next = continueOffset ( content ) ;
383+ if ( next === null ) break ;
384+ offset = next ;
385+ expect ( hops ) . toBeLessThan ( 2000 ) ;
386+ }
387+ expect ( hops ) . toBeGreaterThan ( 1 ) ;
388+ expect ( collected . length ) . toBe ( BIG_LINES ) ;
389+ expect ( collected ) . toEqual (
390+ Array . from ( { length : BIG_LINES } , ( _ , i ) => bigRow ( i ) ) ,
391+ ) ;
392+ } ,
393+ 120_000 ,
394+ ) ;
395+
396+ test (
397+ "chains cursor resumption on a blob past the scan ceiling without re-scanning" ,
398+ async ( ) => {
399+ const rows = Array . from ( { length : BIG_LINES } , ( _ , i ) => bigRow ( i ) ) ;
400+ const bytes = new TextEncoder ( ) . encode ( `${ rows . join ( "\n" ) } \n` ) ;
401+ const run = blobChainRunner ( async ( key ) => {
402+ if ( key === "cl8979-blob" ) return bytes ;
403+ throw new Error ( `missing ${ key } ` ) ;
404+ } ) ;
405+ const collected : string [ ] = [ ] ;
406+ let path = "tool-output:///cl8979-blob" ;
407+ let hops = 0 ;
408+ let sawOffsetFooter = false ;
409+ let sawCursorAlias = false ;
410+ for ( ; ; ) {
411+ const result = await run ( `blob-${ hops } ` , { path, limit : 200 } ) ;
412+ hops += 1 ;
413+ const content = String ( result . content ) ;
414+ expect ( result . isError ) . toBeFalsy ( ) ;
415+ expect ( content ) . not . toContain ( "scan limit" ) ;
416+ collected . push ( ...bodyRows ( content ) ) ;
417+ const cursor = / U s e p a t h = " ( t o o l - o u t p u t : \/ \/ \/ [ ^ " ] + ) " / . exec ( content ) ;
418+ const offset = continueOffset ( content ) ;
419+ if ( offset !== null ) {
420+ sawOffsetFooter = true ;
421+ path = "tool-output:///cl8979-blob" ;
422+ }
423+ if ( cursor !== null ) {
424+ sawCursorAlias = true ;
425+ path = cursor [ 1 ] as string ;
426+ }
427+ if ( offset === null && cursor === null ) break ;
428+ expect ( hops ) . toBeLessThan ( 2000 ) ;
429+ }
430+ expect ( hops ) . toBeGreaterThan ( 1 ) ;
431+ expect ( sawOffsetFooter ) . toBe ( true ) ;
432+ expect ( sawCursorAlias ) . toBe ( true ) ;
433+ expect ( collected . length ) . toBe ( BIG_LINES ) ;
434+ expect ( collected [ BIG_LINES - 1 ] ) . toBe ( bigRow ( BIG_LINES - 1 ) ) ;
435+ } ,
436+ 120_000 ,
437+ ) ;
438+
439+ test ( "windows an overlong single file line so the tail is reachable" , async ( ) => {
440+ const payload = `HEAD-${ "y" . repeat ( 100_000 ) } -TAIL` ;
441+ const p = await fixture ( "cl8979-giant.txt" , `${ payload } \nEND\n` ) ;
442+ let offset = 0 ;
443+ let hops = 0 ;
444+ let collected = "" ;
445+ for ( ; ; ) {
446+ const res = await readFileBounded ( p , offset , 10 , neverAbort ( ) ) ;
447+ hops += 1 ;
448+ expect ( res . isError ) . toBeUndefined ( ) ;
449+ const content = String ( res . content ) ;
450+ expect ( content ) . not . toContain ( "line truncated" ) ;
451+ collected += `${ content } \n` ;
452+ const rows = bodyRows ( content ) ;
453+ expect ( rows . length ) . toBeGreaterThan ( 1 ) ;
454+ const next = continueOffset ( content ) ;
455+ if ( next === null ) break ;
456+ offset = next ;
457+ expect ( hops ) . toBeLessThan ( 100 ) ;
458+ }
459+ expect ( collected ) . toContain ( "HEAD-" ) ;
460+ expect ( collected ) . toContain ( "-TAIL" ) ;
461+ expect ( collected ) . toContain ( "END" ) ;
462+ } ) ;
463+
464+ test ( "a large-file page passes the result-truncation layer byte-identical" , async ( ) => {
465+ const name = "cl8979-page.txt" ;
466+ const rows = Array . from (
467+ { length : 3_000 } ,
468+ ( _ , i ) => `cell-${ i } -` + "v" . repeat ( 50 ) ,
469+ ) ;
470+ await fixture ( name , `${ rows . join ( "\n" ) } \n` ) ;
471+ const plugin = readFileGuardPlugin ( dir , { } ) ;
472+ const guardMiddleware = plugin . middleware ;
473+ if ( guardMiddleware === undefined ) throw new Error ( "expected middleware" ) ;
474+ const fallback = async ( call : ToolCall ) : Promise < ToolResult > => ( {
475+ callId : call . id ,
476+ content : "FALLBACK" ,
477+ } ) ;
478+ const guard = guardMiddleware ( fallback ) ;
479+ const guardOnly = await guard (
480+ { id : "page-1" , name : "read_file" , arguments : { path : name } } ,
481+ neverAbort ( ) ,
482+ ) ;
483+ expect ( guardOnly . isError ) . toBeFalsy ( ) ;
484+ expect ( String ( guardOnly . content ) ) . toContain ( "to continue" ) ;
485+ const spilled = new Map < string , Uint8Array > ( ) ;
486+ const truncPlugin = resultTruncationPlugin ( {
487+ getBlobWriter : ( ) => async ( key : string , payload : Uint8Array ) => {
488+ spilled . set ( key , payload ) ;
489+ } ,
490+ } ) ;
491+ const truncMiddleware = truncPlugin . middleware ;
492+ if ( truncMiddleware === undefined ) throw new Error ( "expected middleware" ) ;
493+ const composed = truncMiddleware ( guard ) ;
494+ const res = await composed (
495+ { id : "page-1" , name : "read_file" , arguments : { path : name } } ,
496+ neverAbort ( ) ,
497+ ) ;
498+ expect ( String ( res . content ) ) . toBe ( String ( guardOnly . content ) ) ;
499+ expect ( spilled . size ) . toBe ( 0 ) ;
500+ } ) ;
501+
502+ test ( "an aborted read rejects with a timeout, not a fallback page" , async ( ) => {
503+ const p = await fixture ( "cl8979-abort.txt" , "x" . repeat ( 1000 ) ) ;
504+ const ctl = new AbortController ( ) ;
505+ ctl . abort ( ) ;
506+ const read = readFileBounded ( p , 0 , 2000 , ctl . signal ) ;
507+ await expect ( read ) . rejects . toThrow ( "[timed out before completing]" ) ;
508+ } ) ;
509+
510+ test ( "a binary file still surfaces a refusal instead of a fallback page" , async ( ) => {
511+ await fixture (
512+ "cl8979-bin.dat" ,
513+ Buffer . from ( [ 0x89 , 0x50 , 0x4e , 0x47 , 0x00 , 0xff , 0x00 ] ) ,
514+ ) ;
515+ const run = chainRunner ( ) ;
516+ const result = await run ( "bin-1" , { path : "cl8979-bin.dat" } ) ;
517+ expect ( result . isError ) . toBe ( true ) ;
518+ expect ( String ( result . content ) ) . toMatch ( / b i n a r y / ) ;
519+ expect ( String ( result . content ) ) . not . toBe ( "FALLBACK" ) ;
520+ } ) ;
521+ } ) ;
522+
294523describe ( "readFileGuardPlugin" , ( ) => {
295524 const fallback = async ( call : ToolCall ) : Promise < ToolResult > => ( {
296525 callId : call . id ,
0 commit comments