@@ -890,6 +890,9 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => {
890890 const compactor = createPruningCompactor ( {
891891 keepRecentTurns : 2 ,
892892 summaryMaxChars : 500 ,
893+ // CL-9007: pin a tiny tail budget so the fold covers the same older
894+ // region the old keepRecentTurns cut folded.
895+ compactionShape : { tailBudgetTokens : 10 } ,
893896 } ) ;
894897 const turns : ConversationTurn [ ] = [
895898 userTurn ( "Ship the widget. Never rename src/widget.ts." ) ,
@@ -953,6 +956,9 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => {
953956 const compactor = createPruningCompactor ( {
954957 keepRecentTurns : 2 ,
955958 summaryMaxChars : 500 ,
959+ // CL-9007: pin a tiny tail budget so each fold covers the same older
960+ // region the old keepRecentTurns cut folded.
961+ compactionShape : { tailBudgetTokens : 10 } ,
956962 readPriorHandoff : async ( ) => latest ,
957963 } ) ;
958964 const firstTurns : ConversationTurn [ ] = [
@@ -989,3 +995,153 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => {
989995 expect ( filesSection ) . toContain ( "diagnostic.log" ) ;
990996 } ) ;
991997} ) ;
998+
999+ describe ( "CL-9007 tail attachments stay whole" , ( ) => {
1000+ const PNG_B64 =
1001+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ;
1002+
1003+ test ( "a recent image attachment and its user text survive the fold whole" , async ( ) => {
1004+ const userText =
1005+ "screenshot ask: keep this newest user message whole verbatim" ;
1006+ const compactor = createPruningCompactor ( {
1007+ keepRecentTurns : 2 ,
1008+ summaryMaxChars : 4000 ,
1009+ compactionShape : { tailBudgetTokens : 1000 } ,
1010+ summarize : async ( ) => "Re-read src/a.ts. Next: review the screenshot." ,
1011+ } ) ;
1012+ const turns : ConversationTurn [ ] = [
1013+ userTurn ( "Migrate the auth module to opaque tokens in src/auth.ts" ) ,
1014+ ...fileReadTurns ( "a" , "src/a.ts" , `a-result:${ "a" . repeat ( 4000 ) } ` ) ,
1015+ ...fileReadTurns ( "b" , "src/b.ts" , `b-result:${ "b" . repeat ( 4000 ) } ` ) ,
1016+ makeTurn ( {
1017+ role : "user" ,
1018+ content : [
1019+ { type : "text" , text : userText } ,
1020+ {
1021+ type : "image" ,
1022+ source : { kind : "base64" , mimeType : "image/png" , data : PNG_B64 } ,
1023+ } ,
1024+ ] ,
1025+ } ) ,
1026+ makeTurn ( {
1027+ role : "assistant" ,
1028+ content : [ { type : "text" , text : "newest reply" } ] ,
1029+ } ) ,
1030+ ] ;
1031+ const result = await compactor . apply ( turns , mockStrategyCtx ) ;
1032+ expect ( result . record . reason . startsWith ( "compacted" ) ) . toBe ( true ) ;
1033+ const live = result . output
1034+ . flatMap ( ( t ) => t . content )
1035+ . filter ( ( b ) => b . type === "text" )
1036+ . map ( ( b ) => b . text )
1037+ . join ( "\n" ) ;
1038+ expect ( live ) . toContain ( userText ) ;
1039+ const image = result . output
1040+ . flatMap ( ( t ) => t . content )
1041+ . find ( ( b ) => b . type === "image" ) ;
1042+ expect ( image ) . toEqual ( {
1043+ type : "image" ,
1044+ source : { kind : "base64" , mimeType : "image/png" , data : PNG_B64 } ,
1045+ } ) ;
1046+ } ) ;
1047+ } ) ;
1048+
1049+ describe ( "CL-9007 repeated compactions update the summary" , ( ) => {
1050+ const TAIL_MARK = "newest ask: carry this tail string forward" ;
1051+
1052+ function firstSession ( ) : ConversationTurn [ ] {
1053+ return [
1054+ userTurn ( "Migrate the auth module to opaque tokens in src/auth.ts" ) ,
1055+ ...fileReadTurns ( "a" , "src/a.ts" , `a-result:${ "a" . repeat ( 4000 ) } ` ) ,
1056+ ...fileReadTurns ( "b" , "src/b.ts" , `b-result:${ "b" . repeat ( 4000 ) } ` ) ,
1057+ userTurn ( TAIL_MARK ) ,
1058+ makeTurn ( {
1059+ role : "assistant" ,
1060+ content : [ { type : "text" , text : "newest reply" } ] ,
1061+ } ) ,
1062+ ] ;
1063+ }
1064+
1065+ function countSpines ( turns : ConversationTurn [ ] ) : number {
1066+ return turns
1067+ . flatMap ( ( t ) => t . content )
1068+ . filter ( ( b ) => b . type === "text" && b . text . includes ( COMPACTED_PREFIX ) )
1069+ . length ;
1070+ }
1071+
1072+ function countMarkers ( turns : ConversationTurn [ ] ) : number {
1073+ // Markers live inside tool_result bodies, not top-level text blocks.
1074+ return turns
1075+ . flatMap ( ( t ) =>
1076+ t . content . flatMap ( ( b ) => {
1077+ if ( b . type === "text" ) return [ b . text ] ;
1078+ if ( b . type === "tool_result" )
1079+ return b . content . map ( ( c ) => ( c . type === "text" ? c . text : "" ) ) ;
1080+ return [ ] ;
1081+ } ) ,
1082+ )
1083+ . reduce ( ( sum , text ) => sum + text . split ( "[tail-shortened" ) . length - 1 , 0 ) ;
1084+ }
1085+
1086+ function liveText ( turns : ConversationTurn [ ] ) : string {
1087+ return turns
1088+ . flatMap ( ( t ) => t . content )
1089+ . filter ( ( b ) => b . type === "text" )
1090+ . map ( ( b ) => b . text )
1091+ . join ( "\n" ) ;
1092+ }
1093+
1094+ test ( "a second fold updates the prior summary and carries the tail forward" , async ( ) => {
1095+ let priorFile : string | undefined ;
1096+ let seenPrior : string | undefined ;
1097+ let calls = 0 ;
1098+ const compactor = createPruningCompactor ( {
1099+ keepRecentTurns : 2 ,
1100+ summaryMaxChars : 4000 ,
1101+ compactionShape : { tailBudgetTokens : 1000 } ,
1102+ readPriorHandoff : async ( ) => priorFile ,
1103+ summarize : async ( _turns , ctx ) => {
1104+ calls += 1 ;
1105+ if ( calls === 2 ) seenPrior = ctx ?. priorSummary ;
1106+ return calls === 1
1107+ ? "Re-read src/a.ts. Next: keep newest ask whole."
1108+ : "Migrating auth to opaque tokens via src/a.ts. Next: keep newest ask whole." ;
1109+ } ,
1110+ } ) ;
1111+
1112+ const first = await compactor . apply ( firstSession ( ) , mockStrategyCtx ) ;
1113+ expect ( first . record . reason . startsWith ( "compacted" ) ) . toBe ( true ) ;
1114+ expect ( countSpines ( first . output ) ) . toBe ( 1 ) ;
1115+ priorFile = new TextDecoder ( ) . decode (
1116+ defined ( defined ( first . blobs ) [ 0 ] ) . bytes ,
1117+ ) ;
1118+ expect ( priorFile ) . toContain ( "src/a.ts" ) ;
1119+
1120+ const second = await compactor . apply (
1121+ [
1122+ ...first . output ,
1123+ // New tool activity after the first fold: the second tail holds this
1124+ // fresh excerpt while the first fold's excerpt is summarized from its
1125+ // shortened text — never re-summarized raw, never duplicated live.
1126+ ...fileReadTurns ( "c" , "src/c.ts" , `c-result:${ "c" . repeat ( 4000 ) } ` ) ,
1127+ userTurn ( "follow-up ask" ) ,
1128+ makeTurn ( {
1129+ role : "assistant" ,
1130+ content : [ { type : "text" , text : "follow-up reply" } ] ,
1131+ } ) ,
1132+ ] ,
1133+ mockStrategyCtx ,
1134+ ) ;
1135+ expect ( second . record . reason . startsWith ( "compacted" ) ) . toBe ( true ) ;
1136+ expect ( countSpines ( second . output ) ) . toBe ( 1 ) ;
1137+ expect ( seenPrior ) . toContain ( COMPACTED_PREFIX ) ;
1138+ expect ( seenPrior ) . toContain ( "src/a.ts" ) ;
1139+ expect ( liveText ( second . output ) ) . toContain ( TAIL_MARK ) ;
1140+ expect ( countMarkers ( second . output ) ) . toBe ( countMarkers ( first . output ) ) ;
1141+ const secondFile = new TextDecoder ( ) . decode (
1142+ defined ( defined ( second . blobs ) [ 0 ] ) . bytes ,
1143+ ) ;
1144+ expect ( secondFile ) . toContain ( "src/a.ts" ) ;
1145+ expect ( secondFile ) . not . toBe ( priorFile ) ;
1146+ } ) ;
1147+ } ) ;
0 commit comments