@@ -10,7 +10,7 @@ use crate::harness::context::{RunConfig, RunContext};
1010use crate :: harness:: events:: { AgentEvent , RecordingListener } ;
1111use crate :: harness:: message:: { AssistantMessage , ContentBlock , Message , UserMessage } ;
1212use crate :: harness:: model:: { ModelRequest , ModelResponse , PromptSegment , SegmentRole } ;
13- use crate :: harness:: summarization:: { SummarizationPolicy , TrimStrategy } ;
13+ use crate :: harness:: summarization:: { SummarizationPolicy , Summarizer , SummaryRecord , TrimStrategy } ;
1414use crate :: harness:: tool:: { ToolCall , ToolResult } ;
1515use crate :: harness:: usage:: Usage ;
1616
@@ -397,6 +397,173 @@ async fn context_compression_compresses_at_or_above_threshold() {
397397 assert ! ( compressed[ 0 ] . 1 > 0 ) ;
398398}
399399
400+ /// A [`Summarizer`] that always fails — models the real gap the
401+ /// `ConcatSummarizer` can never exhibit (a model-backed summarizer whose
402+ /// provider call is rejected).
403+ struct FailingSummarizer ;
404+
405+ #[ async_trait]
406+ impl Summarizer for FailingSummarizer {
407+ async fn summarize ( & self , _messages : & [ Message ] ) -> Result < SummaryRecord > {
408+ Err ( TinyAgentsError :: Model ( "summarizer boom" . to_string ( ) ) )
409+ }
410+ }
411+
412+ /// Build an over-threshold transcript `[system, big-1, big-2, big-3]` under a
413+ /// 100-token window / 0.5 threshold (→ 50-token trigger budget), so the
414+ /// compression middleware is guaranteed to fire.
415+ fn over_threshold_request ( ) -> ( SummarizationPolicy , Vec < Message > ) {
416+ let policy = SummarizationPolicy {
417+ keep_last : 1 ,
418+ ..SummarizationPolicy :: default ( )
419+ }
420+ . with_context_window ( 100 )
421+ . with_threshold_fraction ( 0.5 ) ;
422+ let big = "a" . repeat ( 200 ) ;
423+ let messages = vec ! [
424+ Message :: system( "You are a helpful assistant." ) ,
425+ user( & format!( "{big}-1" ) ) ,
426+ user( & format!( "{big}-2" ) ) ,
427+ user( & format!( "{big}-3" ) ) ,
428+ ] ;
429+ ( policy, messages)
430+ }
431+
432+ #[ tokio:: test]
433+ async fn context_compression_falls_back_to_trim_when_summarizer_errors ( ) {
434+ // Regression for the "summarizer failure aborts the run" gap: under the
435+ // default FallbackTrim policy a failing summarizer must NOT abort. The
436+ // middleware front-drops the transcript to the trigger budget (keeping the
437+ // leading system prompt) and continues, emitting a MiddlewareFailed
438+ // diagnostic plus a Compressed event for the trim.
439+ let ( policy, before) = over_threshold_request ( ) ;
440+ let mw = Arc :: new ( ContextCompressionMiddleware :: with_summarizer (
441+ policy,
442+ Box :: new ( FailingSummarizer ) ,
443+ ) ) ;
444+ // Default failure policy is the trim fallback.
445+ assert_eq ! ( mw. failure_policy( ) , CompressionFailurePolicy :: FallbackTrim ) ;
446+
447+ let mut stack: MiddlewareStack < ( ) > = MiddlewareStack :: new ( ) ;
448+ stack. push ( mw. clone ( ) ) ;
449+
450+ let recorder = Arc :: new ( RecordingListener :: new ( ) ) ;
451+ let mut c = ctx ( ) ;
452+ c. events . subscribe ( recorder. clone ( ) ) ;
453+
454+ let mut request = ModelRequest {
455+ messages : before. clone ( ) ,
456+ ..Default :: default ( )
457+ } ;
458+ stack
459+ . run_before_model ( & mut c, & ( ) , & mut request)
460+ . await
461+ . expect ( "summarizer failure must not abort the run under FallbackTrim" ) ;
462+
463+ // Trimmed from the front — strictly fewer messages — with the system prompt
464+ // preserved (MaxTokens drops system messages last).
465+ assert ! ( request. messages. len( ) < before. len( ) ) ;
466+ assert ! ( matches!( request. messages[ 0 ] , Message :: System ( _) ) ) ;
467+ // No summary record could be produced (the summarizer failed).
468+ assert ! ( mw. records( ) . is_empty( ) ) ;
469+
470+ let events: Vec < AgentEvent > = recorder. events ( ) . into_iter ( ) . map ( |r| r. event ) . collect ( ) ;
471+ assert ! (
472+ events. iter( ) . any( |e| matches!(
473+ e,
474+ AgentEvent :: MiddlewareFailed { name, error }
475+ if name == "context_compression" && error. contains( "summarizer boom" )
476+ ) ) ,
477+ "a MiddlewareFailed diagnostic naming the failure must be emitted: {events:?}"
478+ ) ;
479+ assert ! (
480+ events
481+ . iter( )
482+ . any( |e| matches!( e, AgentEvent :: Compressed { .. } ) ) ,
483+ "the fallback trim must emit a Compressed event: {events:?}"
484+ ) ;
485+ }
486+
487+ #[ tokio:: test]
488+ async fn context_compression_abort_policy_propagates_summarizer_error ( ) {
489+ // Opt back into the legacy behaviour: Abort propagates the error so the run
490+ // fails, but still emits the MiddlewareFailed diagnostic first.
491+ let ( policy, before) = over_threshold_request ( ) ;
492+ let mw = Arc :: new (
493+ ContextCompressionMiddleware :: with_summarizer ( policy, Box :: new ( FailingSummarizer ) )
494+ . with_failure_policy ( CompressionFailurePolicy :: Abort ) ,
495+ ) ;
496+ let mut stack: MiddlewareStack < ( ) > = MiddlewareStack :: new ( ) ;
497+ stack. push ( mw. clone ( ) ) ;
498+
499+ let recorder = Arc :: new ( RecordingListener :: new ( ) ) ;
500+ let mut c = ctx ( ) ;
501+ c. events . subscribe ( recorder. clone ( ) ) ;
502+
503+ let mut request = ModelRequest {
504+ messages : before,
505+ ..Default :: default ( )
506+ } ;
507+ let result = stack. run_before_model ( & mut c, & ( ) , & mut request) . await ;
508+ assert ! (
509+ result. is_err( ) ,
510+ "Abort must propagate the summarizer error and fail the run"
511+ ) ;
512+
513+ let events: Vec < AgentEvent > = recorder. events ( ) . into_iter ( ) . map ( |r| r. event ) . collect ( ) ;
514+ assert ! (
515+ events. iter( ) . any( |e| matches!(
516+ e,
517+ AgentEvent :: MiddlewareFailed { name, .. } if name == "context_compression"
518+ ) ) ,
519+ "Abort must still emit the MiddlewareFailed diagnostic: {events:?}"
520+ ) ;
521+ }
522+
523+ #[ tokio:: test]
524+ async fn context_compression_pass_through_policy_keeps_transcript_and_continues ( ) {
525+ // PassThrough leaves the (over-threshold) transcript untouched and lets the
526+ // run continue, emitting only the MiddlewareFailed diagnostic.
527+ let ( policy, before) = over_threshold_request ( ) ;
528+ let mw = Arc :: new (
529+ ContextCompressionMiddleware :: with_summarizer ( policy, Box :: new ( FailingSummarizer ) )
530+ . with_failure_policy ( CompressionFailurePolicy :: PassThrough ) ,
531+ ) ;
532+ let mut stack: MiddlewareStack < ( ) > = MiddlewareStack :: new ( ) ;
533+ stack. push ( mw. clone ( ) ) ;
534+
535+ let recorder = Arc :: new ( RecordingListener :: new ( ) ) ;
536+ let mut c = ctx ( ) ;
537+ c. events . subscribe ( recorder. clone ( ) ) ;
538+
539+ let mut request = ModelRequest {
540+ messages : before. clone ( ) ,
541+ ..Default :: default ( )
542+ } ;
543+ stack
544+ . run_before_model ( & mut c, & ( ) , & mut request)
545+ . await
546+ . expect ( "PassThrough must not abort the run" ) ;
547+
548+ // Transcript is untouched.
549+ assert_eq ! ( request. messages, before) ;
550+ let events: Vec < AgentEvent > = recorder. events ( ) . into_iter ( ) . map ( |r| r. event ) . collect ( ) ;
551+ assert ! (
552+ events. iter( ) . any( |e| matches!(
553+ e,
554+ AgentEvent :: MiddlewareFailed { name, .. } if name == "context_compression"
555+ ) ) ,
556+ "PassThrough must emit the MiddlewareFailed diagnostic: {events:?}"
557+ ) ;
558+ // No compression happened, so no Compressed event.
559+ assert ! (
560+ !events
561+ . iter( )
562+ . any( |e| matches!( e, AgentEvent :: Compressed { .. } ) ) ,
563+ "PassThrough must not emit a Compressed event: {events:?}"
564+ ) ;
565+ }
566+
400567#[ tokio:: test]
401568async fn context_compression_records_are_bounded_by_max_records ( ) {
402569 // A long-running loop that compresses repeatedly must not grow the
0 commit comments