@@ -565,6 +565,122 @@ test("sessions.compact treats Codex native compaction start as pending, not comp
565565 ws . close ( ) ;
566566} ) ;
567567
568+ test ( "sessions.compact maxLines truncates the transcript on disk and archives the original to .bak" , async ( ) => {
569+ const { dir } = await createSessionStoreDir ( ) ;
570+ const transcriptPath = path . join ( dir , "sess-main.jsonl" ) ;
571+ const originalLines = Array . from ( { length : 500 } , ( _ , index ) =>
572+ JSON . stringify ( { role : "user" , content : `line-${ index } ` } ) ,
573+ ) ;
574+ await fs . writeFile ( transcriptPath , `${ originalLines . join ( "\n" ) } \n` , "utf-8" ) ;
575+ await writeSessionStore ( { entries : { main : sessionStoreEntry ( "sess-main" ) } } ) ;
576+
577+ const { ws } = await openClient ( ) ;
578+ const compacted = await rpcReq < {
579+ ok : true ;
580+ key : string ;
581+ compacted : boolean ;
582+ kept ?: number ;
583+ archived ?: string ;
584+ } > ( ws , "sessions.compact" , { key : "main" , maxLines : 50 } ) ;
585+
586+ expect ( compacted . ok ) . toBe ( true ) ;
587+ expect ( compacted . payload ?. compacted ) . toBe ( true ) ;
588+ expect ( compacted . payload ?. kept ) . toBe ( 50 ) ;
589+
590+ // Active transcript truncated in place on disk: 500 -> 50 lines, newest kept.
591+ const truncated = ( await fs . readFile ( transcriptPath , "utf-8" ) ) . trim ( ) . split ( "\n" ) ;
592+ expect ( truncated ) . toHaveLength ( 50 ) ;
593+ expect ( truncated . at ( - 1 ) ) . toBe ( JSON . stringify ( { role : "user" , content : "line-499" } ) ) ;
594+
595+ // Original 500 lines preserved verbatim in the .bak archive.
596+ const archivedPath = compacted . payload ?. archived ;
597+ if ( ! archivedPath ) {
598+ throw new Error ( "expected archived transcript path" ) ;
599+ }
600+ const archived = ( await fs . readFile ( archivedPath , "utf-8" ) ) . trim ( ) . split ( "\n" ) ;
601+ expect ( archived ) . toHaveLength ( 500 ) ;
602+ expect ( archived . at ( 0 ) ) . toBe ( JSON . stringify ( { role : "user" , content : "line-0" } ) ) ;
603+
604+ // No active run present, so the interrupt guard short-circuits without aborting.
605+ expect ( embeddedRunMock . abortCalls ) . toEqual ( [ ] ) ;
606+ expect ( embeddedRunMock . waitCalls ) . toEqual ( [ ] ) ;
607+
608+ ws . close ( ) ;
609+ } ) ;
610+
611+ test ( "sessions.compact maxLines interrupts an active run before truncating, matching the LLM compact path" , async ( ) => {
612+ const { dir } = await createSessionStoreDir ( ) ;
613+ const transcriptPath = path . join ( dir , "sess-main.jsonl" ) ;
614+ const originalLines = Array . from ( { length : 500 } , ( _ , index ) =>
615+ JSON . stringify ( { role : "user" , content : `line-${ index } ` } ) ,
616+ ) ;
617+ await fs . writeFile ( transcriptPath , `${ originalLines . join ( "\n" ) } \n` , "utf-8" ) ;
618+ await writeSessionStore ( { entries : { main : sessionStoreEntry ( "sess-main" ) } } ) ;
619+
620+ const { ws } = await openClient ( ) ;
621+ // Simulate an embedded agent run actively appending to this session transcript.
622+ embeddedRunMock . activeIds . add ( "sess-main" ) ;
623+ embeddedRunMock . waitResults . set ( "sess-main" , true ) ;
624+
625+ const compacted = await rpcReq < {
626+ ok : true ;
627+ compacted : boolean ;
628+ kept ?: number ;
629+ } > ( ws , "sessions.compact" , { key : "main" , maxLines : 50 } ) ;
630+
631+ // Regression for the ClawSweeper finding: the maxLines truncate branch must
632+ // run the same active-run interrupt guard as the LLM-summarize branch *before*
633+ // archiving and overwriting the transcript, so an active runner cannot keep
634+ // appending to the file being truncated (the data-loss mode tracked by #72765).
635+ expect ( embeddedRunMock . abortCalls ) . toEqual ( [ "sess-main" ] ) ;
636+ expect ( embeddedRunMock . waitCalls ) . toEqual ( [ "sess-main" ] ) ;
637+
638+ // The guard ran first; truncation still completed deterministically afterwards.
639+ expect ( compacted . ok ) . toBe ( true ) ;
640+ expect ( compacted . payload ?. compacted ) . toBe ( true ) ;
641+ expect ( compacted . payload ?. kept ) . toBe ( 50 ) ;
642+ const truncated = ( await fs . readFile ( transcriptPath , "utf-8" ) ) . trim ( ) . split ( "\n" ) ;
643+ expect ( truncated ) . toHaveLength ( 50 ) ;
644+
645+ ws . close ( ) ;
646+ } ) ;
647+
648+ test ( "sessions.compact maxLines aborts without truncating when an active run cannot be interrupted" , async ( ) => {
649+ const { dir, storePath } = await createSessionStoreDir ( ) ;
650+ const transcriptPath = path . join ( dir , "sess-main.jsonl" ) ;
651+ const originalLines = Array . from ( { length : 500 } , ( _ , index ) =>
652+ JSON . stringify ( { role : "user" , content : `line-${ index } ` } ) ,
653+ ) ;
654+ await fs . writeFile ( transcriptPath , `${ originalLines . join ( "\n" ) } \n` , "utf-8" ) ;
655+ await writeSessionStore ( { entries : { main : sessionStoreEntry ( "sess-main" ) } } ) ;
656+
657+ const { ws } = await openClient ( ) ;
658+ // Active embedded run that fails to end within the interrupt window.
659+ embeddedRunMock . activeIds . add ( "sess-main" ) ;
660+ embeddedRunMock . waitResults . set ( "sess-main" , false ) ;
661+
662+ const compacted = await rpcReq < { ok : boolean } > ( ws , "sessions.compact" , {
663+ key : "main" ,
664+ maxLines : 50 ,
665+ } ) ;
666+
667+ // Order proof: the guard ran first and failed, so the RPC errors out *before*
668+ // any archive/truncate. If the guard ran after truncation, the transcript
669+ // would already be 50 lines here. It is still 500 with no .bak, proving the
670+ // interrupt happens before the destructive tail-read/archive/write.
671+ expect ( compacted . ok ) . toBe ( false ) ;
672+ expect ( embeddedRunMock . abortCalls ) . toEqual ( [ "sess-main" ] ) ;
673+ expect ( embeddedRunMock . waitCalls ) . toEqual ( [ "sess-main" ] ) ;
674+
675+ const untouched = ( await fs . readFile ( transcriptPath , "utf-8" ) ) . trim ( ) . split ( "\n" ) ;
676+ expect ( untouched ) . toHaveLength ( 500 ) ;
677+ const dirEntries = await fs . readdir ( dir ) ;
678+ expect ( dirEntries . some ( ( name ) => name . includes ( ".bak" ) ) ) . toBe ( false ) ;
679+ expect ( storePath ) . toBeTruthy ( ) ;
680+
681+ ws . close ( ) ;
682+ } ) ;
683+
568684test ( "sessions.patch preserves nested model ids under provider overrides" , async ( ) => {
569685 const dir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-gw-sessions-nested-" ) ) ;
570686 const storePath = path . join ( dir , "sessions.json" ) ;
0 commit comments