@@ -335,6 +335,248 @@ describe("createPruningCompactor — image aging", () => {
335335 } ) ;
336336} ) ;
337337
338+ describe ( "createPruningCompactor — error anchoring (CL-6906)" , ( ) => {
339+ function assistantErrorCall ( id : string , name : string ) : ConversationTurn {
340+ return makeTurn ( {
341+ role : "assistant" ,
342+ content : [ { type : "tool_call" , id, name, arguments : { } } ] ,
343+ } ) ;
344+ }
345+ function errorResult ( callId : string , text : string ) : ConversationTurn {
346+ return makeTurn ( {
347+ role : "user" ,
348+ content : [ { type : "tool_result" , callId, content : [ { type : "text" , text } ] , isError : true } ] ,
349+ } ) ;
350+ }
351+ function padding ( n : number , prefix : string ) : ConversationTurn [ ] {
352+ return Array . from ( { length : n } , ( _ , i ) =>
353+ makeTurn ( {
354+ role : i % 2 === 0 ? "assistant" : "user" ,
355+ content : [ { type : "text" , text : `${ prefix } ${ i } ` } ] ,
356+ } ) ,
357+ ) ;
358+ }
359+
360+ test ( "a lone errored tool_result no longer anchors on its own" , async ( ) => {
361+ const turns : ConversationTurn [ ] = [
362+ makeTurn ( { role : "user" , content : [ { type : "text" , text : "the initiating task" } ] } ) ,
363+ ...padding ( 3 , "before" ) ,
364+ assistantErrorCall ( "e1" , "run_shell" ) ,
365+ errorResult ( "e1" , "Error: exit code 1 " + "x" . repeat ( 100 ) ) ,
366+ ...padding ( 8 , "after" ) ,
367+ ] ;
368+ const compactor = createPruningCompactor ( {
369+ keepRecentTurns : 6 ,
370+ maxAnchorTurns : 8 ,
371+ summaryMaxChars : 2000 ,
372+ } ) ;
373+ const { output } = await compactor . apply ( turns , mockStrategyCtx ) ;
374+ // The lone error's own turn score (3) sits below the anchor threshold (5),
375+ // so its body must not survive verbatim outside the recent window.
376+ const survivedVerbatim = output . some ( ( t ) =>
377+ t . content . some ( ( b ) => b . type === "tool_result" && b . callId === "e1" ) ,
378+ ) ;
379+ expect ( survivedVerbatim ) . toBe ( false ) ;
380+ } ) ;
381+
382+ test ( "two distinct errors on one turn still clear the anchor threshold" , async ( ) => {
383+ const turns : ConversationTurn [ ] = [
384+ makeTurn ( { role : "user" , content : [ { type : "text" , text : "the initiating task" } ] } ) ,
385+ ...padding ( 3 , "before" ) ,
386+ makeTurn ( {
387+ role : "assistant" ,
388+ content : [
389+ { type : "tool_call" , id : "d1" , name : "run_shell" , arguments : { } } ,
390+ { type : "tool_call" , id : "d2" , name : "grep" , arguments : { } } ,
391+ ] ,
392+ } ) ,
393+ makeTurn ( {
394+ role : "user" ,
395+ content : [
396+ {
397+ type : "tool_result" ,
398+ callId : "d1" ,
399+ content : [ { type : "text" , text : "Error: build failed" } ] ,
400+ isError : true ,
401+ } ,
402+ {
403+ type : "tool_result" ,
404+ callId : "d2" ,
405+ content : [ { type : "text" , text : "Error: no matches found" } ] ,
406+ isError : true ,
407+ } ,
408+ ] ,
409+ } ) ,
410+ ...padding ( 8 , "after" ) ,
411+ ] ;
412+ const compactor = createPruningCompactor ( {
413+ keepRecentTurns : 6 ,
414+ maxAnchorTurns : 8 ,
415+ summaryMaxChars : 2000 ,
416+ } ) ;
417+ const { output } = await compactor . apply ( turns , mockStrategyCtx ) ;
418+ const kept = output . find ( ( t ) =>
419+ t . content . some ( ( b ) => b . type === "tool_result" && b . callId === "d1" ) ,
420+ ) ;
421+ expect ( kept ) . toBeDefined ( ) ;
422+ expect ( kept ?. content . some ( ( b ) => b . type === "tool_result" && b . callId === "d2" ) ) . toBe ( true ) ;
423+ } ) ;
424+
425+ test ( "repeated identical errors collapse to one representative before anchor selection" , async ( ) => {
426+ // "old" repeats the same (tool, error-text) signature that recurs again
427+ // later ("recur"); combined with a distinct error on the same turn, the
428+ // uncollapsed score (3 + 3 = 6) would clear the threshold, but the
429+ // collapsed score (0 + 3 = 3) must not.
430+ const sharedErrorText = "Error: type mismatch on line 12, expected string" ;
431+ const turns : ConversationTurn [ ] = [
432+ makeTurn ( { role : "user" , content : [ { type : "text" , text : "the initiating task" } ] } ) ,
433+ ...padding ( 3 , "before" ) ,
434+ makeTurn ( {
435+ role : "assistant" ,
436+ content : [
437+ { type : "tool_call" , id : "old" , name : "edit_file_check" , arguments : { } } ,
438+ { type : "tool_call" , id : "uniq" , name : "grep" , arguments : { } } ,
439+ ] ,
440+ } ) ,
441+ makeTurn ( {
442+ role : "user" ,
443+ content : [
444+ {
445+ type : "tool_result" ,
446+ callId : "old" ,
447+ content : [ { type : "text" , text : sharedErrorText } ] ,
448+ isError : true ,
449+ } ,
450+ {
451+ type : "tool_result" ,
452+ callId : "uniq" ,
453+ content : [ { type : "text" , text : "Error: distinct failure here" } ] ,
454+ isError : true ,
455+ } ,
456+ ] ,
457+ } ) ,
458+ ...padding ( 4 , "mid" ) ,
459+ assistantErrorCall ( "recur" , "edit_file_check" ) ,
460+ errorResult ( "recur" , sharedErrorText ) ,
461+ ...padding ( 8 , "after" ) ,
462+ ] ;
463+ const compactor = createPruningCompactor ( {
464+ keepRecentTurns : 6 ,
465+ maxAnchorTurns : 8 ,
466+ summaryMaxChars : 2000 ,
467+ } ) ;
468+ const { output, record } = await compactor . apply ( turns , mockStrategyCtx ) ;
469+ expect ( record . decisions [ "repeatedErrorCount" ] ) . toBe ( 1 ) ;
470+ // The combined turn's score drops below threshold once "old" is
471+ // collapsed, so neither of its results survives verbatim.
472+ const oldSurvived = output . some ( ( t ) =>
473+ t . content . some ( ( b ) => b . type === "tool_result" && b . callId === "old" ) ,
474+ ) ;
475+ const uniqSurvived = output . some ( ( t ) =>
476+ t . content . some ( ( b ) => b . type === "tool_result" && b . callId === "uniq" ) ,
477+ ) ;
478+ expect ( oldSurvived ) . toBe ( false ) ;
479+ expect ( uniqSurvived ) . toBe ( false ) ;
480+ } ) ;
481+ } ) ;
482+
483+ describe ( "createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906)" , ( ) => {
484+ test ( "bounds the total scored-anchor pull even when many high-score pairs are scattered through history" , async ( ) => {
485+ const turns : ConversationTurn [ ] = [
486+ makeTurn ( { role : "user" , content : [ { type : "text" , text : "the initiating task" } ] } ) ,
487+ ] ;
488+ // 10 write-pair call/result turns, well separated from each other and from
489+ // the recent window. A single edit_file scores 3 (below the threshold of
490+ // 5); two writes on the same assistant turn score 6, so each pair
491+ // independently clears the scored-anchor bar.
492+ for ( let i = 0 ; i < 10 ; i ++ ) {
493+ turns . push (
494+ makeTurn ( {
495+ role : "assistant" ,
496+ content : [
497+ {
498+ type : "tool_call" ,
499+ id : `edit${ i } a` ,
500+ name : "edit_file" ,
501+ arguments : { path : `f${ i } a.ts` } ,
502+ } ,
503+ {
504+ type : "tool_call" ,
505+ id : `edit${ i } b` ,
506+ name : "edit_file" ,
507+ arguments : { path : `f${ i } b.ts` } ,
508+ } ,
509+ ] ,
510+ } ) ,
511+ makeTurn ( {
512+ role : "user" ,
513+ content : [
514+ {
515+ type : "tool_result" ,
516+ callId : `edit${ i } a` ,
517+ content : [ { type : "text" , text : `edited f${ i } a.ts` } ] ,
518+ } ,
519+ {
520+ type : "tool_result" ,
521+ callId : `edit${ i } b` ,
522+ content : [ { type : "text" , text : `edited f${ i } b.ts` } ] ,
523+ } ,
524+ ] ,
525+ } ) ,
526+ makeTurn ( { role : "assistant" , content : [ { type : "text" , text : `note ${ i } ` } ] } ) ,
527+ makeTurn ( { role : "user" , content : [ { type : "text" , text : `ask ${ i } ` } ] } ) ,
528+ ) ;
529+ }
530+ for ( let i = 0 ; i < 6 ; i ++ ) {
531+ turns . push (
532+ makeTurn ( {
533+ role : i % 2 === 0 ? "assistant" : "user" ,
534+ content : [ { type : "text" , text : `recent${ i } ` } ] ,
535+ } ) ,
536+ ) ;
537+ }
538+
539+ const maxAnchorTurns = 4 ;
540+ const compactor = createPruningCompactor ( {
541+ keepRecentTurns : 6 ,
542+ maxAnchorTurns,
543+ summaryMaxChars : 2000 ,
544+ } ) ;
545+ const { record } = await compactor . apply ( turns , mockStrategyCtx ) ;
546+ // The initiating task (1 turn, no partners) is kept outside the cap; the
547+ // scored/pair-partner pull must stay within maxAnchorTurns.
548+ const anchorTurnCount = record . decisions [ "anchorTurnCount" ] as number ;
549+ expect ( anchorTurnCount - 1 ) . toBeLessThanOrEqual ( maxAnchorTurns ) ;
550+ // With a budget of 4 and each edit pair costing 2 (call + result), exactly
551+ // two pairs (the most recent two) fit; a third would overshoot and must
552+ // be rejected as a whole, not split.
553+ expect ( anchorTurnCount ) . toBe ( 1 + 4 ) ;
554+ } ) ;
555+ } ) ;
556+
557+ describe ( "createPruningCompactor — summarize receives the workflow context (CL-6906)" , ( ) => {
558+ test ( "passes cfg.summaryContext() through to summarize as the second argument" , async ( ) => {
559+ let capturedCtx : unknown = "not called" ;
560+ const workflowCtx = { workflow : { name : "build" , stepIndex : 2 , total : 7 } } ;
561+ const compactor = createPruningCompactor ( {
562+ keepRecentTurns : 1 ,
563+ summaryMaxChars : 500 ,
564+ summaryContext : ( ) => workflowCtx ,
565+ summarize : async ( _turns , ctx ) => {
566+ capturedCtx = ctx ;
567+ return "summary text" ;
568+ } ,
569+ } ) ;
570+ const turns : ConversationTurn [ ] = [
571+ makeTurn ( { role : "assistant" , content : [ { type : "text" , text : "a" } ] } ) ,
572+ makeTurn ( { role : "assistant" , content : [ { type : "text" , text : "b" } ] } ) ,
573+ makeTurn ( { role : "user" , content : [ { type : "text" , text : "recent" } ] } ) ,
574+ ] ;
575+ await compactor . apply ( turns , mockStrategyCtx ) ;
576+ expect ( capturedCtx ) . toBe ( workflowCtx ) ;
577+ } ) ;
578+ } ) ;
579+
338580describe ( "buildContextEnvelope" , ( ) => {
339581 test ( "includes active task label" , ( ) => {
340582 const result = buildContextEnvelope ( {
0 commit comments