1use std::sync::Arc;
16use std::sync::atomic::{AtomicUsize, Ordering};
17
18use agent_base::{AgentResult, ChatMessage, Middleware, PreLlmCtx, StreamClient};
19
20use crate::compression::compactor::{ContextCompactor, estimate_message_tokens};
21use crate::compression::config::CompressionConfig;
22use crate::compression::events::{CompressionEvent, CompressionTrigger};
23use crate::compression::filter::is_summary_message;
24use crate::compression::policy::{AutoCompressionPolicy, CompressionPolicy};
25
26fn write_compression_log(
28 session_id: u64,
29 before: &[ChatMessage],
30 after: &[ChatMessage],
31 tokens_before: usize,
32 tokens_after: usize,
33 reduction_pct: i32,
34) {
35 use std::io::Write;
36
37 let dir = std::path::Path::new("/tmp/phi-agent-compression");
38 if let Err(e) = std::fs::create_dir_all(dir) {
39 tracing::warn!("Failed to create compression log dir: {e}");
40 return;
41 }
42
43 let ts = std::time::SystemTime::now()
44 .duration_since(std::time::UNIX_EPOCH)
45 .unwrap_or_default()
46 .as_secs();
47 let filename = dir.join(format!("session_{session_id}_{ts}.json"));
48
49 let log = serde_json::json!({
50 "session_id": session_id,
51 "timestamp": ts,
52 "evaluation": {
53 "tokens_before": tokens_before,
54 "tokens_after": tokens_after,
55 "reduction_pct": reduction_pct,
56 "msg_count_before": before.len(),
57 "msg_count_after": after.len(),
58 },
59 "before": before.iter().map(format_message).collect::<Vec<_>>(),
60 "after": after.iter().map(format_message).collect::<Vec<_>>(),
61 });
62
63 match std::fs::File::create(&filename) {
64 Ok(mut f) => {
65 if let Err(e) = f.write_all(serde_json::to_string_pretty(&log).unwrap().as_bytes()) {
66 tracing::warn!("Failed to write compression log: {e}");
67 } else {
68 tracing::info!("Compression log written to {}", filename.display());
69 }
70 }
71 Err(e) => tracing::warn!("Failed to create compression log file: {e}"),
72 }
73}
74
75fn format_message(msg: &ChatMessage) -> serde_json::Value {
77 match msg {
78 ChatMessage::System { content, .. } => serde_json::json!({
79 "role": "system",
80 "content": content,
81 }),
82 ChatMessage::User { content, .. } => serde_json::json!({
83 "role": "user",
84 "content": content,
85 }),
86 ChatMessage::Assistant {
87 content,
88 tool_calls,
89 ..
90 } => {
91 let mut m = serde_json::json!({
92 "role": "assistant",
93 "content": content,
94 });
95 if let Some(tc) = tool_calls {
96 m["tool_calls"] = serde_json::to_value(tc).unwrap_or_default();
97 }
98 m
99 }
100 ChatMessage::Tool {
101 content,
102 tool_call_id,
103 ..
104 } => serde_json::json!({
105 "role": "tool",
106 "tool_call_id": tool_call_id,
107 "content": content,
108 }),
109 ChatMessage::Custom { role, data } => serde_json::json!({
110 "role": role,
111 "data": data,
112 }),
113 }
114}
115
116pub struct CompressionMiddleware {
137 compactor: ContextCompactor,
138 policy: Box<dyn CompressionPolicy>,
139 last_compressed_msg_count: AtomicUsize,
143}
144
145#[allow(missing_docs)]
146impl CompressionMiddleware {
147 pub fn new(config: CompressionConfig, client: Arc<dyn StreamClient>) -> Self {
151 Self {
152 compactor: ContextCompactor::new(client, config),
153 policy: Box::new(AutoCompressionPolicy),
154 last_compressed_msg_count: AtomicUsize::new(0),
155 }
156 }
157
158 pub fn from_compactor(compactor: ContextCompactor) -> Self {
162 Self {
163 compactor,
164 policy: Box::new(AutoCompressionPolicy),
165 last_compressed_msg_count: AtomicUsize::new(0),
166 }
167 }
168
169 pub fn with_policy(
171 config: CompressionConfig,
172 client: Arc<dyn StreamClient>,
173 policy: Box<dyn CompressionPolicy>,
174 ) -> Self {
175 Self {
176 compactor: ContextCompactor::new(client, config),
177 policy,
178 last_compressed_msg_count: AtomicUsize::new(0),
179 }
180 }
181
182 pub fn compactor(&self) -> &ContextCompactor {
184 &self.compactor
185 }
186
187 pub fn clone_compactor(&self) -> ContextCompactor {
193 self.compactor.clone_handle()
194 }
195
196 pub fn config(&self) -> &CompressionConfig {
198 self.compactor.config()
199 }
200}
201
202#[async_trait::async_trait]
203impl Middleware for CompressionMiddleware {
204 async fn on_pre_llm(&self, ctx: &mut PreLlmCtx) -> AgentResult<()> {
205 let t0 = std::time::Instant::now();
206 let msg_count = ctx.messages.len();
207
208 let keep = self.config().keep_recent_messages;
211 if msg_count <= keep + 1 {
212 return Ok(());
213 }
214
215 let last_compressed = self.last_compressed_msg_count.load(Ordering::Relaxed);
221 let tokens_before: usize = ctx
222 .messages
223 .iter()
224 .skip(last_compressed)
225 .filter(|m| !matches!(m, ChatMessage::System { .. }))
226 .filter(|m| !is_summary_message(m))
227 .map(estimate_message_tokens)
228 .sum();
229 tracing::info!(
230 tokens_before,
231 msg_count,
232 trigger = self.config().trigger_tokens,
233 "[compression-timing] threshold check"
234 );
235
236 if tokens_before <= self.config().trigger_tokens {
238 return Ok(());
239 }
240
241 tracing::info!(
242 elapsed_ms = t0.elapsed().as_millis() as u64,
243 "[compression-timing] threshold passed"
244 );
245
246 if !self.policy.should_compress(tokens_before, msg_count).await {
248 return Ok(());
249 }
250
251 tracing::info!(
252 elapsed_ms = t0.elapsed().as_millis() as u64,
253 "[compression-timing] policy passed, entering compact"
254 );
255
256 let trigger = CompressionTrigger::Auto;
259 let sid = ctx.session_id.id;
260
261 let filtered: Vec<ChatMessage> = ctx
265 .messages
266 .iter()
267 .filter(|m| !is_summary_message(m))
268 .cloned()
269 .collect();
270
271 let keep = self.config().keep_recent_messages;
274 if filtered.len() <= keep + 1 {
275 self.last_compressed_msg_count
276 .store(msg_count, Ordering::Relaxed);
277 return Ok(());
278 }
279
280 let messages_before = ctx.messages.clone();
282 let t_compact = std::time::Instant::now();
283
284 match self
285 .compactor
286 .compact(
287 sid,
288 &filtered,
289 trigger.clone(),
290 Some(&|ev| ctx.emit(ev.into_user_event())),
291 )
292 .await?
293 {
294 Some(compressed) => {
295 tracing::info!(
296 elapsed_ms = t_compact.elapsed().as_millis() as u64,
297 "[compression-timing] compact() returned Some"
298 );
299 ctx.messages = compressed;
300 self.last_compressed_msg_count
305 .store(msg_count, Ordering::Relaxed);
306 let tokens_after: usize = ctx
312 .messages
313 .iter()
314 .filter(|m| !matches!(m, ChatMessage::System { .. }))
315 .map(estimate_message_tokens)
316 .sum();
317 let reduction_pct = if tokens_before > 0 {
318 ((tokens_before as f64 - tokens_after as f64) / tokens_before as f64 * 100.0)
319 .round() as i32
320 } else {
321 0
322 };
323
324 ctx.emit(
326 CompressionEvent::Completed {
327 session_id: sid,
328 tokens_before,
329 tokens_after,
330 reduction_pct,
331 msg_count_before: msg_count,
332 msg_count_after: ctx.messages.len(),
333 trigger,
334 }
335 .into_user_event(),
336 );
337
338 write_compression_log(
340 ctx.session_id.id,
341 &messages_before,
342 &ctx.messages,
343 tokens_before,
344 tokens_after,
345 reduction_pct,
346 );
347 }
348 None => {
349 self.last_compressed_msg_count
356 .store(msg_count, Ordering::Relaxed);
357 }
358 }
359 Ok(())
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::compression::events::CompressionEvent;
367 use crate::compression::policy::{CompressionPolicy, RateLimitPolicy};
368 use agent_base::{
369 AgentResult, ChatMessage, LlmCapabilities, Middleware, PreLlmCtx, ResponseFormat,
370 SessionId, StreamClient,
371 };
372
373 struct MockClient {
377 response: &'static str,
378 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
379 }
380
381 #[async_trait::async_trait]
382 impl StreamClient for MockClient {
383 async fn stream(
384 &self,
385 _messages: &[ChatMessage],
386 _tools: &[serde_json::Value],
387 _reasoning: Option<&agent_base::ReasoningConfig>,
388 _response_format: Option<&ResponseFormat>,
389 ) -> AgentResult<
390 std::pin::Pin<
391 Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
392 >,
393 > {
394 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
395 let response = self.response.to_string();
396 Ok(Box::pin(futures_util::stream::once(async move {
397 Ok(agent_base::StreamChunk::Text(response))
398 })))
399 }
400
401 async fn chat(
402 &self,
403 _messages: &[ChatMessage],
404 _tools: &[serde_json::Value],
405 _reasoning: Option<&agent_base::ReasoningConfig>,
406 _response_format: Option<&ResponseFormat>,
407 ) -> AgentResult<String> {
408 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
409 Ok(self.response.to_string())
410 }
411
412 fn capabilities(&self) -> LlmCapabilities {
413 LlmCapabilities::default()
414 }
415 }
416
417 struct FailingClient;
419
420 #[async_trait::async_trait]
421 impl StreamClient for FailingClient {
422 async fn stream(
423 &self,
424 _messages: &[ChatMessage],
425 _tools: &[serde_json::Value],
426 _reasoning: Option<&agent_base::ReasoningConfig>,
427 _response_format: Option<&ResponseFormat>,
428 ) -> AgentResult<
429 std::pin::Pin<
430 Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
431 >,
432 > {
433 Err(agent_base::AgentError::llm("summarisation failed"))
434 }
435
436 async fn chat(
437 &self,
438 _messages: &[ChatMessage],
439 _tools: &[serde_json::Value],
440 _reasoning: Option<&agent_base::ReasoningConfig>,
441 _response_format: Option<&ResponseFormat>,
442 ) -> AgentResult<String> {
443 Err(agent_base::AgentError::llm("summarisation failed"))
444 }
445
446 fn capabilities(&self) -> LlmCapabilities {
447 LlmCapabilities::default()
448 }
449 }
450
451 struct SpyPolicy {
453 observed: std::sync::Arc<std::sync::Mutex<Vec<(usize, usize)>>>,
455 result: bool,
456 }
457
458 impl SpyPolicy {
459 #[allow(clippy::type_complexity)]
460 fn new(result: bool) -> (Self, std::sync::Arc<std::sync::Mutex<Vec<(usize, usize)>>>) {
461 let observed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
462 (
463 Self {
464 observed: observed.clone(),
465 result,
466 },
467 observed,
468 )
469 }
470 }
471
472 #[async_trait::async_trait]
473 impl CompressionPolicy for SpyPolicy {
474 async fn should_compress(&self, tokens_before: usize, msg_count: usize) -> bool {
475 self.observed
476 .lock()
477 .unwrap()
478 .push((tokens_before, msg_count));
479 self.result
480 }
481 }
482
483 fn make_ctx(messages: Vec<ChatMessage>) -> PreLlmCtx {
484 PreLlmCtx {
485 session_id: SessionId::new(1),
486 messages,
487 tools: vec![],
488 emit_fn: None,
489 }
490 }
491
492 fn make_ctx_with_events(
493 messages: Vec<ChatMessage>,
494 events: std::sync::Arc<std::sync::Mutex<Vec<CompressionEvent>>>,
495 ) -> PreLlmCtx {
496 let events_clone = events.clone();
497 PreLlmCtx {
498 session_id: SessionId::new(1),
499 messages,
500 tools: vec![],
501 emit_fn: Some(Box::new(move |event: agent_base::UserEvent| {
502 if let Some(ev) = CompressionEvent::from_user_event(&event) {
503 events_clone.lock().unwrap().push(ev);
504 }
505 })),
506 }
507 }
508
509 fn make_messages(count: usize) -> Vec<ChatMessage> {
510 let mut msgs = vec![ChatMessage::system("You are a test agent.")];
511 for i in 0..count {
512 msgs.push(ChatMessage::user(format!("question {i}")));
513 msgs.push(ChatMessage::assistant(format!(
514 "answer {i} with some extra content to make the old block large enough"
515 )));
516 }
517 msgs
518 }
519
520 #[tokio::test]
523 async fn test_middleware_noop_when_below_threshold() {
524 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
525 let client = std::sync::Arc::new(MockClient {
526 response: "summary",
527 calls: calls.clone(),
528 });
529 let config = CompressionConfig::default().with_trigger_tokens(999_999);
530 let mw = CompressionMiddleware::new(config, client);
531
532 let msgs = make_messages(5);
533 let original_len = msgs.len();
534 let mut ctx = make_ctx(msgs);
535
536 mw.on_pre_llm(&mut ctx).await.unwrap();
537
538 assert_eq!(ctx.messages.len(), original_len);
539 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
540 }
541
542 #[tokio::test]
543 async fn test_middleware_compresses_when_above_threshold() {
544 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
545 let client = std::sync::Arc::new(MockClient {
546 response: "compressed summary of earlier work",
547 calls: calls.clone(),
548 });
549 let config = CompressionConfig::default()
550 .with_trigger_tokens(1)
551 .with_keep_recent_messages(4);
552 let mw = CompressionMiddleware::new(config, client);
553
554 let msgs = make_messages(20);
555 let mut ctx = make_ctx(msgs);
556
557 mw.on_pre_llm(&mut ctx).await.unwrap();
558
559 assert!(
560 ctx.messages.len() < 41,
561 "expected compression, got {} messages",
562 ctx.messages.len()
563 );
564 assert!(matches!(&ctx.messages[0], ChatMessage::System { .. }));
565
566 use crate::compression::SUMMARY_PREFIX;
567 let has_summary = ctx.messages.iter().any(|m| match m {
568 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
569 _ => false,
570 });
571 assert!(has_summary, "expected summary in compressed output");
572 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
573 }
574
575 #[tokio::test]
576 async fn test_middleware_uses_cache_on_repeated_calls() {
577 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
578 let client = std::sync::Arc::new(MockClient {
579 response: "cached summary",
580 calls: calls.clone(),
581 });
582 let config = CompressionConfig::default()
583 .with_trigger_tokens(1)
584 .with_keep_recent_messages(4);
585 let mw = CompressionMiddleware::new(config, client);
586
587 let msgs = make_messages(20);
588
589 let mut ctx1 = make_ctx(msgs.clone());
590 mw.on_pre_llm(&mut ctx1).await.unwrap();
591 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
592
593 let mut ctx2 = make_ctx(msgs);
594 mw.on_pre_llm(&mut ctx2).await.unwrap();
595 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
596 }
597
598 #[tokio::test]
599 async fn test_middleware_fallback_on_summarisation_failure() {
600 let client = std::sync::Arc::new(FailingClient);
601 let config = CompressionConfig::default()
602 .with_trigger_tokens(1)
603 .with_keep_recent_messages(4);
604 let mw = CompressionMiddleware::new(config, client);
605
606 let msgs = make_messages(20);
607 let mut ctx = make_ctx(msgs);
608
609 mw.on_pre_llm(&mut ctx).await.unwrap();
610
611 assert!(matches!(&ctx.messages[0], ChatMessage::System { .. }));
612
613 use crate::compression::SUMMARY_PREFIX;
614 let has_summary = ctx.messages.iter().any(|m| match m {
615 ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
616 _ => false,
617 });
618 assert!(!has_summary, "should have no summary on failure");
619 }
620
621 #[tokio::test]
622 async fn test_middleware_noop_when_disabled() {
623 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
624 let client = std::sync::Arc::new(MockClient {
625 response: "summary",
626 calls: calls.clone(),
627 });
628 let config = CompressionConfig::default().with_enabled(false);
629 let mw = CompressionMiddleware::new(config, client);
630
631 let msgs = make_messages(20);
632 let original_len = msgs.len();
633 let mut ctx = make_ctx(msgs);
634
635 mw.on_pre_llm(&mut ctx).await.unwrap();
636
637 assert_eq!(ctx.messages.len(), original_len);
638 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
639 }
640
641 #[tokio::test]
642 async fn test_middleware_preserves_system_prompt() {
643 let client = std::sync::Arc::new(MockClient {
644 response: "summary",
645 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
646 });
647 let config = CompressionConfig::default()
648 .with_trigger_tokens(1)
649 .with_keep_recent_messages(4);
650 let mw = CompressionMiddleware::new(config, client);
651
652 let mut msgs = vec![
653 ChatMessage::system("You are helpful."),
654 ChatMessage::system("Extra instructions."),
655 ];
656 for i in 0..20 {
657 msgs.push(ChatMessage::user(format!("q{i}")));
658 msgs.push(ChatMessage::assistant(format!("a{i}")));
659 }
660
661 let mut ctx = make_ctx(msgs);
662 mw.on_pre_llm(&mut ctx).await.unwrap();
663
664 assert!(matches!(&ctx.messages[0], ChatMessage::System { .. }));
665 assert!(matches!(&ctx.messages[1], ChatMessage::System { .. }));
666 match &ctx.messages[0] {
667 ChatMessage::System { content, .. } => assert_eq!(content, "You are helpful."),
668 _ => unreachable!(),
669 }
670 match &ctx.messages[1] {
671 ChatMessage::System { content, .. } => assert_eq!(content, "Extra instructions."),
672 _ => unreachable!(),
673 }
674 }
675
676 #[tokio::test]
677 async fn test_from_compactor_and_accessor() {
678 let client = std::sync::Arc::new(MockClient {
679 response: "s",
680 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
681 });
682 let config = CompressionConfig::default().with_trigger_tokens(42);
683 let compactor = ContextCompactor::new(client, config.clone());
684 let mw = CompressionMiddleware::from_compactor(compactor);
685
686 assert_eq!(mw.config().trigger_tokens, 42);
687 }
688
689 #[tokio::test]
692 async fn test_policy_called_with_correct_args() {
693 let client = std::sync::Arc::new(MockClient {
694 response: "summary",
695 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
696 });
697 let config = CompressionConfig::default()
698 .with_trigger_tokens(1)
699 .with_keep_recent_messages(4);
700 let (policy, observed) = SpyPolicy::new(true);
701 let mw = CompressionMiddleware::with_policy(config, client, Box::new(policy));
702
703 let msgs = make_messages(10);
704 let msg_count = msgs.len();
705 let tokens_est: usize = msgs.iter().map(estimate_message_tokens).sum();
706 let mut ctx = make_ctx(msgs);
707 mw.on_pre_llm(&mut ctx).await.unwrap();
708
709 let spy_calls = observed.lock().unwrap();
711 assert_eq!(spy_calls.len(), 1);
712 assert_eq!(spy_calls[0].1, msg_count);
713 assert!(spy_calls[0].0 > 1);
715 assert!(
717 (spy_calls[0].0 as i64 - tokens_est as i64).unsigned_abs() < 100,
718 "token estimate mismatch: spy={}, computed={}",
719 spy_calls[0].0,
720 tokens_est
721 );
722 }
723
724 #[tokio::test]
725 async fn test_policy_deny_skips_compression() {
726 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
727 let client = std::sync::Arc::new(MockClient {
728 response: "summary",
729 calls: calls.clone(),
730 });
731 let config = CompressionConfig::default()
732 .with_trigger_tokens(1)
733 .with_keep_recent_messages(4);
734 let (policy, _observed) = SpyPolicy::new(false);
735 let mw = CompressionMiddleware::with_policy(config, client, Box::new(policy));
736
737 let msgs = make_messages(20);
738 let original_len = msgs.len();
739 let mut ctx = make_ctx(msgs);
740
741 mw.on_pre_llm(&mut ctx).await.unwrap();
742
743 assert_eq!(ctx.messages.len(), original_len);
745 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
747 }
748
749 #[tokio::test]
750 async fn test_policy_not_called_when_below_threshold() {
751 let client = std::sync::Arc::new(MockClient {
752 response: "summary",
753 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
754 });
755 let config = CompressionConfig::default().with_trigger_tokens(999_999);
756 let (policy, observed) = SpyPolicy::new(true);
757 let mw = CompressionMiddleware::with_policy(config, client, Box::new(policy));
758
759 let msgs = make_messages(5);
760 let mut ctx = make_ctx(msgs);
761
762 mw.on_pre_llm(&mut ctx).await.unwrap();
763
764 let spy_calls = observed.lock().unwrap();
766 assert_eq!(spy_calls.len(), 0);
767 }
768
769 #[tokio::test]
770 async fn test_rate_limit_policy_blocks_repeated_compression() {
771 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
772 let client = std::sync::Arc::new(MockClient {
773 response: "summary",
774 calls: calls.clone(),
775 });
776 let config = CompressionConfig::default()
777 .with_trigger_tokens(1)
778 .with_keep_recent_messages(4);
779 let policy = Box::new(RateLimitPolicy::new(std::time::Duration::from_secs(60)));
781 let mw = CompressionMiddleware::with_policy(config, client, policy);
782
783 let msgs = make_messages(20);
784
785 let mut ctx1 = make_ctx(msgs.clone());
787 mw.on_pre_llm(&mut ctx1).await.unwrap();
788 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
789
790 let mut ctx2 = make_ctx(msgs);
792 mw.on_pre_llm(&mut ctx2).await.unwrap();
793 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
794 }
795
796 #[tokio::test]
799 async fn test_emits_preparing_and_completed_events() {
800 let client = std::sync::Arc::new(MockClient {
801 response: "compressed summary text",
802 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
803 });
804 let config = CompressionConfig::default()
805 .with_trigger_tokens(1)
806 .with_keep_recent_messages(4);
807 let mw = CompressionMiddleware::new(config, client);
808
809 let msgs = make_messages(20);
810 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CompressionEvent>::new()));
811 let mut ctx = make_ctx_with_events(msgs, events.clone());
812
813 mw.on_pre_llm(&mut ctx).await.unwrap();
814
815 let events = events.lock().unwrap();
816 assert!(
818 events.len() >= 3,
819 "expected at least 3 events, got {}",
820 events.len()
821 );
822
823 assert!(
825 matches!(&events[0], CompressionEvent::Preparing { .. }),
826 "first event should be Preparing, got {:?}",
827 events[0]
828 );
829
830 assert!(
832 matches!(&events[1], CompressionEvent::Started { .. }),
833 "second event should be Started, got {:?}",
834 events[1]
835 );
836
837 assert!(
839 matches!(events.last().unwrap(), CompressionEvent::Completed { .. }),
840 "last event should be Completed, got {:?}",
841 events.last().unwrap()
842 );
843
844 if let CompressionEvent::Preparing { trigger, .. } = &events[0] {
846 assert_eq!(*trigger, CompressionTrigger::Auto);
847 }
848 }
849
850 #[tokio::test]
851 async fn test_no_events_when_below_threshold() {
852 let client = std::sync::Arc::new(MockClient {
853 response: "summary",
854 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
855 });
856 let config = CompressionConfig::default().with_trigger_tokens(999_999);
857 let mw = CompressionMiddleware::new(config, client);
858
859 let msgs = make_messages(5);
860 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CompressionEvent>::new()));
861 let mut ctx = make_ctx_with_events(msgs, events.clone());
862
863 mw.on_pre_llm(&mut ctx).await.unwrap();
864
865 let events = events.lock().unwrap();
866 assert!(events.is_empty(), "no events expected below threshold");
867 }
868
869 #[tokio::test]
870 async fn test_no_events_when_policy_denies() {
871 let client = std::sync::Arc::new(MockClient {
872 response: "summary",
873 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
874 });
875 let config = CompressionConfig::default()
876 .with_trigger_tokens(1)
877 .with_keep_recent_messages(4);
878 let (policy, _observed) = SpyPolicy::new(false);
879 let mw = CompressionMiddleware::with_policy(config, client, Box::new(policy));
880
881 let msgs = make_messages(20);
882 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CompressionEvent>::new()));
883 let mut ctx = make_ctx_with_events(msgs, events.clone());
884
885 mw.on_pre_llm(&mut ctx).await.unwrap();
886
887 let events = events.lock().unwrap();
888 assert!(events.is_empty(), "no events expected when policy denies");
889 }
890
891 #[tokio::test]
892 async fn test_messages_unchanged_when_compact_returns_none() {
893 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
896 let client = std::sync::Arc::new(MockClient {
897 response: "summary",
898 calls: calls.clone(),
899 });
900 let config = CompressionConfig::default()
901 .with_trigger_tokens(1)
902 .with_keep_recent_messages(4)
903 .with_enabled(false); let (policy, _observed) = SpyPolicy::new(true);
905 let mw = CompressionMiddleware::with_policy(config, client, Box::new(policy));
906
907 let msgs = make_messages(20);
908 let original_len = msgs.len();
909 let mut ctx = make_ctx(msgs);
910
911 mw.on_pre_llm(&mut ctx).await.unwrap();
912
913 assert_eq!(ctx.messages.len(), original_len);
915 }
916
917 #[tokio::test]
918 async fn test_exact_event_sequence_on_cache_miss() {
919 let client = std::sync::Arc::new(MockClient {
920 response: "compressed summary text",
921 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
922 });
923 let config = CompressionConfig::default()
924 .with_trigger_tokens(1)
925 .with_keep_recent_messages(4);
926 let mw = CompressionMiddleware::new(config, client);
927
928 let msgs = make_messages(20);
929 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CompressionEvent>::new()));
930 let mut ctx = make_ctx_with_events(msgs, events.clone());
931
932 mw.on_pre_llm(&mut ctx).await.unwrap();
933
934 let events = events.lock().unwrap();
935 assert_eq!(
939 events.len(),
940 5,
941 "expected 5 events, got {}: {:?}",
942 events.len(),
943 *events
944 );
945 assert!(matches!(&events[0], CompressionEvent::Preparing { .. }));
946 assert!(matches!(&events[1], CompressionEvent::Started { .. }));
947 assert!(matches!(
948 &events[2],
949 CompressionEvent::Progress { chars: 0, .. }
950 ));
951 assert!(matches!(&events[3], CompressionEvent::Progress { chars, .. } if *chars > 0));
953 assert!(matches!(&events[4], CompressionEvent::Completed { .. }));
954 }
955
956 #[tokio::test]
957 async fn test_event_sequence_on_no_retrigger() {
958 let client = std::sync::Arc::new(MockClient {
961 response: "cached summary",
962 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
963 });
964 let config = CompressionConfig::default()
965 .with_trigger_tokens(1)
966 .with_keep_recent_messages(4);
967 let mw = CompressionMiddleware::new(config, client);
968
969 let msgs = make_messages(20);
970 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CompressionEvent>::new()));
971
972 let mut ctx1 = make_ctx_with_events(msgs.clone(), events.clone());
974 mw.on_pre_llm(&mut ctx1).await.unwrap();
975 assert!(
976 events.lock().unwrap().len() >= 3,
977 "first call should emit events"
978 );
979 events.lock().unwrap().clear();
980
981 let mut ctx2 = make_ctx_with_events(msgs, events.clone());
984 mw.on_pre_llm(&mut ctx2).await.unwrap();
985
986 let events = events.lock().unwrap();
987 assert_eq!(
988 events.len(),
989 0,
990 "same messages: expected 0 events (no re-trigger), got {}",
991 events.len()
992 );
993 }
994
995 #[tokio::test]
996 async fn test_no_retrigger_after_compression() {
997 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1001 let client = std::sync::Arc::new(MockClient {
1002 response: "short",
1003 calls: calls.clone(),
1004 });
1005 let config = CompressionConfig::default()
1006 .with_trigger_tokens(1)
1007 .with_keep_recent_messages(4);
1008 let mw = CompressionMiddleware::new(config, client);
1009
1010 let msgs = make_messages(20);
1011
1012 let mut ctx1 = make_ctx(msgs.clone());
1014 mw.on_pre_llm(&mut ctx1).await.unwrap();
1015 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1016 let compressed_len = ctx1.messages.len();
1017 assert!(compressed_len < msgs.len(), "should have compressed");
1018
1019 let mut ctx2 = make_ctx(ctx1.messages.clone());
1023 mw.on_pre_llm(&mut ctx2).await.unwrap();
1024 assert_eq!(
1025 calls.load(std::sync::atomic::Ordering::SeqCst),
1026 1,
1027 "should NOT have re-triggered compression"
1028 );
1029 }
1030
1031 #[tokio::test]
1034 async fn test_e2e_simulation_30_turns() {
1035 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1036 let client = std::sync::Arc::new(MockClient {
1037 response: "compressed summary of the conversation so far",
1038 calls: calls.clone(),
1039 });
1040 let config = CompressionConfig::default()
1041 .with_trigger_tokens(2000)
1042 .with_keep_recent_messages(4);
1043 let mw = CompressionMiddleware::new(config, client);
1044
1045 let mut messages = vec![ChatMessage::system("You are a helpful assistant.")];
1046 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CompressionEvent>::new()));
1047 let mut compression_count = 0;
1048 let mut compression_turns = Vec::new();
1049 let mut old_block_sizes = Vec::new();
1050
1051 for turn in 0..30 {
1052 messages.push(ChatMessage::user(format!(
1053 "请继续讲故事,这是第{}轮对话。我想听一个关于古代英雄的故事,要有曲折的情节和深刻的寓意,最好能让人有所启发和思考。",
1054 turn + 1
1055 )));
1056 messages.push(ChatMessage::assistant(format!(
1058 "好的,让我继续讲第{}轮的故事。从前有座山,山里有座庙,庙里有个老和尚在讲故事。\
1059 这个故事讲的是从前有座山,山里有座庙,庙里有个老和尚在讲故事。\
1060 故事的内容是关于一个勇敢的冒险者,他走遍了千山万水,经历了无数磨难。\
1061 他遇到了各种各样的人,有善良的农夫,有狡猾的商人,有智慧的老者。\
1062 每个人都给了他不同的启示,让他对人生有了更深的理解。\
1063 他学会了坚韧不拔,学会了与人为善,学会了在困境中寻找希望。\
1064 最终,他回到了家乡,成为了一个受人尊敬的长者,把自己的故事讲给后人听。\
1065 这个故事告诉我们,人生就是一场旅行,重要的不是目的地,而是沿途的风景。",
1066 turn + 1
1067 )));
1068
1069 let mut ctx = make_ctx_with_events(messages.clone(), events.clone());
1070 mw.on_pre_llm(&mut ctx).await.unwrap();
1071 messages = ctx.messages.clone();
1072
1073 let new_compressions = events
1074 .lock()
1075 .unwrap()
1076 .iter()
1077 .filter(|e| matches!(e, CompressionEvent::Completed { .. }))
1078 .count();
1079 let turn_compressions = new_compressions - compression_count;
1080 compression_count = new_compressions;
1081
1082 if turn_compressions > 0 {
1083 compression_turns.push(turn + 1);
1084 if let CompressionEvent::Completed {
1086 msg_count_before,
1087 msg_count_after,
1088 ..
1089 } = events.lock().unwrap().last().unwrap()
1090 {
1091 old_block_sizes.push((*msg_count_before, *msg_count_after));
1092 }
1093 println!(
1094 "[Turn {:2}] COMPRESSED | msgs={:2} → {:2} | llm_calls={}",
1095 turn + 1,
1096 old_block_sizes.last().unwrap().0,
1097 old_block_sizes.last().unwrap().1,
1098 calls.load(std::sync::atomic::Ordering::SeqCst)
1099 );
1100 } else {
1101 println!(
1102 "[Turn {:2}] ok | msgs={:2}",
1103 turn + 1,
1104 messages.len(),
1105 );
1106 }
1107 }
1108
1109 println!("\n=== Simulation Summary ===");
1110 println!("Compression turns: {:?}", compression_turns);
1111 println!("Total compressions: {}", compression_count);
1112 println!(
1113 "LLM calls: {}",
1114 calls.load(std::sync::atomic::Ordering::SeqCst)
1115 );
1116 println!("Final messages: {}", messages.len());
1117 println!("Old block before/after: {:?}", old_block_sizes);
1118
1119 assert!(
1121 compression_count >= 2,
1122 "expected at least 2 compressions, got {}",
1123 compression_count
1124 );
1125
1126 assert_eq!(
1128 calls.load(std::sync::atomic::Ordering::SeqCst),
1129 compression_count,
1130 );
1131
1132 for window in compression_turns.windows(2) {
1134 assert!(
1135 window[1] - window[0] >= 2,
1136 "consecutive compressions at turns {:?}",
1137 window
1138 );
1139 }
1140
1141 assert!(
1145 messages.len() < 61,
1146 "final messages should be < 61 (uncompressed), got {}",
1147 messages.len()
1148 );
1149 }
1150}