Skip to main content

agent_works/compression/
middleware.rs

1//! Middleware integration for context compression.
2//!
3//! [`CompressionMiddleware`] implements the [`agent_base::Middleware`] trait,
4//! compressing the per-LLM-call message list on every `on_pre_llm` invocation.
5//!
6//! The middleware delegates to [`ContextCompactor`] which handles the hybrid
7//! retention strategy (system prompt + summary + recent N messages) and the
8//! stable-prefix cache.  The session's stored history is **never** modified;
9//! only the mutable message copy in [`PreLlmCtx`] is replaced.
10//!
11//! A [`CompressionPolicy`] controls whether compression proceeds once the token
12//! threshold is crossed.  The default [`AutoCompressionPolicy`] always proceeds;
13//! custom policies can add user confirmation, rate limiting, etc.
14
15use 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
26/// Write compression before/after log to `/tmp/phi-agent-compression/`.
27fn 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
75/// Format a ChatMessage into a readable JSON value for logging.
76fn 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
116/// Middleware that compresses conversation history before each LLM call.
117///
118/// Wraps a [`ContextCompactor`] and forwards `on_pre_llm` to its [`compact`]
119/// method.  When the estimated token count is below [`CompressionConfig::trigger_tokens`],
120/// the middleware is a no-op and the original messages pass through unchanged.
121///
122/// A [`CompressionPolicy`] controls whether compression actually proceeds once
123/// the threshold is crossed.  Use [`with_policy`](Self::with_policy) to supply
124/// a custom policy (e.g. user confirmation, rate limiting).
125///
126/// # Usage
127///
128/// ```ignore
129/// use agent_works::compression::{CompressionConfig, CompressionMiddleware};
130///
131/// let mw = CompressionMiddleware::new(config, client);
132/// builder.middleware(mw);
133/// ```
134///
135/// [`compact`]: ContextCompactor::compact
136pub struct CompressionMiddleware {
137    compactor: ContextCompactor,
138    policy: Box<dyn CompressionPolicy>,
139    /// Message count after the last successful compression.
140    /// Used to calculate only the *new* old-block tokens for threshold checks,
141    /// preventing re-triggering caused by recent messages rolling into the old block.
142    last_compressed_msg_count: AtomicUsize,
143}
144
145#[allow(missing_docs)]
146impl CompressionMiddleware {
147    /// Create a new middleware with the given config and LLM client.
148    ///
149    /// Uses [`AutoCompressionPolicy`] (always compress when threshold is hit).
150    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    /// Create a new middleware from an existing [`ContextCompactor`].
159    ///
160    /// Uses [`AutoCompressionPolicy`].
161    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    /// Create a new middleware with a custom [`CompressionPolicy`].
170    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    /// Access the inner compactor (e.g. for `/compact` or cache clearing).
183    pub fn compactor(&self) -> &ContextCompactor {
184        &self.compactor
185    }
186
187    /// Create a cloned handle of the inner compactor.
188    ///
189    /// The clone shares the same cache — clearing it through either handle
190    /// affects both.  Useful for storing a separate handle outside the
191    /// middleware (e.g. in `PhiAgent` for `/compact` access).
192    pub fn clone_compactor(&self) -> ContextCompactor {
193        self.compactor.clone_handle()
194    }
195
196    /// Access the compression config.
197    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        // Quick check — below minimum message count, skip entirely.
209        // Need at least system + keep_recent + 1 to have anything to compress.
210        let keep = self.config().keep_recent_messages;
211        if msg_count <= keep + 1 {
212            return Ok(());
213        }
214
215        // Only count tokens added SINCE the last compression (new old-block
216        // content).  This prevents re-triggering caused by recent messages
217        // rolling into the old block after compression.
218        //
219        // Also skip system and existing summary messages.
220        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        // Quick check — below threshold, skip entirely.
237        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        // Policy check — ask policy whether to proceed.
247        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        // Determine trigger type (manual if /compact, auto otherwise).
257        // For now, middleware is always auto; /compact goes through a different path.
258        let trigger = CompressionTrigger::Auto;
259        let sid = ctx.session_id.id;
260
261        // Discard old summary before re-compressing.
262        // compact() handles preserving user messages and summarizing assistant/tool.
263        // We only need to remove the old summary so it doesn't get re-summarized.
264        let filtered: Vec<ChatMessage> = ctx
265            .messages
266            .iter()
267            .filter(|m| !is_summary_message(m))
268            .cloned()
269            .collect();
270
271        // After filtering, check if there's enough content to compress.
272        // Need more than keep_recent messages to have an old block at all.
273        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        // Save messages before compression for logging.
281        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                // Record the pre-compression message count so the next threshold
301                // check only counts new content added since this compression.
302                // Using pre-compression count (not compressed length) because
303                // skip() operates on the full message array structure.
304                self.last_compressed_msg_count
305                    .store(msg_count, Ordering::Relaxed);
306                // Count tokens after compression (same scope as tokens_before).
307                // tokens_before counts all non-system, non-summary messages from last_compressed.
308                // tokens_after should count the same scope after compression.
309                // Since compression replaces the old block with a summary, we count
310                // all non-system messages in the compressed result (including the new summary).
311                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                // Send Completed event.
325                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 before/after log to /tmp/phi-agent-compression/.
339                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                // Compression skipped (threshold not reached, disabled, or too few messages).
350                // This is a normal no-op — do NOT send Failed event.
351                // compact() is pure and never modified ctx.messages, so no restore needed.
352                //
353                // Still update the checkpoint so the next threshold check only
354                // counts truly new content, preventing a re-trigger loop.
355                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    // ── Test helpers ──────────────────────────────────────────────────────
374
375    /// Minimal mock that returns a fixed string and counts calls.
376    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    /// Mock that always returns an error.
418    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    /// Policy that records calls and returns a fixed value.
452    struct SpyPolicy {
453        /// `(tokens_before, msg_count)` for each call.
454        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    // ── CompressionMiddleware::on_pre_llm ─────────────────────────────────
521
522    #[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    // ── Policy integration ────────────────────────────────────────────────
690
691    #[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        // Policy should have been called once.
710        let spy_calls = observed.lock().unwrap();
711        assert_eq!(spy_calls.len(), 1);
712        assert_eq!(spy_calls[0].1, msg_count);
713        // tokens_before should be > trigger_tokens (which is 1).
714        assert!(spy_calls[0].0 > 1);
715        // Approximate match — spy should receive roughly the same token estimate.
716        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        // Messages should be unchanged — policy denied.
744        assert_eq!(ctx.messages.len(), original_len);
745        // No LLM call should have been made.
746        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        // Policy should NOT have been called — below threshold.
765        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        // 60-second rate limit — second call should be blocked.
780        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        // First call — should compress.
786        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        // Second call immediately — rate limited, no LLM call.
791        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    // ── Typed event emission ──────────────────────────────────────────────
797
798    #[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        // Should have: Preparing (from compact), Started (from compact), Progress, Completed.
817        assert!(
818            events.len() >= 3,
819            "expected at least 3 events, got {}",
820            events.len()
821        );
822
823        // First event should be Preparing.
824        assert!(
825            matches!(&events[0], CompressionEvent::Preparing { .. }),
826            "first event should be Preparing, got {:?}",
827            events[0]
828        );
829
830        // Second event should be Started.
831        assert!(
832            matches!(&events[1], CompressionEvent::Started { .. }),
833            "second event should be Started, got {:?}",
834            events[1]
835        );
836
837        // Last event should be Completed.
838        assert!(
839            matches!(events.last().unwrap(), CompressionEvent::Completed { .. }),
840            "last event should be Completed, got {:?}",
841            events.last().unwrap()
842        );
843
844        // Verify Preparing has trigger = Auto.
845        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        // compact() returns None when disabled or too few messages —
894        // compact() is pure so ctx.messages is never mutated.
895        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); // disabled → compact() returns None
904        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        // Messages unchanged — compact() never modified them.
914        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        // Exact sequence: Preparing → Started → Progress(0) → Progress(N) → Completed
936        // Preparing = compact emits Preparing first, then Started,
937        // Progress(0) = "Connecting to LLM", Progress(N) = streaming chars from summarizer.
938        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        // events[3] is Progress with actual char count from the mock response.
952        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        // After compression, calling with the same messages should NOT re-trigger
959        // because last_compressed_msg_count was set to the pre-compression count.
960        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        // First call — should compress and emit events.
973        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        // Second call with the same messages — should NOT re-trigger.
982        // last_compressed=20, skip(20) → 0 new messages → below threshold.
983        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        // After successful compression, the next call with the compressed messages
998        // should NOT re-trigger compression because last_compressed_msg_count
999        // was set to the pre-compression message count, so skip() returns 0 new messages.
1000        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        // First call — should compress (20 messages > trigger_tokens=1).
1013        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        // Second call with the compressed messages (realistic: next turn reloads
1020        // compressed state from disk). last_compressed=20 (pre-compression count),
1021        // skip(20) on compressed_len messages → 0 messages → no re-trigger.
1022        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    /// End-to-end simulation: 30 turns of long conversation.
1032    /// Verifies compression triggers, no re-trigger, and old block stability.
1033    #[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            // Longer assistant response (~500 chars) to accumulate tokens faster.
1057            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                // Record old block size from the compression event.
1085                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        // Verify: at least 2 compressions in 30 turns.
1120        assert!(
1121            compression_count >= 2,
1122            "expected at least 2 compressions, got {}",
1123            compression_count
1124        );
1125
1126        // Verify: LLM calls == compression count (no wasted calls).
1127        assert_eq!(
1128            calls.load(std::sync::atomic::Ordering::SeqCst),
1129            compression_count,
1130        );
1131
1132        // Verify: no two compressions on consecutive turns.
1133        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        // Verify: final message count is reasonable.
1142        // With "preserve user messages" strategy, users accumulate, so the count
1143        // grows over time.  But it should be less than uncompressed (61 msgs).
1144        assert!(
1145            messages.len() < 61,
1146            "final messages should be < 61 (uncompressed), got {}",
1147            messages.len()
1148        );
1149    }
1150}