Skip to main content

molo_agent/memory/
summarize.rs

1//! Summarization trim strategy: uses an LLM to compact over-budget old
2//! messages into a single summary.
3//!
4//! [`SummarizeStrategy`] is a ready-to-use implementation of
5//! [`TrimStrategy`](crate::memory::TrimStrategy) that holds a
6//! [`Provider`](crate::Provider) to call a summarizer model: when over budget
7//! it keeps the most recent rounds verbatim (the tail of the conversation
8//! carries the strongest signal and is not compacted), textifies the earlier
9//! messages, and hands them to the summarizer; the output replaces the
10//! compacted portion as a single **System summary message** and is
11//! materialized (`replace: true`) — one compaction covers many rounds, until
12//! new messages breach the budget again.
13//!
14//! The summary message sits at the front of the result sequence and uses the
15//! System role: a summary is not "what the user said" — it is
16//! framework-injected history compaction, and the model should treat it as
17//! context background rather than conversation content. The summary message
18//! also obeys Memory's trim rules and participates as input in the next
19//! compaction when over budget again (incremental merge — the summary is never
20//! compacted repeatedly).
21//!
22//! A failed summarization (network / rate limit etc.) does not interrupt the
23//! conversation: it degrades to window dropping (projection — storage keeps
24//! the original text and summarization is retried on the next over-budget
25//! retrieval), logging a single warn.
26
27use std::fmt;
28
29use super::window::{count_message, keep_rounds, split_rounds};
30use super::{Budget, MemoryError, TokenCounter, TrimResult, TrimStrategy, WindowDrop};
31use crate::message::{ContentBlock, Message};
32use crate::provider::{ChatRequest, ModelOptions, Provider};
33
34/// Content prefix of the summary message: lets the model and downstream tools
35/// recognize "this is a historical summary" (fed as the prior-summary input on
36/// the next compaction, never re-summarized).
37const SUMMARY_PREFIX: &str = "Summary of prior context:\n";
38
39/// Default summarization prompt: instruction + requirements, sent to the
40/// summarizer model.
41const DEFAULT_SUMMARY_PROMPT: &str = "\
42You are a conversation-history compressor. Compress the provided conversation history into a concise summary so the model can recover context in subsequent conversation.
43
44Requirements:
45- Preserve the task goal, current state, decisions made, open items, and next steps;
46- Keep proper nouns such as code identifiers, file paths, URLs, and numbers verbatim;
47- The history may contain a \"Summary of prior context\" (system message): first understand the prior summary, then merge it with the new history and output a single integrated summary; do not re-summarize the prior summary itself;
48- Output only the summary body, with no explanations or surrounding text.";
49
50/// Summarization trim strategy: over-budget old messages → summarizer → one
51/// System summary message.
52///
53/// # Behavior
54///
55/// - **Keep the most recent rounds verbatim**: keep complete rounds from the
56///   tail forward until the budget is approached (the budget first deducts a
57///   reservation for the summary output, see
58///   [`with_summary_max_tokens`](SummarizeStrategy::with_summary_max_tokens));
59///   at least the most recent round is kept (kept even when a single round
60///   exceeds the budget; nothing more can be compacted);
61/// - **Incremental compaction**: when over budget again, the previous summary
62///   message goes to the summarizer as input (prior summary), producing an
63///   integrated new summary — the summary is never compacted repeatedly
64///   (semantic drift), and information already compacted is not lost;
65/// - **Materialization**: `replace: true`, the compaction result is written
66///   back to storage with zero recomputation afterwards;
67/// - **Failure degradation**: when the summarizer call fails, degrade to
68///   window dropping (projection — storage keeps the original text, retried on
69///   the next over-budget retrieval); the conversation is not interrupted.
70///
71/// The summary message uses the System role and sits at the front of the
72/// sequence (a summary is not conversation content but history compaction; the
73/// System role also avoids consecutive User messages after compaction). The
74/// summary message's tokens count toward the budget and obey the window trim
75/// rules like any other message.
76///
77/// # Comparison
78///
79/// Compared to the default [`WindowDrop`]: window dropping is a lossless
80/// projection with cheap per-turn recomputation, suited to ad-hoc inspection;
81/// this strategy trades one summarizer call for a longer compactable span,
82/// suited to long conversations that must not exceed limits, at the cost of
83/// the summarizer call (cost + latency) and information loss.
84///
85/// # Example
86///
87/// Inject the summarization strategy (using
88/// [`FakeProvider`](crate::FakeProvider) in place of a real summarizer to
89/// demonstrate the full flow):
90///
91/// ```rust
92/// # extern crate molo_agent as molo;
93/// use std::sync::Arc;
94/// use molo::memory::{Memory, SummarizeStrategy, WindowMemory};
95/// use molo::{FakeProvider, FakeReply, Message};
96///
97/// #[tokio::main]
98/// async fn main() -> Result<(), molo::memory::MemoryError> {
99///     let fake = Arc::new(FakeProvider::new([FakeReply::Text("Key points from earlier rounds".into())]));
100///     let mut memory = WindowMemory::new(30)
101///         .with_strategy(Arc::new(SummarizeStrategy::new(fake)));
102///     for i in 1..=4 {
103///         memory.record(Message::user(format!("Question from round {i}"))).await?;
104///         memory.record(Message::assistant(format!("Answer from round {i}"))).await?;
105///     }
106///
107///     // 44 tokens total > budget 30: compacted to [summary, most recent round].
108///     let context = memory.context().await?;
109///     assert_eq!(context.len(), 3);
110///     assert!(matches!(context[0], Message::System(_)));
111///     assert_eq!(context[1], Message::user("Question from round 4"));
112///     Ok(())
113/// }
114/// ```
115pub struct SummarizeStrategy {
116    provider: Box<dyn Provider>,
117    prompt: String,
118    /// Token cap for the summary output (also the budget reservation for kept
119    /// rounds).
120    summary_max_tokens: u32,
121    /// Request-scoped model options for the summarizer call (headers,
122    /// temperature, extra parameters). The strategy builds its own request, so
123    /// it does not see the agent's [`ModelOptions`]; anything the endpoint
124    /// needs on *every* call (an auth header for a gateway, a tenant id) has
125    /// to be set here as well, or the summarizer call fails and degrades to
126    /// window dropping.
127    options: ModelOptions,
128}
129
130impl fmt::Debug for SummarizeStrategy {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        // Provider is a trait object and cannot derive Debug: print the
133        // comparable fields and mark the Provider's type.
134        f.debug_struct("SummarizeStrategy")
135            .field("provider", &"Box<dyn Provider>")
136            .field("prompt_len", &self.prompt.len())
137            .field("summary_max_tokens", &self.summary_max_tokens)
138            .field("options", &self.options)
139            .finish()
140    }
141}
142
143impl SummarizeStrategy {
144    /// Creates a summarization strategy with the default prompt and a default
145    /// summary output cap (1024 tokens).
146    pub fn new(provider: impl Provider + 'static) -> Self {
147        Self {
148            provider: Box::new(provider),
149            prompt: DEFAULT_SUMMARY_PROMPT.into(),
150            summary_max_tokens: 1024,
151            options: ModelOptions::default(),
152        }
153    }
154
155    /// Replaces the default summarization prompt (instruction + requirements;
156    /// the history text is appended by the strategy).
157    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
158        self.prompt = prompt.into();
159        self
160    }
161
162    /// Sets the token cap for the summary output (default 1024).
163    ///
164    /// The value doubles as the budget reservation for kept rounds: the kept
165    /// rounds' token total stays within "budget − summary cap", leaving room
166    /// for the summary output.
167    ///
168    /// Kept authoritative over [`with_options`](Self::with_options)'s
169    /// `max_tokens`: the cap is coupled with the budget reservation, so a
170    /// different cap would make the reservation wrong.
171    pub fn with_summary_max_tokens(mut self, max_tokens: u32) -> Self {
172        self.summary_max_tokens = max_tokens;
173        self
174    }
175
176    /// Sets the model options carried by the summarizer call: request headers
177    /// ([`ModelOptions::headers`]), sampling parameters, and vendor extension
178    /// parameters.
179    ///
180    /// The strategy sends its own request (system prompt + textified history),
181    /// so it does not inherit the agent's [`ModelOptions`]. Endpoints that
182    /// require something on every call — a gateway route or auth header, a
183    /// tenant id — must have it set here too; otherwise the summarizer call is
184    /// the one that fails.
185    ///
186    /// ```rust
187    /// # extern crate molo_agent as molo;
188    /// use molo::memory::SummarizeStrategy;
189    /// use molo::provider::{FakeProvider, FakeReply, ModelOptions};
190    ///
191    /// let mut options = ModelOptions::default();
192    /// options.headers.insert("x-tenant".into(), "acme".into());
193    ///
194    /// let strategy = SummarizeStrategy::new(FakeProvider::new([FakeReply::Text(
195    ///     "Earlier rounds in brief".into(),
196    /// )]))
197    /// .with_options(options);
198    /// # let _ = strategy;
199    /// ```
200    pub fn with_options(mut self, options: ModelOptions) -> Self {
201        self.options = options;
202        self
203    }
204
205    /// Shared implementation of the summarization call: decide the kept rounds
206    /// → send the compacted part to the summarizer → assemble
207    /// [summary message, kept rounds].
208    async fn trim_impl(
209        &self,
210        messages: &[Message],
211        counts: &[usize],
212        budget: &Budget,
213        counter: &dyn TokenCounter,
214        fallback: &WindowDrop,
215    ) -> Result<TrimResult, MemoryError> {
216        // Keep the most recent rounds: budget minus the reservation for the
217        // summary output; at least 1 round (fallback).
218        let rounds = split_rounds(messages, counts);
219        let keep = keep_rounds(&rounds, budget, self.summary_max_tokens as usize);
220        let cut = rounds[rounds.len() - keep].0;
221        if cut == 0 {
222            // Everything is kept (single round over budget, or within budget):
223            // nothing to compact.
224            return Ok(TrimResult {
225                messages: messages.to_vec(),
226                replace: false,
227            });
228        }
229
230        let request = ChatRequest {
231            messages: vec![
232                Message::system(self.prompt.clone()),
233                Message::user(messages_to_text(&messages[..cut])),
234            ],
235            tools: Vec::new(),
236            options: ModelOptions {
237                // The cap is the budget reservation, so it governs the request
238                // regardless of what `with_options` was given (see
239                // `with_summary_max_tokens`).
240                max_tokens: Some(self.summary_max_tokens),
241                ..self.options.clone()
242            },
243        };
244        let summary = match self.provider.chat(request).await {
245            Ok(response) => {
246                // The summarizer should reply with an Assistant message; other
247                // roles degrade defensively.
248                let Message::Assistant { content, .. } = &response.message else {
249                    return fallback
250                        .trim_with_counts(messages, counts, budget, counter)
251                        .await;
252                };
253                if content.trim().is_empty() {
254                    None
255                } else {
256                    Some(content.trim().to_string())
257                }
258            }
259            Err(error) => {
260                // On failure, degrade to window dropping (projection): the
261                // conversation continues, storage keeps the original text, and
262                // summarization is retried on the next over-budget retrieval.
263                #[cfg(feature = "tracing")]
264                tracing::warn!(%error, "summarize failed, falling back to window drop");
265                #[cfg(not(feature = "tracing"))]
266                let _ = error;
267                None
268            }
269        };
270        let Some(summary) = summary else {
271            return fallback
272                .trim_with_counts(messages, counts, budget, counter)
273                .await;
274        };
275
276        let mut result = Vec::with_capacity(keep + 1);
277        result.push(Message::system(format!("{SUMMARY_PREFIX}{summary}")));
278        result.extend_from_slice(&messages[cut..]);
279        // Compaction event (observability): compacted count, kept count,
280        // summary output token count.
281        // The summary message's tokens count toward the budget and may leave
282        // the materialized result over budget (the next retrieval compacts
283        // again) — the log carries this info for diagnosis.
284        #[cfg(feature = "tracing")]
285        {
286            let summary_tokens = count_message(counter, &result[0]).await?;
287            tracing::info!(
288                compressed = cut,
289                kept = messages.len() - cut,
290                summary_tokens,
291                "context summarized by LLM"
292            );
293        }
294        Ok(TrimResult {
295            messages: result,
296            replace: true,
297        })
298    }
299}
300
301#[async_trait::async_trait]
302impl TrimStrategy for SummarizeStrategy {
303    async fn trim(
304        &self,
305        messages: &[Message],
306        budget: &Budget,
307        counter: &dyn TokenCounter,
308    ) -> Result<TrimResult, MemoryError> {
309        let mut counts = Vec::with_capacity(messages.len());
310        for m in messages {
311            counts.push(count_message(counter, m).await?);
312        }
313        self.trim_with_counts(messages, &counts, budget, counter)
314            .await
315    }
316
317    async fn trim_with_counts(
318        &self,
319        messages: &[Message],
320        counts: &[usize],
321        budget: &Budget,
322        counter: &dyn TokenCounter,
323    ) -> Result<TrimResult, MemoryError> {
324        if messages.is_empty() {
325            return Ok(TrimResult {
326                messages: Vec::new(),
327                replace: false,
328            });
329        }
330        debug_assert_eq!(messages.len(), counts.len());
331        let fallback = WindowDrop;
332        self.trim_impl(messages, counts, budget, counter, &fallback)
333            .await
334    }
335}
336
337/// Textifies a message sequence as input for the summarizer.
338///
339/// One `role: content` line per message: System verbatim, User joins content
340/// blocks, Assistant takes the body plus tool calls (name + arguments; the
341/// reasoning trace is not carried), ToolResult takes the result text.
342/// The summary message (prior summary) appears here as a `system:` line, which
343/// is how the summarizer recognizes it.
344fn messages_to_text(messages: &[Message]) -> String {
345    let mut lines = Vec::with_capacity(messages.len());
346    for message in messages {
347        match message {
348            Message::System(s) => lines.push(format!("system: {s}")),
349            Message::User(blocks) => {
350                let text: String = blocks
351                    .iter()
352                    .map(|block| match block {
353                        ContentBlock::Text(t) => t.clone(),
354                        // The summary keeps a placeholder so the context
355                        // "an image was there" survives compression (the
356                        // bytes themselves cannot be summarized).
357                        ContentBlock::Image(image) => format!("[image: {}]", image.mime_type),
358                        // Pass-through blocks are rendered by their wire
359                        // type (e.g. `[audio]`); unknown shapes degrade to
360                        // a generic marker.
361                        ContentBlock::Wire(value) => {
362                            match value.get("type").and_then(|t| t.as_str()) {
363                                Some(kind) => format!("[{kind}]"),
364                                None => "[content]".to_string(),
365                            }
366                        }
367                    })
368                    .collect::<Vec<_>>()
369                    .join(" ");
370                lines.push(format!("user: {text}"));
371            }
372            Message::Assistant {
373                content,
374                tool_calls,
375                ..
376            } => {
377                if tool_calls.is_empty() {
378                    lines.push(format!("assistant: {content}"));
379                } else {
380                    let calls = tool_calls
381                        .iter()
382                        .map(|tc| format!("{} {}", tc.name, tc.arguments))
383                        .collect::<Vec<_>>()
384                        .join("; ");
385                    lines.push(format!("assistant: {content}\ntool_calls: {calls}"));
386                }
387            }
388            Message::ToolResult { content, .. } => lines.push(format!("tool_result: {content}")),
389        }
390    }
391    lines.join("\n")
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::memory::{CharTokenCounter, Memory, WindowMemory};
398    use crate::message::ToolCall;
399    use crate::provider::{FakeProvider, FakeReply, ModelOptions, ProviderError};
400    use std::sync::Arc;
401
402    /// Builds a conversation (round `start` through round `n`; about 11 tokens
403    /// per round).
404    async fn record_rounds(memory: &mut WindowMemory, start: usize, n: usize) {
405        for i in start..=n {
406            memory
407                .record(Message::user(format!("Question from round {i}")))
408                .await
409                .unwrap();
410            memory
411                .record(Message::assistant(format!("Answer from round {i}")))
412                .await
413                .unwrap();
414        }
415    }
416
417    /// Builds the strategy with a shared instance: Arc<FakeProvider> implements
418    /// Provider, so tests can assert the request history from the original
419    /// instance.
420    fn strategy(fake: Arc<FakeProvider>) -> Arc<SummarizeStrategy> {
421        Arc::new(SummarizeStrategy::new(fake))
422    }
423
424    /// Basic compaction: over budget → [summary (System), most recent round];
425    /// the summarizer only receives the compacted part (kept rounds excluded).
426    #[tokio::test]
427    async fn summarizes_old_rounds_and_keeps_recent() {
428        let fake = Arc::new(FakeProvider::new([FakeReply::Text(
429            "Key points from earlier rounds".into(),
430        )]));
431        let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
432        record_rounds(&mut memory, 1, 4).await;
433
434        let context = memory.context().await.unwrap();
435        assert_eq!(context.len(), 3);
436        assert_eq!(
437            context[0],
438            Message::system(format!("{SUMMARY_PREFIX}Key points from earlier rounds"))
439        );
440        assert_eq!(context[1], Message::user("Question from round 4"));
441        assert_eq!(context[2], Message::assistant("Answer from round 4"));
442
443        // Request = [prompt (system), compacted-part text (user)]; kept rounds
444        // excluded.
445        let requests = fake.requests();
446        assert_eq!(requests.len(), 1);
447        let messages = &requests[0].messages;
448        assert!(
449            matches!(&messages[0], Message::System(p) if p.contains("conversation-history compressor"))
450        );
451        let Message::User(blocks) = &messages[1] else {
452            panic!("expected user message");
453        };
454        let ContentBlock::Text(text) = &blocks[0] else {
455            panic!("expected a text block");
456        };
457        assert!(text.contains("Question from round 1") && text.contains("Question from round 3"));
458        assert!(!text.contains("Question from round 4"));
459    }
460
461    /// The summary output cap doubles as the budget reservation for kept
462    /// rounds: a smaller cap → more rounds kept.
463    #[tokio::test]
464    async fn summary_budget_reserves_space_for_recent_rounds() {
465        let fake = Arc::new(FakeProvider::new([FakeReply::Text("Highlights".into())]));
466        // Budget 30, summary cap 4: 26 tokens available for kept rounds. Each
467        // round is 11 tokens ("Question/Answer from round N"): keep the recent
468        // 2 rounds (22); adding a 3rd (33 > 26) stops. Result: [summary,
469        // round 3, round 4].
470        let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
471            SummarizeStrategy::new(fake.clone()).with_summary_max_tokens(4),
472        ));
473        record_rounds(&mut memory, 1, 4).await;
474
475        let context = memory.context().await.unwrap();
476        assert_eq!(context.len(), 5);
477        assert_eq!(context[1], Message::user("Question from round 3"));
478        assert_eq!(context[3], Message::user("Question from round 4"));
479    }
480
481    /// Options set on the strategy are carried by the summarizer call — the
482    /// strategy builds its own request, so this is how a gateway-required
483    /// header reaches the summarizer. The summary cap stays authoritative for
484    /// `max_tokens`: it doubles as the budget reservation.
485    #[tokio::test]
486    async fn strategy_options_are_carried_by_the_summarizer_call() {
487        let fake = Arc::new(FakeProvider::new([FakeReply::Text("Highlights".into())]));
488        let mut options = ModelOptions {
489            temperature: Some(0.2),
490            max_tokens: Some(9_999),
491            ..Default::default()
492        };
493        options.headers.insert("x-tenant".into(), "acme".into());
494        let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
495            SummarizeStrategy::new(fake.clone())
496                .with_summary_max_tokens(4)
497                .with_options(options),
498        ));
499        record_rounds(&mut memory, 1, 4).await;
500        memory.context().await.unwrap();
501
502        let requests = fake.requests();
503        assert_eq!(requests.len(), 1);
504        let options = &requests[0].options;
505        assert_eq!(
506            options.headers.get("x-tenant").map(String::as_str),
507            Some("acme")
508        );
509        assert_eq!(options.temperature, Some(0.2));
510        assert_eq!(options.max_tokens, Some(4));
511    }
512
513    /// Incremental compaction: on the second over-budget event, the previous
514    /// summary is merged into the new one as input.
515    #[tokio::test]
516    async fn incremental_summary_reuses_previous_summary() {
517        let fake = Arc::new(FakeProvider::new([
518            FakeReply::Text("first segment highlights".into()),
519            FakeReply::Text("merged highlights".into()),
520        ]));
521        let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
522            SummarizeStrategy::new(fake.clone()).with_summary_max_tokens(4),
523        ));
524        record_rounds(&mut memory, 1, 4).await;
525        memory.context().await.unwrap(); // first compaction → [summary, u3, a3, u4, a4]
526
527        // Append rounds 5 and 6: over budget again → second compaction.
528        record_rounds(&mut memory, 5, 6).await;
529        let context = memory.context().await.unwrap();
530        assert_eq!(
531            context[0],
532            Message::system(format!("{SUMMARY_PREFIX}merged highlights"))
533        );
534        // Budget 30 minus reserve 4 → 26: keep the 2 most recent rounds
535        // (11 + 11); the earlier messages (including the old summary) go to the
536        // second summarization. Result: [new summary, u5, a5, u6, a6].
537        assert_eq!(context.len(), 5);
538        assert_eq!(context[1], Message::user("Question from round 5"));
539
540        // The second request's input text contains the first summary message
541        // (incremental merge).
542        let requests = fake.requests();
543        assert_eq!(requests.len(), 2);
544        let Message::User(blocks) = &requests[1].messages[1] else {
545            panic!("expected user message");
546        };
547        let ContentBlock::Text(text) = &blocks[0] else {
548            panic!("expected a text block");
549        };
550        assert!(text.contains("first segment highlights"));
551    }
552
553    /// Failure degradation: summarizer call fails → window-dropped view
554    /// (projection, storage unchanged), the conversation continues; the next
555    /// over-budget retrieval retries summarization.
556    #[tokio::test]
557    async fn provider_failure_falls_back_to_window_drop() {
558        let fake = Arc::new(FakeProvider::new([
559            FakeReply::Error(ProviderError::Network("mock network error".into())),
560            FakeReply::Text("second attempt succeeded".into()),
561        ]));
562        let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
563        record_rounds(&mut memory, 1, 4).await;
564
565        // First time: failure → degrade to window dropping (budget 30 keeps the
566        // 2 most recent rounds, no summary message).
567        let context = memory.context().await.unwrap();
568        assert_eq!(
569            context,
570            vec![
571                Message::user("Question from round 3"),
572                Message::assistant("Answer from round 3"),
573                Message::user("Question from round 4"),
574                Message::assistant("Answer from round 4"),
575            ]
576        );
577
578        // Storage was not materialized: still over budget on the second call →
579        // the strategy runs again, this time succeeding.
580        let context = memory.context().await.unwrap();
581        assert_eq!(
582            context[0],
583            Message::system(format!("{SUMMARY_PREFIX}second attempt succeeded"))
584        );
585        assert_eq!(fake.requests().len(), 2);
586    }
587
588    /// Empty summary output (the model produced no text) → likewise falls back
589    /// to window dropping.
590    #[tokio::test]
591    async fn empty_summary_falls_back() {
592        let fake = Arc::new(FakeProvider::new([FakeReply::Text("".into())]));
593        let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
594        record_rounds(&mut memory, 1, 4).await;
595
596        let context = memory.context().await.unwrap();
597        assert_eq!(context.len(), 4); // window dropping: budget 30 keeps the 2 most recent rounds
598        assert!(!context.iter().any(|m| matches!(m, Message::System(_))));
599    }
600
601    /// Single-round over-budget fallback: nothing to compact, returns as-is
602    /// without calling the summarizer.
603    #[tokio::test]
604    async fn single_round_over_budget_returns_as_is() {
605        let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
606        let mut memory = WindowMemory::new(5).with_strategy(strategy(fake.clone()));
607        memory
608            .record(Message::user(
609                "An extremely long user message, over budget in a single round",
610            ))
611            .await
612            .unwrap();
613        memory.record(Message::assistant("Reply")).await.unwrap();
614
615        let context = memory.context().await.unwrap();
616        assert_eq!(context.len(), 2);
617        assert!(
618            fake.requests().is_empty(),
619            "with nothing to compact the summarizer must not be called"
620        );
621    }
622
623    /// Defensive: an empty message list is returned as-is.
624    #[tokio::test]
625    async fn empty_input_returns_empty() {
626        let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
627        let strategy = strategy(fake);
628        let result = strategy
629            .trim(&[], &Budget::tokens(10), &CharTokenCounter)
630            .await
631            .unwrap();
632        assert!(result.messages.is_empty());
633        assert!(!result.replace);
634    }
635
636    /// Tool-call round textification: the assistant message carries the tool
637    /// name and arguments, with the tool result on its own line.
638    #[tokio::test]
639    async fn messages_to_text_includes_tool_calls_and_results() {
640        let messages = vec![
641            Message::system("setup"),
642            Message::user("calculate"),
643            Message::Assistant {
644                content: "".into(),
645                reasoning: Some("reasoning trace is not included".into()),
646                tool_calls: vec![ToolCall {
647                    id: "t1".into(),
648                    name: "calc".into(),
649                    arguments: r#"{"expr":"1+1"}"#.into(),
650                }],
651            },
652            Message::tool_result("t1", "2"),
653        ];
654        let text = messages_to_text(&messages);
655        assert!(text.contains("system: setup"));
656        assert!(text.contains("user: calculate"));
657        assert!(text.contains(r#"tool_calls: calc {"expr":"1+1"}"#));
658        assert!(text.contains("tool_result: 2"));
659        // The reasoning trace never enters the summary input.
660        assert!(!text.contains("reasoning trace is not included"));
661    }
662
663    /// The textified input counts against the kept-round budget (same
664    /// convention as window trimming).
665    #[tokio::test]
666    async fn trim_counts_consistent_with_window() {
667        let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
668        let strategy = strategy(fake);
669        let messages = vec![
670            Message::user("u1"),
671            Message::assistant("a1"),
672            Message::user("u2"),
673            Message::assistant("a2"),
674        ];
675        let counter = CharTokenCounter;
676        let result = strategy
677            .trim_with_counts(&messages, &[1, 1, 1, 1], &Budget::tokens(2), &counter)
678            .await
679            .unwrap();
680        // Budget 2 − reserve 1024 → only 1 round kept: summary + u2/a2.
681        assert_eq!(result.messages.len(), 3);
682        assert!(result.replace);
683    }
684}