@@ -37,12 +37,18 @@ const {
3737 resolveQualityGuardMaxRetries,
3838 extractOpaqueIdentifiers,
3939 auditSummaryQuality,
40+ capCompactionSummary,
41+ capCompactionSummaryPreservingSuffix,
42+ formatFileOperations,
4043 computeAdaptiveChunkRatio,
4144 isOversizedForSummary,
4245 readWorkspaceContextForSummary,
4346 BASE_CHUNK_RATIO ,
4447 MIN_CHUNK_RATIO ,
4548 SAFETY_MARGIN ,
49+ MAX_COMPACTION_SUMMARY_CHARS ,
50+ MAX_FILE_OPS_SECTION_CHARS ,
51+ SUMMARY_TRUNCATED_MARKER ,
4652} = __testing ;
4753
4854function stubSessionManager ( ) : ExtensionContext [ "sessionManager" ] {
@@ -255,6 +261,104 @@ describe("compaction-safeguard tool failures", () => {
255261 } ) ;
256262} ) ;
257263
264+ describe ( "compaction-safeguard summary budgets" , ( ) => {
265+ it ( "caps file operations summary and reports omitted entries" , ( ) => {
266+ const readFiles = Array . from (
267+ { length : 200 } ,
268+ ( _ , i ) => `docs/very/long/path/${ i } -read-file.md` ,
269+ ) ;
270+ const modifiedFiles = Array . from (
271+ { length : 200 } ,
272+ ( _ , i ) => `src/features/${ i } /nested/component/file-${ i } .ts` ,
273+ ) ;
274+
275+ const section = formatFileOperations ( readFiles , modifiedFiles ) ;
276+
277+ expect ( section ) . toContain ( "<read-files>" ) ;
278+ expect ( section ) . toContain ( "<modified-files>" ) ;
279+ expect ( section ) . toContain ( "...and " ) ;
280+ expect ( section . length ) . toBeLessThanOrEqual ( MAX_FILE_OPS_SECTION_CHARS ) ;
281+ } ) ;
282+
283+ it ( "caps final compaction summary with a truncation marker" , ( ) => {
284+ const oversized = "x" . repeat ( MAX_COMPACTION_SUMMARY_CHARS + 500 ) ;
285+ const capped = capCompactionSummary ( oversized ) ;
286+
287+ expect ( capped . length ) . toBeLessThanOrEqual ( MAX_COMPACTION_SUMMARY_CHARS ) ;
288+ expect ( capped ) . toContain ( SUMMARY_TRUNCATED_MARKER . trim ( ) ) ;
289+ expect ( capped . endsWith ( SUMMARY_TRUNCATED_MARKER ) ) . toBe ( true ) ;
290+ } ) ;
291+
292+ it ( "preserves workspace critical rules suffix when capping" , ( ) => {
293+ const suffix =
294+ "\n\n<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>" ;
295+ const body = "x" . repeat ( MAX_COMPACTION_SUMMARY_CHARS ) ;
296+
297+ const capped = capCompactionSummaryPreservingSuffix ( body , suffix ) ;
298+
299+ expect ( capped . length ) . toBeLessThanOrEqual ( MAX_COMPACTION_SUMMARY_CHARS ) ;
300+ expect ( capped ) . toContain ( "<workspace-critical-rules>" ) ;
301+ expect ( capped ) . toContain ( "## Session Startup" ) ;
302+ expect ( capped . endsWith ( suffix ) ) . toBe ( true ) ;
303+ } ) ;
304+
305+ it ( "preserves diagnostic sections (tool failures, file ops) when capping oversized body" , ( ) => {
306+ const diagnosticSuffix =
307+ "\n\n## Tool Failures\n- exec: failed\n\n<read-files>\nfoo.ts\n</read-files>\n\n" +
308+ "<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>" ;
309+ const body = "x" . repeat ( MAX_COMPACTION_SUMMARY_CHARS ) ;
310+
311+ const capped = capCompactionSummaryPreservingSuffix ( body , diagnosticSuffix ) ;
312+
313+ expect ( capped . length ) . toBeLessThanOrEqual ( MAX_COMPACTION_SUMMARY_CHARS ) ;
314+ expect ( capped ) . toContain ( "## Tool Failures" ) ;
315+ expect ( capped ) . toContain ( "<read-files>" ) ;
316+ expect ( capped ) . toContain ( "<workspace-critical-rules>" ) ;
317+ expect ( capped . endsWith ( diagnosticSuffix ) ) . toBe ( true ) ;
318+ } ) ;
319+
320+ it ( "keeps section separator when body ends without newline (e.g. buildStructuredFallbackSummary)" , ( ) => {
321+ const bodyNoNewline = "## Exact identifiers\nNone." ;
322+ const suffixNoLeadingNewline = "## Tool Failures\n- exec: failed" ;
323+
324+ const capped = capCompactionSummaryPreservingSuffix (
325+ bodyNoNewline ,
326+ `\n\n${ suffixNoLeadingNewline } ` ,
327+ ) ;
328+
329+ expect ( capped ) . toContain ( "None.\n\n## Tool Failures" ) ;
330+ expect ( capped ) . not . toMatch ( / N o n e \. # # T o o l F a i l u r e s / ) ;
331+ } ) ;
332+
333+ it ( "keeps body prefix when truncation marker cannot fit (tiny budget)" , ( ) => {
334+ const body = "## Decisions\nKeep flow.\n## Constraints\nFollow rules." ;
335+ const tinyBudget = 10 ; // Smaller than SUMMARY_TRUNCATED_MARKER.length
336+ const capped = capCompactionSummary ( body , tinyBudget ) ;
337+
338+ expect ( capped . length ) . toBeLessThanOrEqual ( tinyBudget ) ;
339+ expect ( capped ) . toContain ( "## Decis" ) ;
340+ expect ( capped ) . not . toContain ( "[Compaction summary truncated" ) ;
341+ } ) ;
342+
343+ it ( "preserves tail sections when suffix exceeds cap (workspace rules, diagnostics over preserved turns)" , ( ) => {
344+ const criticalTail =
345+ "\n\n## Tool Failures\n- exec: failed\n\n<read-files>\nfoo.ts\n</read-files>\n\n" +
346+ "<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>" ;
347+ const preservedTurns =
348+ "## Recent turns preserved verbatim\n- User: x\n- Assistant: y\n" +
349+ "x" . repeat ( MAX_COMPACTION_SUMMARY_CHARS ) ;
350+ const oversizedSuffix = preservedTurns + criticalTail ;
351+
352+ const capped = capCompactionSummaryPreservingSuffix ( "short body" , oversizedSuffix ) ;
353+
354+ expect ( capped . length ) . toBeLessThanOrEqual ( MAX_COMPACTION_SUMMARY_CHARS ) ;
355+ expect ( capped ) . toContain ( "<workspace-critical-rules>" ) ;
356+ expect ( capped ) . toContain ( "## Tool Failures" ) ;
357+ expect ( capped ) . toContain ( "<read-files>" ) ;
358+ expect ( capped ) . toContain ( "## Session Startup" ) ;
359+ } ) ;
360+ } ) ;
361+
258362describe ( "computeAdaptiveChunkRatio" , ( ) => {
259363 const CONTEXT_WINDOW = 200_000 ;
260364
@@ -1358,17 +1462,20 @@ describe("compaction-safeguard recent-turn preservation", () => {
13581462 expect ( secondCall ?. customInstructions ) . toContain ( "latest_user_ask_not_reflected" ) ;
13591463 } ) ;
13601464
1361- it ( "keeps last successful summary when a quality retry call fails " , async ( ) => {
1465+ it ( "preserves split-turn and recent-turn suffixes when retry fallback is capped " , async ( ) => {
13621466 mockSummarizeInStages . mockReset ( ) ;
1467+ const oversizedHistorySummary = "history detail " . repeat ( MAX_COMPACTION_SUMMARY_CHARS ) ;
1468+ const splitTurnPrefixSummary = "split-turn prefix context that must survive capping" ;
13631469 mockSummarizeInStages
1364- . mockResolvedValueOnce ( "short summary missing headings" )
1470+ . mockResolvedValueOnce ( oversizedHistorySummary )
1471+ . mockResolvedValueOnce ( splitTurnPrefixSummary )
13651472 . mockRejectedValueOnce ( new Error ( "retry transient failure" ) ) ;
13661473
13671474 const sessionManager = stubSessionManager ( ) ;
13681475 const model = createAnthropicModelFixture ( ) ;
13691476 setCompactionSafeguardRuntime ( sessionManager , {
13701477 model,
1371- recentTurnsPreserve : 0 ,
1478+ recentTurnsPreserve : 1 ,
13721479 qualityGuardEnabled : true ,
13731480 qualityGuardMaxRetries : 1 ,
13741481 } ) ;
@@ -1384,8 +1491,16 @@ describe("compaction-safeguard recent-turn preservation", () => {
13841491 messagesToSummarize : [
13851492 { role : "user" , content : "older context" , timestamp : 1 } ,
13861493 { role : "assistant" , content : "older reply" , timestamp : 2 } as unknown as AgentMessage ,
1494+ { role : "user" , content : "latest ask status" , timestamp : 3 } ,
1495+ {
1496+ role : "assistant" ,
1497+ content : [ { type : "text" , text : "latest assistant reply" } ] ,
1498+ timestamp : 4 ,
1499+ } as unknown as AgentMessage ,
1500+ ] ,
1501+ turnPrefixMessages : [
1502+ { role : "user" , content : "prefix request that was split out" , timestamp : 0 } ,
13871503 ] ,
1388- turnPrefixMessages : [ ] ,
13891504 firstKeptEntryId : "entry-1" ,
13901505 tokensBefore : 1_500 ,
13911506 fileOps : {
@@ -1395,7 +1510,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
13951510 } ,
13961511 settings : { reserveTokens : 4_000 } ,
13971512 previousSummary : undefined ,
1398- isSplitTurn : false ,
1513+ isSplitTurn : true ,
13991514 } ,
14001515 customInstructions : "" ,
14011516 signal : new AbortController ( ) . signal ,
@@ -1407,8 +1522,15 @@ describe("compaction-safeguard recent-turn preservation", () => {
14071522 } ;
14081523
14091524 expect ( result . cancel ) . not . toBe ( true ) ;
1410- expect ( result . compaction ?. summary ) . toContain ( "short summary missing headings" ) ;
1411- expect ( mockSummarizeInStages ) . toHaveBeenCalledTimes ( 2 ) ;
1525+ const summary = result . compaction ?. summary ?? "" ;
1526+ expect ( summary . length ) . toBeLessThanOrEqual ( MAX_COMPACTION_SUMMARY_CHARS ) ;
1527+ expect ( summary ) . toContain ( SUMMARY_TRUNCATED_MARKER ) ;
1528+ expect ( summary ) . toContain ( "**Turn Context (split turn):**" ) ;
1529+ expect ( summary ) . toContain ( splitTurnPrefixSummary ) ;
1530+ expect ( summary ) . toContain ( "## Recent turns preserved verbatim" ) ;
1531+ expect ( summary ) . toContain ( "latest ask status" ) ;
1532+ expect ( summary ) . toContain ( "latest assistant reply" ) ;
1533+ expect ( mockSummarizeInStages ) . toHaveBeenCalledTimes ( 3 ) ;
14121534 } ) ;
14131535
14141536 it ( "keeps required headings when all turns are preserved and history is carried forward" , async ( ) => {
0 commit comments