Skip to main content

agent_works/compression/
compactor.rs

1//! Core compression logic — hybrid retention + stable-prefix cache.
2//!
3//! [`ContextCompactor`] takes a full message history and produces a compressed
4//! copy that preserves:
5//!
6//! 1. The system prompt (verbatim).
7//! 2. The most recent N messages (verbatim, including tool results).
8//! 3. A handoff summary of the older history (generated by LLM, cached).
9//!
10//! The compactor is **stateful** — it caches summaries keyed on a stable prefix
11//! hash so repeated calls with the same old block skip the LLM round-trip.
12
13use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::sync::{Arc, Mutex};
16
17use agent_base::{
18    AgentResult, AgentRuntime, ChatMessage, ContextWindowManager, SessionId, StreamClient,
19};
20
21use crate::compression::config::CompressionConfig;
22use crate::compression::events::{CompressionEvent, CompressionTrigger};
23use crate::compression::filter::{SUMMARY_PREFIX, is_summary_message, split_system_prompt};
24use crate::compression::summarizer::summarize;
25
26// ── Public API ───────────────────────────────────────────────────────────────
27
28/// Cached summary entry: `(transcript_len, summary_text)`.
29///
30/// `transcript_len` guards against reusing a stale summary when the old block
31/// has grown significantly since the cache was populated.
32type CompactionCache = std::collections::HashMap<(u64, u64), (usize, String)>;
33
34/// Core compressor implementing the hybrid retention strategy.
35///
36/// Holds a [`StreamClient`] for LLM summarisation and a thread-safe cache to
37/// avoid redundant calls.  Designed to be shared via `Arc` across middleware
38/// invocations.
39///
40/// Use [`clone()`](Self::clone) to create a second handle that shares the same
41/// underlying cache — useful when the compactor is registered as middleware *and*
42/// stored separately for manual `/compact` access.
43pub struct ContextCompactor {
44    client: Arc<dyn StreamClient>,
45    config: CompressionConfig,
46    /// `(session_id, prefix_hash) → (transcript_len, summary)` cache.
47    /// Wrapped in `Arc` so that [`clone()`](Self::clone) shares the same cache.
48    cache: Arc<Mutex<CompactionCache>>,
49}
50
51#[allow(missing_docs)]
52impl ContextCompactor {
53    pub fn new(client: Arc<dyn StreamClient>, config: CompressionConfig) -> Self {
54        Self {
55            client,
56            config,
57            cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
58        }
59    }
60
61    /// Create a second handle to the same compactor.
62    ///
63    /// The clone shares the same LLM client and summary cache — clearing the
64    /// cache through either handle affects both.
65    pub fn clone_handle(&self) -> Self {
66        Self {
67            client: self.client.clone(),
68            config: self.config.clone(),
69            cache: self.cache.clone(),
70        }
71    }
72
73    pub fn config(&self) -> &CompressionConfig {
74        &self.config
75    }
76
77    // ── Core compression ─────────────────────────────────────────────────
78
79    /// Maximum tokens for user messages preserved verbatim from the old block.
80    ///
81    /// Matches codex's `COMPACT_USER_MESSAGE_MAX_TOKENS`.  User messages are
82    /// typically short instructions, so 20k tokens is generous enough to never
83    /// truncate in practice while preventing pathological cases (e.g., user
84    /// pasting a huge code block).
85    const MAX_PRESERVED_USER_TOKENS: usize = 20_000;
86
87    /// Produce a compressed copy of `messages` for a single LLM call.
88    ///
89    /// Returns `None` when compression is skipped (disabled, below threshold,
90    /// or too few messages).  The caller should use the original `messages`
91    /// unchanged in that case.
92    ///
93    /// `session_id` is used as part of the cache key so different sessions
94    /// never share summaries.
95    ///
96    /// # Compression strategy
97    ///
98    /// User messages from the old block are preserved verbatim (truncated to
99    /// [`MAX_PRESERVED_USER_TOKENS`](Self::MAX_PRESERVED_USER_TOKENS)).
100    /// Only assistant and tool responses are summarised by the LLM.  This
101    /// mirrors codex's compaction strategy and avoids the common pitfall of
102    /// the summarizer reproducing assistant-generated content (poems, code,
103    /// articles) verbatim, which can make the compressed output *larger* than
104    /// the original.
105    pub async fn compact(
106        &self,
107        session_id: u64,
108        messages: &[ChatMessage],
109        trigger: CompressionTrigger,
110        emit_fn: Option<&(dyn Fn(CompressionEvent) + Sync)>,
111    ) -> AgentResult<Option<Vec<ChatMessage>>> {
112        let t0 = std::time::Instant::now();
113        if !self.config.enabled {
114            return Ok(None);
115        }
116
117        let total_tokens: usize = messages.iter().map(estimate_message_tokens).sum();
118        tracing::info!(
119            total_tokens,
120            trigger = self.config.trigger_tokens,
121            "[compact-timing] enter compact()"
122        );
123        if total_tokens <= self.config.trigger_tokens {
124            return Ok(None);
125        }
126
127        // 1. Split off system prompt.
128        let (system_msgs, conversation) = split_system_prompt(messages);
129        tracing::info!(
130            elapsed_ms = t0.elapsed().as_millis() as u64,
131            conv_len = conversation.len(),
132            keep = self.config.keep_recent_messages,
133            "[compact-timing] after split_system_prompt"
134        );
135        if conversation.len() <= self.config.keep_recent_messages + 1 {
136            return Ok(None);
137        }
138
139        // 2. Split conversation into old / recent.
140        let mut recent_start = conversation.len() - self.config.keep_recent_messages;
141        recent_start = safe_cut_index(conversation, 0, recent_start);
142        tracing::info!(
143            elapsed_ms = t0.elapsed().as_millis() as u64,
144            recent_start,
145            "[compact-timing] after split old/recent"
146        );
147        if recent_start == 0 {
148            return Ok(None);
149        }
150
151        // All checks passed — compression will proceed. Notify caller.
152
153        let old = &conversation[..recent_start];
154        let recent = &conversation[recent_start..];
155
156        // Defensive: never start the old block on an orphaned tool result.
157        if matches!(old.first(), Some(ChatMessage::Tool { .. })) {
158            tracing::warn!("context compression skipped: old block starts with a tool result");
159            return Ok(None);
160        }
161
162        // 3. Filter old summaries from both blocks.
163        let old: Vec<&ChatMessage> = old.iter().filter(|m| !is_summary_message(m)).collect();
164        let recent: Vec<&ChatMessage> = recent.iter().filter(|m| !is_summary_message(m)).collect();
165
166        // 4. Extract original goal (first user message in old block).
167        let original_goal = old
168            .iter()
169            .find_map(|m| match m {
170                ChatMessage::User { content, .. } if !content.starts_with(SUMMARY_PREFIX) => {
171                    Some(content.as_str())
172                }
173                _ => None,
174            })
175            .unwrap_or("(unknown goal)");
176        let original_goal = truncate_str(original_goal, 400);
177
178        // 5. Separate user messages (preserved verbatim) from assistant/tool
179        //    messages (sent to summarizer).
180        let preserved_user_msgs: Vec<&ChatMessage> = old
181            .iter()
182            .filter(|m| matches!(m, ChatMessage::User { .. }))
183            .copied()
184            .collect();
185        let assistant_tool_msgs: Vec<&ChatMessage> = old
186            .iter()
187            .filter(|m| !matches!(m, ChatMessage::User { .. }))
188            .copied()
189            .collect();
190
191        // Serialize only assistant/tool messages for the summarizer.
192        let transcript = truncate_to_chars(&assistant_tool_msgs, self.config.max_transcript_chars);
193        if transcript.trim().is_empty() && preserved_user_msgs.is_empty() {
194            return Ok(None);
195        }
196
197        // All early-exit checks passed — compression will proceed.
198        let msg_count = messages.len();
199        if let Some(emit) = emit_fn {
200            emit(CompressionEvent::Preparing {
201                session_id,
202                tokens_before: total_tokens,
203                msg_count,
204                trigger: trigger.clone(),
205            });
206            emit(CompressionEvent::Started {
207                session_id,
208                tokens_before: total_tokens,
209                msg_count,
210                trigger: trigger.clone(),
211            });
212        }
213
214        const CACHE_PREFIX_CHARS: usize = 4096;
215        let prefix: String = transcript.chars().take(CACHE_PREFIX_CHARS).collect();
216        let key = (session_id, hash_str(&prefix));
217
218        let cached = self.cache.lock().ok().and_then(|c| c.get(&key).cloned());
219
220        // Reuse cached summary when the transcript hasn't changed, OR when it
221        // has hit the max_transcript_chars cap (content is frozen at that point,
222        // further growth only affects the tail which is truncated away).
223        let cache_effective = matches!(cached, Some((cl, _)) if cl == transcript.len() || transcript.len() >= self.config.max_transcript_chars);
224
225        let summary = if transcript.trim().is_empty() {
226            // No assistant/tool content to summarize — skip LLM call.
227            String::new()
228        } else {
229            match cached {
230                Some((_, ref s)) if cache_effective => s.clone(),
231                _ => {
232                    // Notify caller that we're about to call the LLM.
233                    if let Some(emit) = emit_fn {
234                        emit(CompressionEvent::Progress {
235                            session_id,
236                            chars: 0,
237                        });
238                    }
239                    tracing::info!(
240                        elapsed_ms = t0.elapsed().as_millis() as u64,
241                        "[compact-timing] before summarize() LLM call"
242                    );
243                    // summarize() reports cumulative char count via on_progress.
244                    // Wrap it to emit CompressionEvent::Progress.
245                    let s = if let Some(emit) = emit_fn {
246                        let progress_fn = |chars: usize| {
247                            emit(CompressionEvent::Progress { session_id, chars });
248                        };
249                        summarize(
250                            self.client.as_ref(),
251                            &transcript,
252                            &original_goal,
253                            self.config.max_summary_chars,
254                            Some(&progress_fn),
255                        )
256                        .await
257                    } else {
258                        summarize(
259                            self.client.as_ref(),
260                            &transcript,
261                            &original_goal,
262                            self.config.max_summary_chars,
263                            None,
264                        )
265                        .await
266                    };
267                    let s = match s {
268                        Ok(s) => {
269                            tracing::info!(
270                                elapsed_ms = t0.elapsed().as_millis() as u64,
271                                "[compact-timing] summarize() returned Ok"
272                            );
273                            s
274                        }
275                        Err(e) => {
276                            tracing::warn!(
277                                session_id,
278                                "summarisation failed, falling back to dropping old block: {e}"
279                            );
280                            String::new()
281                        }
282                    };
283                    if !s.is_empty()
284                        && let Ok(mut cache) = self.cache.lock()
285                    {
286                        cache.insert(key, (transcript.len(), s.clone()));
287                    }
288                    s
289                }
290            }
291        };
292
293        // 6. Assemble: [system prompt] + [preserved user messages] + [summary] + [recent N].
294        //    User messages are preserved verbatim (truncated to MAX_PRESERVED_USER_TOKENS)
295        //    so the LLM always sees exact user instructions.
296        let mut new_messages: Vec<ChatMessage> = system_msgs.to_vec();
297
298        // Add preserved user messages, truncated from the start to fit.
299        let truncated_user =
300            truncate_user_messages(&preserved_user_msgs, Self::MAX_PRESERVED_USER_TOKENS);
301
302        let trimmed = summary.trim();
303
304        // 7. Guard: if the replacement (preserved users + summary) is larger
305        //    than the original old block, discard the compression and keep the
306        //    original messages.  This prevents negative compression on short
307        //    conversations where the summary overhead exceeds the original
308        //    assistant content.
309        let old_block_chars: usize = old.iter().map(|m| message_content_len(m)).sum();
310        let replacement_chars: usize = truncated_user
311            .iter()
312            .map(|m| message_content_len(m))
313            .sum::<usize>()
314            + if trimmed.is_empty() {
315                0
316            } else {
317                SUMMARY_PREFIX.len() + 1 + trimmed.len()
318            };
319        if replacement_chars >= old_block_chars {
320            tracing::info!(
321                session_id,
322                old_block_chars,
323                replacement_chars,
324                "[compact-timing] replacement larger than original, skipping compression"
325            );
326            return Ok(None);
327        }
328
329        new_messages.extend(truncated_user.into_iter().cloned());
330
331        if !trimmed.is_empty() {
332            new_messages.push(ChatMessage::user(format!("{SUMMARY_PREFIX}\n{trimmed}")));
333        }
334        new_messages.extend(recent.into_iter().cloned());
335
336        // Record what happened.
337        let compressed_tokens: usize = new_messages.iter().map(estimate_message_tokens).sum();
338        tracing::info!(
339            session_id,
340            tokens_before = total_tokens,
341            tokens_after = compressed_tokens,
342            kept_recent = self.config.keep_recent_messages,
343            summary_len = trimmed.len(),
344            preserved_user_count = preserved_user_msgs.len(),
345            old_block_chars,
346            replacement_chars,
347            cache_hit = cache_effective,
348            elapsed_ms = t0.elapsed().as_millis() as u64,
349            "[compact-timing] compression complete"
350        );
351
352        Ok(Some(new_messages))
353    }
354
355    // ── Cache management ─────────────────────────────────────────────────
356
357    /// Clear all cached summaries.
358    pub fn clear_cache(&self) {
359        if let Ok(mut cache) = self.cache.lock() {
360            cache.clear();
361        }
362    }
363
364    // ── Session-level compression ──────────────────────────────────────────
365
366    /// Read-compress-write-back: compress a session's messages in place.
367    ///
368    /// Returns `Ok(true)` if compression was applied (session was above the
369    /// threshold), `Ok(false)` if the session was already below the threshold.
370    ///
371    /// Returns `Err` if the write-back validation fails (unexpected) or if
372    /// the session was modified concurrently between read and write-back
373    /// (TOCTOU guard).
374    pub async fn compact_session(
375        &self,
376        runtime: &AgentRuntime,
377        session_id: &SessionId,
378        emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
379    ) -> AgentResult<bool> {
380        let sid = session_id.id;
381        let trigger = CompressionTrigger::Manual;
382
383        // Phase 1: read messages from the session (lock held briefly).
384        let messages = runtime
385            .with_session_mut(session_id, |session| session.chat_messages().to_vec())
386            .await?;
387        let msg_count_before = messages.len();
388        let tokens_before: usize = messages.iter().map(estimate_message_tokens).sum();
389
390        // Phase 2: compress (async LLM call, no session lock held).
391        // Filter out old summary messages before compressing (same as middleware).
392        let filtered: Vec<ChatMessage> = messages
393            .iter()
394            .filter(|m| !is_summary_message(m))
395            .cloned()
396            .collect();
397
398        // Build emit_fn wrapper: CompressionEvent → into_user_event() → caller's emit_fn.
399        let compressed = if let Some(ref ef) = emit_fn {
400            let ef = ef.clone();
401            self.compact(
402                sid,
403                &filtered,
404                trigger.clone(),
405                Some(&move |ev: CompressionEvent| {
406                    ef(ev.into_user_event());
407                }),
408            )
409            .await?
410        } else {
411            self.compact(sid, &filtered, trigger.clone(), None).await?
412        };
413
414        let compressed = match compressed {
415            Some(msgs) => msgs,
416            None => {
417                // Below threshold — no-op, no events needed.
418                return Ok(false);
419            }
420        };
421
422        let tokens_after: usize = compressed.iter().map(estimate_message_tokens).sum();
423        let msg_count_after = compressed.len();
424
425        // Phase 3: write back (lock held briefly for set_chat_messages + validation).
426        // TOCTOU guard: verify message count hasn't changed since Phase 1.
427        runtime
428            .with_session_mut(session_id, |session| {
429                let msg_count_now = session.chat_messages().len();
430                if msg_count_now != msg_count_before {
431                    return Err(agent_base::AgentError::internal(format!(
432                        "session modified concurrently ({} → {} messages), aborting write-back",
433                        msg_count_before, msg_count_now
434                    )));
435                }
436                session.set_chat_messages(compressed).map_err(|e| {
437                    agent_base::AgentError::internal(format!(
438                        "set_chat_messages validation failed: {e}"
439                    ))
440                })
441            })
442            .await??;
443
444        // Emit Completed event.
445        let reduction_pct = if tokens_before > 0 {
446            ((tokens_before as f64 - tokens_after as f64) / tokens_before as f64 * 100.0).round()
447                as i32
448        } else {
449            0
450        };
451        if let Some(ref f) = emit_fn {
452            f(CompressionEvent::Completed {
453                session_id: sid,
454                tokens_before,
455                tokens_after,
456                reduction_pct,
457                msg_count_before,
458                msg_count_after,
459                trigger,
460            }
461            .into_user_event());
462        }
463
464        Ok(true)
465    }
466}
467
468// ── Helpers ──────────────────────────────────────────────────────────────────
469
470/// Total character length of a message's text content.
471///
472/// Used for size-based comparison (before vs after compression) rather than
473/// token estimation.
474fn message_content_len(msg: &ChatMessage) -> usize {
475    match msg {
476        ChatMessage::System { content, .. } => content.len(),
477        ChatMessage::User { content, .. } => content.len(),
478        ChatMessage::Assistant {
479            content,
480            reasoning_content,
481            tool_calls,
482        } => {
483            let mut len = content.as_deref().map(|c| c.len()).unwrap_or(0);
484            if let Some(rc) = reasoning_content {
485                len += rc.len();
486            }
487            if let Some(calls) = tool_calls {
488                for c in calls {
489                    len += c.id.len() + c.name.len() + c.arguments.len();
490                }
491            }
492            len
493        }
494        ChatMessage::Tool {
495            tool_call_id,
496            content,
497        } => tool_call_id.len() + content.len(),
498        ChatMessage::Custom { role, data } => role.len() + data.to_string().len(),
499    }
500}
501
502/// Walk `cut` backward until the boundary is tool-pairing safe.
503///
504/// A cut is *unsafe* when:
505/// - the message just left of the cut is an `Assistant` with pending tool calls, or
506/// - the message just right of the cut is a `Tool` result.
507///
508/// In either case we move the cut leftward until neither condition holds,
509/// ensuring the resulting message list never has orphaned `Tool` messages or
510/// `Assistant{tool_calls}` whose results are missing.
511pub fn safe_cut_index(messages: &[ChatMessage], start: usize, mut cut: usize) -> usize {
512    while cut > start {
513        let left_is_tool_call = matches!(
514            messages[cut - 1],
515            ChatMessage::Assistant {
516                tool_calls: Some(_),
517                ..
518            }
519        );
520        let right_is_tool = matches!(messages[cut], ChatMessage::Tool { .. });
521        if !left_is_tool_call && !right_is_tool {
522            break;
523        }
524        cut -= 1;
525    }
526    cut
527}
528
529/// CJK-aware token estimate for a single message.
530///
531/// Mirrors `ContextWindowManager::message_tokens` (which is `pub(crate)` in
532/// agent-base and not reachable from here).
533pub(crate) fn estimate_message_tokens(msg: &ChatMessage) -> usize {
534    match msg {
535        ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
536        ChatMessage::User {
537            content, images, ..
538        } => ContextWindowManager::estimate_tokens(content) + images.len() * 85,
539        ChatMessage::Assistant {
540            content,
541            reasoning_content,
542            tool_calls,
543        } => {
544            let mut tokens = content
545                .as_deref()
546                .map(ContextWindowManager::estimate_tokens)
547                .unwrap_or(0);
548            if let Some(rc) = reasoning_content {
549                tokens += ContextWindowManager::estimate_tokens(rc);
550            }
551            if let Some(calls) = tool_calls {
552                for c in calls {
553                    tokens += ContextWindowManager::estimate_tokens(&c.id);
554                    tokens += ContextWindowManager::estimate_tokens(&c.name);
555                    tokens += ContextWindowManager::estimate_tokens(&c.arguments);
556                }
557            }
558            tokens
559        }
560        ChatMessage::Tool {
561            tool_call_id,
562            content,
563        } => {
564            ContextWindowManager::estimate_tokens(tool_call_id)
565                + ContextWindowManager::estimate_tokens(content)
566        }
567        ChatMessage::Custom { role, data } => {
568            ContextWindowManager::estimate_tokens(role)
569                + ContextWindowManager::estimate_tokens(&data.to_string())
570        }
571    }
572}
573
574/// Estimate total tokens across a message list.
575#[allow(dead_code)]
576fn estimate_total_tokens(messages: &[ChatMessage]) -> usize {
577    messages.iter().map(estimate_message_tokens).sum()
578}
579
580/// Hash a string into a `u64` using the default hasher.
581fn hash_str(s: &str) -> u64 {
582    let mut h = DefaultHasher::new();
583    s.hash(&mut h);
584    h.finish()
585}
586
587/// Serialise a block of messages into a compact human-readable transcript.
588///
589/// Each message is rendered as `[role] content` with individual fields
590/// truncated.  The entire output is then truncated to `max_chars`.
591pub fn serialize_block(messages: &[&ChatMessage], max_chars: usize) -> String {
592    let mut parts: Vec<String> = Vec::with_capacity(messages.len());
593    for msg in messages {
594        let line = match msg {
595            ChatMessage::System { content, .. } => {
596                format!("[system] {}", truncate_str(content, 400))
597            }
598            ChatMessage::User { content, .. } => format!("[user] {}", truncate_str(content, 400)),
599            ChatMessage::Assistant {
600                content,
601                tool_calls,
602                ..
603            } => match tool_calls {
604                Some(calls) if !calls.is_empty() => {
605                    let calls: Vec<String> = calls
606                        .iter()
607                        .map(|c| format!("{}({})", c.name, truncate_str(&c.arguments, 150)))
608                        .collect();
609                    format!("[assistant tool_call] {}", calls.join("; "))
610                }
611                _ => format!(
612                    "[assistant] {}",
613                    content
614                        .as_deref()
615                        .map(|c| truncate_str(c, 400))
616                        .unwrap_or_default()
617                ),
618            },
619            ChatMessage::Tool {
620                tool_call_id,
621                content,
622            } => format!(
623                "[tool:{}] {}",
624                truncate_str(tool_call_id, 50),
625                truncate_str(content, 300)
626            ),
627            ChatMessage::Custom { role, data } => {
628                format!("[custom:{}] {}", role, truncate_str(&data.to_string(), 400))
629            }
630        };
631        parts.push(line);
632    }
633    let joined = parts.join("\n");
634    truncate_str(&joined, max_chars)
635}
636
637/// Truncate a string to `max_chars`, appending `…` if truncated.
638pub fn truncate_str(s: &str, max_chars: usize) -> String {
639    let count = s.chars().count();
640    if count <= max_chars {
641        return s.to_string();
642    }
643    let head: String = s.chars().take(max_chars).collect();
644    format!("{head}…")
645}
646
647/// Serialise messages into a transcript and truncate to `max_chars`.
648fn truncate_to_chars(messages: &[&ChatMessage], max_chars: usize) -> String {
649    serialize_block(messages, max_chars)
650}
651
652/// Truncate a list of user messages to fit within `max_tokens`.
653///
654/// Preserves messages from the *end* (most recent) and drops from the start
655/// when the budget is exceeded.  This mirrors codex's approach: recent user
656/// instructions are more valuable than older ones.
657fn truncate_user_messages<'a>(
658    messages: &[&'a ChatMessage],
659    max_tokens: usize,
660) -> Vec<&'a ChatMessage> {
661    if max_tokens == 0 {
662        return Vec::new();
663    }
664    let mut result: Vec<&ChatMessage> = Vec::with_capacity(messages.len());
665    let mut remaining = max_tokens;
666    // Walk from end to start, keeping messages that fit.
667    for msg in messages.iter().rev() {
668        let tokens = estimate_message_tokens(msg);
669        if tokens <= remaining {
670            result.push(msg);
671            remaining -= tokens;
672        }
673        // Skip oversized messages — don't break, earlier messages may still fit.
674    }
675    result.reverse();
676    result
677}
678
679// ── Tests ────────────────────────────────────────────────────────────────────
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use agent_base::{AgentResult, ResponseFormat, ToolCallMessage};
685
686    // ── Test helpers ──────────────────────────────────────────────────────
687
688    /// Minimal mock that returns a fixed string.
689    struct MockClient(&'static str);
690
691    #[async_trait::async_trait]
692    impl StreamClient for MockClient {
693        async fn stream(
694            &self,
695            _messages: &[ChatMessage],
696            _tools: &[serde_json::Value],
697            _reasoning: Option<&agent_base::ReasoningConfig>,
698            _response_format: Option<&ResponseFormat>,
699        ) -> AgentResult<
700            std::pin::Pin<
701                Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
702            >,
703        > {
704            let response = self.0.to_string();
705            Ok(Box::pin(futures_util::stream::once(async move {
706                Ok(agent_base::StreamChunk::Text(response))
707            })))
708        }
709
710        async fn chat(
711            &self,
712            _messages: &[ChatMessage],
713            _tools: &[serde_json::Value],
714            _reasoning: Option<&agent_base::ReasoningConfig>,
715            _response_format: Option<&ResponseFormat>,
716        ) -> AgentResult<String> {
717            Ok(self.0.to_string())
718        }
719
720        fn capabilities(&self) -> agent_base::LlmCapabilities {
721            agent_base::LlmCapabilities::default()
722        }
723    }
724
725    /// Mock that counts `chat()` invocations.
726    struct CountingClient {
727        response: &'static str,
728        calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
729    }
730
731    #[async_trait::async_trait]
732    impl StreamClient for CountingClient {
733        async fn stream(
734            &self,
735            _messages: &[ChatMessage],
736            _tools: &[serde_json::Value],
737            _reasoning: Option<&agent_base::ReasoningConfig>,
738            _response_format: Option<&ResponseFormat>,
739        ) -> AgentResult<
740            std::pin::Pin<
741                Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
742            >,
743        > {
744            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
745            let response = self.response.to_string();
746            Ok(Box::pin(futures_util::stream::once(async move {
747                Ok(agent_base::StreamChunk::Text(response))
748            })))
749        }
750
751        async fn chat(
752            &self,
753            _messages: &[ChatMessage],
754            _tools: &[serde_json::Value],
755            _reasoning: Option<&agent_base::ReasoningConfig>,
756            _response_format: Option<&ResponseFormat>,
757        ) -> AgentResult<String> {
758            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
759            Ok(self.response.to_string())
760        }
761
762        fn capabilities(&self) -> agent_base::LlmCapabilities {
763            agent_base::LlmCapabilities::default()
764        }
765    }
766
767    /// Mock that always returns an error.
768    struct FailingClient;
769
770    #[async_trait::async_trait]
771    impl StreamClient for FailingClient {
772        async fn stream(
773            &self,
774            _messages: &[ChatMessage],
775            _tools: &[serde_json::Value],
776            _reasoning: Option<&agent_base::ReasoningConfig>,
777            _response_format: Option<&ResponseFormat>,
778        ) -> AgentResult<
779            std::pin::Pin<
780                Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>,
781            >,
782        > {
783            Err(agent_base::AgentError::llm("summarisation failed"))
784        }
785
786        async fn chat(
787            &self,
788            _messages: &[ChatMessage],
789            _tools: &[serde_json::Value],
790            _reasoning: Option<&agent_base::ReasoningConfig>,
791            _response_format: Option<&ResponseFormat>,
792        ) -> AgentResult<String> {
793            Err(agent_base::AgentError::llm("summarisation failed"))
794        }
795
796        fn capabilities(&self) -> agent_base::LlmCapabilities {
797            agent_base::LlmCapabilities::default()
798        }
799    }
800
801    fn make_messages(count: usize) -> Vec<ChatMessage> {
802        let mut msgs = vec![ChatMessage::system("You are a test agent.")];
803        for i in 0..count {
804            msgs.push(ChatMessage::user(format!("question {i}")));
805            msgs.push(ChatMessage::assistant(format!(
806                "answer {i} with some extra content to make it longer"
807            )));
808        }
809        msgs
810    }
811
812    // ── safe_cut_index ────────────────────────────────────────────────────
813
814    #[test]
815    fn test_safe_cut_never_splits_tool_pair() {
816        let msgs = vec![
817            ChatMessage::user("hi"),
818            ChatMessage::assistant(""),
819            ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
820            ChatMessage::tool("call_1", "result"),
821            ChatMessage::user("next"),
822        ];
823        // Cut at 3 (between assistant{tool_call} and tool result) → unsafe.
824        // Should walk back past the assistant{tool_call} message.
825        let cut = super::safe_cut_index(&msgs, 0, 3);
826        assert!(
827            cut <= 2,
828            "cut should walk back before assistant{{tool_call}}"
829        );
830        assert!(
831            !matches!(
832                &msgs[cut - 1],
833                ChatMessage::Assistant {
834                    tool_calls: Some(_),
835                    ..
836                }
837            ),
838            "left boundary must not be assistant with pending tool calls"
839        );
840    }
841
842    #[test]
843    fn test_safe_cut_prefers_given_boundary_when_safe() {
844        let msgs = vec![
845            ChatMessage::user("hi"),
846            ChatMessage::assistant("hello"),
847            ChatMessage::user("next"),
848            ChatMessage::assistant("world"),
849        ];
850        // Cut at 2 is safe: left=Assistant(no tool_calls), right=User.
851        assert_eq!(super::safe_cut_index(&msgs, 0, 2), 2);
852    }
853
854    #[test]
855    fn test_safe_cut_walks_back_from_orphan_tool() {
856        let msgs = vec![
857            ChatMessage::user("a"),
858            ChatMessage::assistant("b"),
859            ChatMessage::tool("c1", "r1"),
860            ChatMessage::user("d"),
861        ];
862        // Cut=1 lands on assistant (no tool_calls), right=user → safe.
863        assert_eq!(super::safe_cut_index(&msgs, 0, 1), 1);
864    }
865
866    // ── estimate_message_tokens ───────────────────────────────────────────
867
868    #[test]
869    fn test_estimate_tokens_cjk_and_tool() {
870        let msg = ChatMessage::tool("t1", "你好世界");
871        let tokens = super::estimate_message_tokens(&msg);
872        // CJK: ~4 chars / 1.5 ≈ 3; id "t1": 2 chars / 4 = 1
873        assert!((2..=10).contains(&tokens));
874    }
875
876    #[test]
877    fn test_estimate_tokens_user_with_images() {
878        let msg = ChatMessage::user_with_images(
879            "describe this",
880            vec![agent_base::ImageAttachment::Url {
881                url: "http://example.com/img.png".into(),
882                detail: Some(agent_base::ImageDetail::Auto),
883            }],
884        );
885        let tokens = super::estimate_message_tokens(&msg);
886        // Should include the 85-token per-image overhead.
887        assert!(tokens >= 85);
888    }
889
890    // ── serialize_block ───────────────────────────────────────────────────
891
892    #[test]
893    fn test_serialize_block_preserves_tool_calls() {
894        let msgs = [
895            ChatMessage::assistant(""),
896            ChatMessage::tool("c1", "result"),
897        ];
898        let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
899        let out = super::serialize_block(&msg_refs, 2000);
900        assert!(out.contains("[tool:c1]"), "got: {out}");
901        assert!(out.contains("result"), "got: {out}");
902    }
903
904    #[test]
905    fn test_serialize_block_truncates_oversized_fields() {
906        let long_arg = "x".repeat(500);
907        let msgs = [ChatMessage::Assistant {
908            content: None,
909            reasoning_content: None,
910            tool_calls: Some(vec![ToolCallMessage {
911                id: "t1".into(),
912                name: "bash".into(),
913                arguments: long_arg,
914            }]),
915        }];
916        let msg_refs: Vec<&ChatMessage> = msgs.iter().collect();
917        let out = super::serialize_block(&msg_refs, 2000);
918        // Should contain "bash(" and "…", but NOT all 500 chars.
919        assert!(out.contains("bash("));
920        assert!(out.contains("…"));
921        assert!(out.len() < 400);
922    }
923
924    // ── compact ───────────────────────────────────────────────────────────
925
926    #[tokio::test]
927    async fn test_compact_noop_when_disabled() {
928        let config = CompressionConfig::default().with_enabled(false);
929        let client = std::sync::Arc::new(MockClient("summary"));
930        let compactor = ContextCompactor::new(client, config);
931        let msgs = make_messages(100);
932        let result = compactor
933            .compact(1, &msgs, CompressionTrigger::Auto, None)
934            .await
935            .unwrap();
936        assert!(result.is_none());
937    }
938
939    #[tokio::test]
940    async fn test_compact_noop_when_below_threshold() {
941        let config = CompressionConfig::default().with_trigger_tokens(999_999);
942        let client = std::sync::Arc::new(MockClient("summary"));
943        let compactor = ContextCompactor::new(client, config);
944        let msgs = make_messages(10);
945        let result = compactor
946            .compact(1, &msgs, CompressionTrigger::Auto, None)
947            .await
948            .unwrap();
949        assert!(result.is_none());
950    }
951
952    #[tokio::test]
953    async fn test_compact_produces_valid_output() {
954        let config = CompressionConfig::default()
955            .with_trigger_tokens(1) // Always trigger.
956            .with_keep_recent_messages(4);
957        let client = std::sync::Arc::new(MockClient("test summary text"));
958        let compactor = ContextCompactor::new(client, config);
959        let msgs = make_messages(20);
960        let result = compactor
961            .compact(1, &msgs, CompressionTrigger::Auto, None)
962            .await
963            .unwrap()
964            .unwrap();
965
966        // First message should be system prompt.
967        assert!(matches!(&result[0], ChatMessage::System { .. }));
968
969        // Should contain a summary message and then recent messages.
970        let has_summary = result.iter().any(|m| match m {
971            ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
972            _ => false,
973        });
974        assert!(has_summary, "expected summary message in output");
975
976        // Recent messages should be present.
977        let last = result.last().unwrap();
978        assert!(matches!(
979            last,
980            ChatMessage::User { .. } | ChatMessage::Assistant { .. }
981        ));
982
983        // No orphan Tool messages: every Tool must have index > 0.
984        for (i, msg) in result.iter().enumerate() {
985            assert!(
986                !matches!(msg, ChatMessage::Tool { .. }) || i > 0,
987                "orphan Tool at index 0"
988            );
989        }
990    }
991
992    // ── compact with failing client ───────────────────────────────────────
993
994    #[tokio::test]
995    async fn test_compact_fallback_on_summarisation_failure() {
996        let config = CompressionConfig::default()
997            .with_trigger_tokens(1)
998            .with_keep_recent_messages(4);
999        let client = std::sync::Arc::new(FailingClient);
1000        let compactor = ContextCompactor::new(client, config);
1001        let msgs = make_messages(20);
1002        let result = compactor
1003            .compact(1, &msgs, CompressionTrigger::Auto, None)
1004            .await
1005            .unwrap()
1006            .unwrap();
1007
1008        // Fallback: summary is empty, so we get system + recent only.
1009        assert!(matches!(&result[0], ChatMessage::System { .. }));
1010        let has_summary = result.iter().any(|m| match m {
1011            ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1012            _ => false,
1013        });
1014        assert!(!has_summary, "should have no summary on failure");
1015    }
1016
1017    // ── compact cache ─────────────────────────────────────────────────────
1018
1019    #[tokio::test]
1020    async fn test_compact_caches_summary() {
1021        let config = CompressionConfig::default()
1022            .with_trigger_tokens(1)
1023            .with_keep_recent_messages(4);
1024        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1025        let client = std::sync::Arc::new(CountingClient {
1026            response: "cached summary",
1027            calls: calls.clone(),
1028        });
1029        let compactor = ContextCompactor::new(client, config);
1030        let msgs = make_messages(20);
1031
1032        // First call — should invoke LLM once.
1033        let _ = compactor
1034            .compact(1, &msgs, CompressionTrigger::Auto, None)
1035            .await
1036            .unwrap();
1037        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1038
1039        // Second call with same old block — should hit cache (no new LLM call).
1040        let _ = compactor
1041            .compact(1, &msgs, CompressionTrigger::Auto, None)
1042            .await
1043            .unwrap();
1044        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1045
1046        // Third call after appending messages (old block grows) — should re-summarize.
1047        let mut grown = msgs.clone();
1048        grown.push(ChatMessage::user("new question"));
1049        grown.push(ChatMessage::assistant("new answer"));
1050        let _ = compactor
1051            .compact(1, &grown, CompressionTrigger::Auto, None)
1052            .await
1053            .unwrap();
1054        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
1055    }
1056
1057    // ── compact cache length guard ────────────────────────────────────────
1058
1059    #[tokio::test]
1060    async fn test_compact_cache_length_guard_re_summarises_on_growth() {
1061        // Construct old block > 4096 chars (CACHE_PREFIX_CHARS) so the prefix
1062        // hash stays stable when we grow the tail.  The length guard
1063        // (cl == transcript.len()) should detect the growth and re-summarise.
1064        let config = CompressionConfig::default()
1065            .with_trigger_tokens(1)
1066            .with_keep_recent_messages(4)
1067            .with_max_transcript_chars(60_000); // High cap so we don't hit the freeze guard.
1068        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1069        let client = std::sync::Arc::new(CountingClient {
1070            response: "summary",
1071            calls: calls.clone(),
1072        });
1073        let compactor = ContextCompactor::new(client, config);
1074
1075        // Each pair "question NNNN\nanswer NNNN" serialises to ~30 chars.
1076        // 200 pairs → ~6000 chars transcript (> 4096 prefix).
1077        let mut msgs = vec![ChatMessage::system("sys")];
1078        for i in 0..200 {
1079            msgs.push(ChatMessage::user(format!("question {i:04}")));
1080            msgs.push(ChatMessage::assistant(format!("answer {i:04}")));
1081        }
1082
1083        // First call — cache miss, LLM called once.
1084        let _ = compactor
1085            .compact(1, &msgs, CompressionTrigger::Auto, None)
1086            .await
1087            .unwrap();
1088        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1089
1090        // Second call, identical — cache hit (same prefix + same length).
1091        let _ = compactor
1092            .compact(1, &msgs, CompressionTrigger::Auto, None)
1093            .await
1094            .unwrap();
1095        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1096
1097        // Grow the tail: prefix hash unchanged (first 4096 chars identical),
1098        // but transcript length increased → length guard should reject cache.
1099        msgs.push(ChatMessage::user("extra question"));
1100        msgs.push(ChatMessage::assistant("extra answer"));
1101        let _ = compactor
1102            .compact(1, &msgs, CompressionTrigger::Auto, None)
1103            .await
1104            .unwrap();
1105        assert_eq!(
1106            calls.load(std::sync::atomic::Ordering::SeqCst),
1107            2,
1108            "length guard should have triggered re-summarisation"
1109        );
1110    }
1111
1112    // ── compact orphan Tool assertion ─────────────────────────────────────
1113
1114    #[tokio::test]
1115    async fn test_compact_no_orphan_tool_messages() {
1116        let config = CompressionConfig::default()
1117            .with_trigger_tokens(1)
1118            .with_keep_recent_messages(4);
1119        let client = std::sync::Arc::new(MockClient("summary"));
1120        let compactor = ContextCompactor::new(client, config);
1121
1122        // Build messages with tool_calls + tool results in the old block.
1123        let mut msgs = vec![ChatMessage::system("sys")];
1124        for i in 0..10 {
1125            msgs.push(ChatMessage::user(format!("q{i}")));
1126            msgs.push(ChatMessage::assistant_tool_call(
1127                format!("tc{i}"),
1128                "bash",
1129                format!("{{\"cmd\":\"echo {i} with some extra arguments to make it longer\"}}"),
1130            ));
1131            msgs.push(ChatMessage::tool(
1132                format!("tc{i}"),
1133                format!("result {i} with some extra output content"),
1134            ));
1135        }
1136
1137        let result = compactor
1138            .compact(1, &msgs, CompressionTrigger::Auto, None)
1139            .await
1140            .unwrap()
1141            .unwrap();
1142
1143        // No orphan Tool: every Tool must be preceded by an Assistant whose
1144        // tool_calls reference the same tool_call_id.
1145        for (i, msg) in result.iter().enumerate() {
1146            if let ChatMessage::Tool { tool_call_id, .. } = msg {
1147                assert!(i > 0, "Tool message at index 0 has no preceding assistant");
1148                let prev = &result[i - 1];
1149                match prev {
1150                    ChatMessage::Assistant {
1151                        tool_calls: Some(calls),
1152                        ..
1153                    } => {
1154                        let ids: Vec<&str> = calls.iter().map(|c| c.id.as_str()).collect();
1155                        assert!(
1156                            ids.contains(&tool_call_id.as_str()),
1157                            "Tool {tool_call_id} not referenced by preceding assistant: {ids:?}"
1158                        );
1159                    }
1160                    other => panic!(
1161                        "Tool {tool_call_id} at index {i} not preceded by \
1162                         Assistant{{tool_calls}}, got: {other:?}"
1163                    ),
1164                }
1165            }
1166        }
1167    }
1168
1169    // ── compact boundary conditions ─────────────────────────────────────
1170
1171    #[tokio::test]
1172    async fn test_compact_noop_empty_messages() {
1173        let config = CompressionConfig::default().with_trigger_tokens(1);
1174        let client = std::sync::Arc::new(MockClient("summary"));
1175        let compactor = ContextCompactor::new(client, config);
1176        let result = compactor
1177            .compact(1, &[], CompressionTrigger::Auto, None)
1178            .await
1179            .unwrap();
1180        assert!(result.is_none());
1181    }
1182
1183    #[tokio::test]
1184    async fn test_compact_noop_system_only() {
1185        let config = CompressionConfig::default().with_trigger_tokens(1);
1186        let client = std::sync::Arc::new(MockClient("summary"));
1187        let compactor = ContextCompactor::new(client, config);
1188        let msgs = vec![ChatMessage::system("You are helpful.")];
1189        let result = compactor
1190            .compact(1, &msgs, CompressionTrigger::Auto, None)
1191            .await
1192            .unwrap();
1193        assert!(result.is_none());
1194    }
1195
1196    #[tokio::test]
1197    async fn test_compact_noop_conversation_equals_keep() {
1198        // conversation.len() == keep_recent_messages → too few to split.
1199        let config = CompressionConfig::default()
1200            .with_trigger_tokens(1)
1201            .with_keep_recent_messages(100);
1202        let client = std::sync::Arc::new(MockClient("summary"));
1203        let compactor = ContextCompactor::new(client, config);
1204        let msgs = make_messages(3); // 1 system + 6 conversation = 7
1205        let result = compactor
1206            .compact(1, &msgs, CompressionTrigger::Auto, None)
1207            .await
1208            .unwrap();
1209        assert!(result.is_none());
1210    }
1211
1212    #[tokio::test]
1213    async fn test_compact_filters_old_summary_in_old_block() {
1214        let config = CompressionConfig::default()
1215            .with_trigger_tokens(1)
1216            .with_keep_recent_messages(4);
1217        let client = std::sync::Arc::new(MockClient("new summary"));
1218        let compactor = ContextCompactor::new(client, config);
1219
1220        // Build messages with an old summary in the old block.
1221        let mut msgs = vec![ChatMessage::system("sys")];
1222        // Old summary (should be filtered).
1223        msgs.push(ChatMessage::user(format!(
1224            "{SUMMARY_PREFIX}\nprevious summary text"
1225        )));
1226        for i in 0..10 {
1227            msgs.push(ChatMessage::user(format!("question {i}")));
1228            msgs.push(ChatMessage::assistant(format!(
1229                "answer {i} with some extra content to make the old block large enough"
1230            )));
1231        }
1232
1233        let result = compactor
1234            .compact(1, &msgs, CompressionTrigger::Auto, None)
1235            .await
1236            .unwrap()
1237            .unwrap();
1238
1239        // The old summary should NOT appear in the output.
1240        let summary_count = result
1241            .iter()
1242            .filter(|m| match m {
1243                ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1244                _ => false,
1245            })
1246            .count();
1247        assert_eq!(
1248            summary_count, 1,
1249            "should have exactly 1 summary (the new one), old one must be filtered"
1250        );
1251    }
1252
1253    #[tokio::test]
1254    async fn test_compact_filters_old_summary_in_recent_block() {
1255        let config = CompressionConfig::default()
1256            .with_trigger_tokens(1)
1257            .with_keep_recent_messages(4);
1258        let client = std::sync::Arc::new(MockClient("new summary"));
1259        let compactor = ContextCompactor::new(client, config);
1260
1261        // Build messages where old summary falls in the recent window.
1262        let mut msgs = vec![ChatMessage::system("sys")];
1263        for i in 0..5 {
1264            msgs.push(ChatMessage::user(format!("question {i}")));
1265            msgs.push(ChatMessage::assistant(format!(
1266                "answer {i} with some extra content to make the old block large enough"
1267            )));
1268        }
1269        // Old summary at the end of old block / start of recent.
1270        msgs.push(ChatMessage::user(format!(
1271            "{SUMMARY_PREFIX}\nold summary in recent"
1272        )));
1273        for i in 5..10 {
1274            msgs.push(ChatMessage::user(format!("question {i}")));
1275            msgs.push(ChatMessage::assistant(format!(
1276                "answer {i} with some extra content to make the old block large enough"
1277            )));
1278        }
1279
1280        let result = compactor
1281            .compact(1, &msgs, CompressionTrigger::Auto, None)
1282            .await
1283            .unwrap()
1284            .unwrap();
1285
1286        // Only the new summary should remain.
1287        let summary_count = result
1288            .iter()
1289            .filter(|m| match m {
1290                ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1291                _ => false,
1292            })
1293            .count();
1294        assert_eq!(
1295            summary_count, 1,
1296            "old summary in recent block must be filtered"
1297        );
1298    }
1299
1300    #[tokio::test]
1301    #[ignore] // Requires real API key: DEEPSEEK_API_KEY
1302    async fn test_compact_with_real_deepseek_api() {
1303        // This test calls the real DeepSeek API to verify compact() works end-to-end.
1304        // Run with: cargo test test_compact_with_real_deepseek_api -- --nocapture
1305
1306        let api_key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
1307        if api_key.is_empty() {
1308            eprintln!("Skipping test: DEEPSEEK_API_KEY not set");
1309            return;
1310        }
1311
1312        let base_url = std::env::var("DEEPSEEK_BASE_URL")
1313            .unwrap_or_else(|_| "https://api.deepseek.com".to_string());
1314
1315        // Create a real OpenAI client pointing to DeepSeek.
1316        let client: std::sync::Arc<dyn agent_base::StreamClient> = std::sync::Arc::new(
1317            agent_base::OpenAiClient::new(api_key, "deepseek-v4-flash".to_string(), Some(base_url)),
1318        );
1319
1320        let config = CompressionConfig::default()
1321            .with_trigger_tokens(1) // Always trigger.
1322            .with_keep_recent_messages(4);
1323
1324        let compactor = ContextCompactor::new(client, config);
1325
1326        // Create messages similar to the user's test case.
1327        let msgs = vec![
1328            ChatMessage::system("You are a versatile AI assistant."),
1329            ChatMessage::user("你好".to_string()),
1330            ChatMessage::assistant("你好!有什么我可以帮你的吗?".to_string()),
1331            ChatMessage::user("来一首古诗".to_string()),
1332            ChatMessage::assistant(
1333                "好的,送你一首王维的《山居秋暝》:\n空山新雨后,天气晚来秋。".to_string(),
1334            ),
1335            ChatMessage::user("再来".to_string()),
1336            ChatMessage::assistant("秋江独酌\n清秋江上月,独酌对孤舟。".to_string()),
1337            ChatMessage::user("再来一首".to_string()),
1338            ChatMessage::assistant("夏日山居\n薰风拂面柳丝柔,蝉噪林深夏日幽。".to_string()),
1339            ChatMessage::user("再来".to_string()),
1340        ];
1341
1342        let result = compactor
1343            .compact(1, &msgs, CompressionTrigger::Auto, None)
1344            .await
1345            .unwrap();
1346
1347        match result {
1348            Some(compressed) => {
1349                eprintln!("=== Compact Result ===");
1350                eprintln!("Messages: {} -> {}", msgs.len(), compressed.len());
1351                for (i, msg) in compressed.iter().enumerate() {
1352                    match msg {
1353                        ChatMessage::User { content, .. } => {
1354                            let preview = if content.len() > 100 {
1355                                &content[..100]
1356                            } else {
1357                                content
1358                            };
1359                            eprintln!("{}: [user] {}", i, preview);
1360                        }
1361                        ChatMessage::Assistant { content, .. } => {
1362                            let text = content.as_deref().unwrap_or("");
1363                            let preview = if text.len() > 100 { &text[..100] } else { text };
1364                            eprintln!("{}: [assistant] {}", i, preview);
1365                        }
1366                        ChatMessage::System { content, .. } => {
1367                            let preview = if content.len() > 100 {
1368                                &content[..100]
1369                            } else {
1370                                content
1371                            };
1372                            eprintln!("{}: [system] {}", i, preview);
1373                        }
1374                        _ => {}
1375                    }
1376                }
1377                eprintln!("======================");
1378
1379                // Check that the summary doesn't start with "An alternate model reviewed".
1380                let summary_msg = compressed.iter().find(|m| match m {
1381                    ChatMessage::User { content, .. } => content.starts_with(SUMMARY_PREFIX),
1382                    _ => false,
1383                });
1384
1385                if let Some(ChatMessage::User { content, .. }) = summary_msg {
1386                    let summary_content = content
1387                        .strip_prefix(SUMMARY_PREFIX)
1388                        .unwrap_or(content)
1389                        .trim();
1390                    let preview = if summary_content.len() > 200 {
1391                        &summary_content[..200]
1392                    } else {
1393                        summary_content
1394                    };
1395                    eprintln!("=== Summary Content ===");
1396                    eprintln!("{}", preview);
1397                    eprintln!("========================");
1398
1399                    assert!(
1400                        !summary_content.starts_with("An alternate model reviewed"),
1401                        "Summary should not start with 'An alternate model reviewed', got: {}",
1402                        preview
1403                    );
1404                }
1405            }
1406            None => {
1407                eprintln!("Compact returned None (no compression needed)");
1408            }
1409        }
1410    }
1411}