Skip to main content

oxi_ai/
compaction.rs

1//! Context compaction for long conversations
2//!
3//! This module provides functionality to compact conversation history when it
4//! becomes too large, using the LLM itself to summarize older messages.
5
6use crate::high_level::complete;
7use crate::high_level::tokens::estimate as estimate_tokens;
8use crate::{
9    Api, AssistantMessage, ContentBlock, Context, Message, Model, Provider, StreamOptions,
10    TextContent, UserMessage,
11};
12
13/// Safely truncate a string to a maximum number of characters, appending "..." if truncated.
14fn safe_truncate(s: &str, max_chars: usize) -> String {
15    if s.len() <= max_chars {
16        return s.to_string();
17    }
18    let boundary = s
19        .char_indices()
20        .take_while(|(i, _)| *i <= max_chars)
21        .last()
22        .map(|(i, c)| i + c.len_utf8())
23        .unwrap_or(0);
24    format!("{}...", &s[..boundary])
25}
26
27/// Generate a concise summary of the last N conversation messages.
28///
29/// Returns a string summarizing key topics and decisions without
30/// requiring a full compaction step.
31pub fn generate_branch_summary(messages: &[Message], n: usize) -> String {
32    if messages.is_empty() {
33        return "(empty conversation)".to_string();
34    }
35
36    let last_n: Vec<_> = if n > 0 {
37        messages.iter().rev().take(n).collect()
38    } else {
39        messages.iter().collect()
40    };
41
42    let mut topics = Vec::new();
43    let mut decisions = Vec::new();
44
45    for msg in last_n.iter().rev() {
46        let role = match msg {
47            Message::User(_) => "user",
48            Message::Assistant(_) => "assistant",
49            Message::ToolResult(_) => "tool",
50        };
51        let content = msg.text_content().unwrap_or_default();
52        let preview = safe_truncate(&content, 120);
53
54        // Detect code/file references
55        if content.contains("created file") || content.contains("edited file") {
56            topics.push("file modifications".to_string());
57        }
58        if content.contains("implemented") || content.contains("added feature") {
59            topics.push("feature implementation".to_string());
60        }
61        if content.contains("decided") || content.contains("chose") || content.contains("agreed") {
62            decisions.push(preview);
63        }
64        if content.contains("search") || content.contains("debug") || content.contains("fix") {
65            topics.push(format!("inquiry/analysis by {}", role));
66        }
67    }
68
69    // Deduplicate topics
70    topics.dedup();
71    decisions.dedup();
72
73    let summary = if topics.is_empty() && decisions.is_empty() {
74        // Fallback: just the last message preview
75        messages
76            .last()
77            .and_then(|m| m.text_content().ok())
78            .map(|c| safe_truncate(&c, 200))
79            .unwrap_or_else(|| "(no content)".to_string())
80    } else {
81        let mut parts = Vec::new();
82        if !topics.is_empty() {
83            parts.push(format!("Topics: {}", topics.join(", ")));
84        }
85        if !decisions.is_empty() {
86            parts.push(format!("Decisions: {}", decisions.join("; ")));
87        }
88        parts.join(" | ")
89    };
90
91    format!("[Branch summary of {} msgs] {}", messages.len(), summary)
92}
93
94use chrono::{DateTime, Utc};
95use serde::{Deserialize, Serialize};
96use std::future::Future;
97use std::pin::Pin;
98use std::sync::Arc;
99use std::time::Duration;
100
101/// Compaction configuration for LLM-based compaction
102#[derive(Debug, Clone)]
103pub struct CompactionConfig {
104    /// How many recent messages to always keep (not compacted)
105    pub keep_recent: usize,
106    /// Maximum number of old messages to include in one summarization batch
107    pub max_batch: usize,
108    /// Target compaction ratio (0.0 to 1.0) - e.g., 0.5 means reduce to 50%
109    pub target_ratio: f32,
110    /// Maximum tokens for the summary response
111    pub summary_max_tokens: usize,
112    /// Temperature for summarization (lower = more focused)
113    pub temperature: f32,
114    /// Timeout for LLM compaction requests
115    pub timeout: Duration,
116    /// Custom instruction for the summarizer
117    pub custom_instruction: Option<String>,
118}
119
120impl CompactionConfig {
121    /// Create a default compaction configuration
122    pub fn new() -> Self {
123        Self {
124            keep_recent: 4,
125            max_batch: 20,
126            target_ratio: 0.5,
127            summary_max_tokens: 1024,
128            temperature: 0.3,
129            timeout: Duration::from_secs(60),
130            custom_instruction: None,
131        }
132    }
133
134    /// Set how many recent messages to always keep
135    pub fn with_keep_recent(mut self, count: usize) -> Self {
136        self.keep_recent = count;
137        self
138    }
139
140    /// Set maximum batch size for summarization
141    pub fn with_max_batch(mut self, count: usize) -> Self {
142        self.max_batch = count;
143        self
144    }
145
146    /// Set target compaction ratio (0.0 to 1.0)
147    pub fn with_target_ratio(mut self, ratio: f32) -> Self {
148        self.target_ratio = ratio.clamp(0.1, 0.9);
149        self
150    }
151
152    /// Set maximum tokens for summary
153    pub fn with_summary_max_tokens(mut self, tokens: usize) -> Self {
154        self.summary_max_tokens = tokens;
155        self
156    }
157
158    /// Set temperature for summarization
159    pub fn with_temperature(mut self, temp: f32) -> Self {
160        self.temperature = temp.clamp(0.0, 1.0);
161        self
162    }
163
164    /// Set timeout for LLM requests
165    pub fn with_timeout(mut self, timeout: Duration) -> Self {
166        self.timeout = timeout;
167        self
168    }
169
170    /// Set custom instruction for the summarizer
171    pub fn with_custom_instruction(mut self, instruction: impl Into<String>) -> Self {
172        self.custom_instruction = Some(instruction.into());
173        self
174    }
175}
176
177impl Default for CompactionConfig {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// Metadata about a compaction operation
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185pub struct CompactionMetadata {
186    /// Estimated token count before compaction
187    pub original_tokens: usize,
188    /// Estimated token count after compaction
189    pub compacted_tokens: usize,
190    /// Number of messages that were compacted
191    pub messages_compacted: usize,
192    /// Number of messages kept
193    pub messages_kept: usize,
194    /// Timestamp of compaction
195    pub timestamp: DateTime<Utc>,
196    /// Target ratio used
197    pub target_ratio: f32,
198    /// Actual compaction ratio achieved
199    pub actual_ratio: f32,
200    /// Whether the operation was successful
201    pub success: bool,
202    /// Error message if the operation failed
203    pub error: Option<String>,
204}
205
206impl CompactionMetadata {
207    /// Create new metadata for a successful compaction
208    pub fn new(
209        original_tokens: usize,
210        compacted_tokens: usize,
211        messages_compacted: usize,
212        messages_kept: usize,
213        target_ratio: f32,
214    ) -> Self {
215        let actual_ratio = if original_tokens > 0 {
216            compacted_tokens as f32 / original_tokens as f32
217        } else {
218            1.0
219        };
220
221        Self {
222            original_tokens,
223            compacted_tokens,
224            messages_compacted,
225            messages_kept,
226            timestamp: Utc::now(),
227            target_ratio,
228            actual_ratio,
229            success: true,
230            error: None,
231        }
232    }
233
234    /// Create metadata for a failed compaction
235    pub fn failed(
236        original_tokens: usize,
237        messages_compacted: usize,
238        target_ratio: f32,
239        error: impl Into<String>,
240    ) -> Self {
241        Self {
242            original_tokens,
243            compacted_tokens: original_tokens,
244            messages_compacted,
245            messages_kept: 0,
246            timestamp: Utc::now(),
247            target_ratio,
248            actual_ratio: 1.0,
249            success: false,
250            error: Some(error.into()),
251        }
252    }
253
254    /// Get the compression factor (how much the context was reduced)
255    pub fn compression_factor(&self) -> f32 {
256        if self.actual_ratio > 0.0 {
257            1.0 - self.actual_ratio
258        } else {
259            0.0
260        }
261    }
262
263    /// Get tokens saved from compaction
264    pub fn tokens_saved(&self) -> usize {
265        self.original_tokens.saturating_sub(self.compacted_tokens)
266    }
267}
268
269/// Result of context compaction
270#[derive(Debug, Clone, Default)]
271pub struct CompactedContext {
272    /// Summary of the compacted messages
273    pub summary: String,
274    /// Messages that were kept (typically recent ones)
275    pub kept_messages: Vec<Message>,
276    /// Number of messages that were compacted
277    pub compacted_count: usize,
278    /// Metadata about the compaction operation
279    pub metadata: CompactionMetadata,
280    /// Optional rendered PNG frames (snapcompact). `None` for LLM
281    /// compaction. Stored as `(frame_index, png_bytes)` so downstream
282    /// code can attach the bytes as image content to the next
283    /// assistant turn.
284    pub frames: Option<FrameBag>,
285}
286
287/// Rendered snapcompact PNG frames: `(frame_index, png_bytes)`.
288pub type FrameBag = std::sync::Arc<Vec<(u32, Vec<u8>)>>;
289
290impl CompactedContext {
291    /// Create a new compacted context (no rendered frames).
292    pub fn new(
293        summary: String,
294        kept_messages: Vec<Message>,
295        compacted_count: usize,
296        metadata: CompactionMetadata,
297    ) -> Self {
298        Self {
299            summary,
300            kept_messages,
301            compacted_count,
302            metadata,
303            frames: None,
304        }
305    }
306
307    /// Get the summary text
308    pub fn summary(&self) -> &str {
309        &self.summary
310    }
311
312    /// Get kept messages count
313    pub fn kept_count(&self) -> usize {
314        self.kept_messages.len()
315    }
316
317    /// Get compacted messages count
318    pub fn compacted_count(&self) -> usize {
319        self.compacted_count
320    }
321
322    /// Get the compaction metadata
323    pub fn metadata(&self) -> &CompactionMetadata {
324        &self.metadata
325    }
326
327    /// Check if compaction was successful
328    pub fn is_success(&self) -> bool {
329        self.metadata.success
330    }
331}
332
333/// Compaction strategy determining when to compact
334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
335pub enum CompactionStrategy {
336    /// Never compact context
337    Disabled,
338    /// Compact when context is at least this percentage full (0.0 to 1.0)
339    Threshold(f32),
340    /// Compact after every N turns
341    EveryNTurns(usize),
342    /// Compact when context exceeds this absolute token count
343    AbsoluteTokens(usize),
344    /// Always compact using the snapcompact PNG renderer.
345    /// The compactor must be set to a `SnapcompactCompactor`
346    /// (from `oxi-sdk`) for this to produce frames.
347    Snapcompact,
348}
349
350impl CompactionStrategy {
351    /// Check if compaction should happen based on strategy
352    ///
353    /// # Arguments
354    /// * `context_tokens` - Estimated token count of current context
355    /// * `context_window` - Total context window size
356    /// * `iteration` - Current iteration count
357    ///
358    /// # Returns
359    /// `true` if compaction should be triggered
360    pub fn should_compact(
361        &self,
362        context_tokens: usize,
363        context_window: usize,
364        iteration: usize,
365    ) -> bool {
366        match self {
367            CompactionStrategy::Disabled => false,
368            CompactionStrategy::Threshold(threshold) => {
369                if context_window == 0 {
370                    return false;
371                }
372                let usage = context_tokens as f32 / context_window as f32;
373                usage >= *threshold
374            }
375            CompactionStrategy::EveryNTurns(n) => iteration > 0 && iteration.is_multiple_of(*n),
376            CompactionStrategy::AbsoluteTokens(max_tokens) => context_tokens >= *max_tokens,
377            CompactionStrategy::Snapcompact => true,
378        }
379    }
380}
381
382impl Default for CompactionStrategy {
383    fn default() -> Self {
384        CompactionStrategy::Threshold(0.8)
385    }
386}
387
388/// Error type for compaction operations
389#[derive(Debug, Clone)]
390pub enum CompactionError {
391    /// Compaction request to LLM failed
392    LlmError(String),
393    /// No messages to compact
394    NoMessagesToCompact,
395    /// Too few messages to compact (need at least keep_recent + 1)
396    TooFewMessages {
397        /// Total messages available.
398        total: usize,
399        /// Minimum messages needed (`keep_recent + 1`).
400        keep_recent: usize,
401    },
402    /// Compaction was disabled
403    CompactionDisabled,
404    /// Context window not available
405    NoContextWindow,
406}
407
408impl std::fmt::Display for CompactionError {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        match self {
411            CompactionError::LlmError(msg) => write!(f, "LLM compaction failed: {}", msg),
412            CompactionError::NoMessagesToCompact => write!(f, "No messages to compact"),
413            CompactionError::TooFewMessages { total, keep_recent } => {
414                write!(
415                    f,
416                    "Not enough messages ({}) to compact (need at least {} for keep_recent)",
417                    total,
418                    keep_recent + 1
419                )
420            }
421            CompactionError::CompactionDisabled => write!(f, "Compaction is disabled"),
422            CompactionError::NoContextWindow => write!(f, "Context window not configured"),
423        }
424    }
425}
426
427impl std::error::Error for CompactionError {}
428
429/// Trait for context compaction implementations
430pub trait Compactor: Send + Sync {
431    /// Compact messages, returning a summary and kept messages
432    fn compact<'a>(
433        &'a self,
434        messages: &'a [Message],
435        instruction: Option<&'a str>,
436    ) -> Pin<
437        Box<
438            dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
439        >,
440    >;
441
442    /// Estimate the token count of messages
443    fn estimate_tokens(&self, messages: &[Message]) -> usize {
444        messages
445            .iter()
446            .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
447            .sum()
448    }
449}
450
451/// Context transformer — applied before provider stream call.
452///
453/// Used by snapcompact inline imaging to replace large tool results
454/// with PNG frames, reducing token usage on vision-capable models.
455pub trait ContextTransformer: Send + Sync {
456    /// Transform the context before sending to the provider.
457    fn transform<'a>(
458        &'a self,
459        context: &'a Context,
460        model: &'a Model,
461    ) -> Pin<Box<dyn Future<Output = Context> + Send + 'a>>;
462}
463
464/// A no-op transformer that returns the context unchanged.
465pub struct NoopContextTransformer;
466
467impl ContextTransformer for NoopContextTransformer {
468    fn transform<'a>(
469        &'a self,
470        context: &'a Context,
471        _model: &'a Model,
472    ) -> Pin<Box<dyn Future<Output = Context> + Send + 'a>> {
473        Box::pin(async move { context.clone() })
474    }
475}
476
477/// LLM-based compactor that uses the model itself to summarize
478pub struct LlmCompactor {
479    model: Model,
480    _provider: Arc<dyn Provider>,
481    config: CompactionConfig,
482}
483
484impl LlmCompactor {
485    /// Create a new LLM compactor with default configuration
486    pub fn new(model: Model, provider: Arc<dyn Provider>) -> Self {
487        Self {
488            model,
489            _provider: provider,
490            config: CompactionConfig::new(),
491        }
492    }
493
494    /// Create a new LLM compactor with custom configuration
495    pub fn with_config(
496        model: Model,
497        provider: Arc<dyn Provider>,
498        config: CompactionConfig,
499    ) -> Self {
500        Self {
501            model,
502            _provider: provider,
503            config,
504        }
505    }
506
507    /// Set how many recent messages to always keep
508    pub fn with_keep_recent(mut self, count: usize) -> Self {
509        self.config.keep_recent = count;
510        self
511    }
512
513    /// Set maximum batch size for summarization
514    pub fn with_max_batch(mut self, count: usize) -> Self {
515        self.config.max_batch = count;
516        self
517    }
518
519    /// Set target compaction ratio
520    pub fn with_target_ratio(mut self, ratio: f32) -> Self {
521        self.config.target_ratio = ratio.clamp(0.1, 0.9);
522        self
523    }
524
525    /// Build the summarization prompt
526    fn build_summarize_prompt(&self, messages: &[Message], instruction: Option<&str>) -> String {
527        let mut prompt = String::new();
528
529        prompt.push_str("Summarize the following conversation concisely. ");
530        prompt.push_str("Capture the key points, decisions, and any ongoing tasks or context.\n\n");
531
532        if let Some(instr) = instruction {
533            prompt.push_str(&format!("Focus areas: {}\n\n", instr));
534        } else if let Some(ref custom_instr) = self.config.custom_instruction {
535            prompt.push_str(&format!("Focus areas: {}\n\n", custom_instr));
536        }
537
538        prompt.push_str("## Conversation to summarize:\n");
539
540        for (i, msg) in messages.iter().enumerate() {
541            let role = match msg {
542                Message::User(_) => "User",
543                Message::Assistant(_) => "Assistant",
544                Message::ToolResult(_) => "Tool",
545            };
546            let content = msg.text_content().unwrap_or_default();
547            let content_preview = safe_truncate(&content, 500);
548            prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
549        }
550
551        prompt.push_str("\n## Summary:\n");
552        prompt
553            .push_str("Provide a concise summary that captures the essence of this conversation.");
554
555        prompt
556    }
557
558    /// Attempt to compact using a fallback strategy if LLM fails
559    async fn compact_with_fallback(
560        &self,
561        old_messages: &[Message],
562        recent_messages: &[Message],
563        instruction: Option<&str>,
564    ) -> std::result::Result<CompactedContext, CompactionError> {
565        // Try LLM-based summarization first
566        match self.summarize_with_llm(old_messages, instruction).await {
567            Ok(summary) => {
568                // Build the summary message
569                let mut summary_msg =
570                    AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
571                summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
572                    "[Previous conversation summarized: {}]",
573                    summary
574                )))];
575
576                // Build final compacted context
577                let mut kept = vec![Message::Assistant(summary_msg)];
578                kept.extend(recent_messages.iter().cloned());
579
580                let original_tokens = self.estimate_tokens(old_messages);
581                let compacted_tokens = self.estimate_tokens(&kept);
582                let kept_len = kept.len();
583
584                Ok(CompactedContext::new(
585                    summary,
586                    kept,
587                    old_messages.len(),
588                    CompactionMetadata::new(
589                        original_tokens,
590                        compacted_tokens,
591                        old_messages.len(),
592                        kept_len,
593                        self.config.target_ratio,
594                    ),
595                ))
596            }
597            Err(llm_err) => {
598                // Fallback: simple truncation with key topics
599                self.compact_fallback(old_messages, recent_messages)
600                    .await
601                    .map_err(|_| CompactionError::LlmError(llm_err.to_string()))
602            }
603        }
604    }
605
606    /// Summarize messages using the LLM
607    async fn summarize_with_llm(
608        &self,
609        messages: &[Message],
610        instruction: Option<&str>,
611    ) -> std::result::Result<String, CompactionError> {
612        let prompt = self.build_summarize_prompt(messages, instruction);
613
614        let mut context = Context::new();
615        context.set_system_prompt(
616            "You are a helpful assistant that summarizes conversations concisely.",
617        );
618        context.add_message(Message::User(UserMessage::new(prompt)));
619
620        let options = StreamOptions {
621            temperature: Some(self.config.temperature as f64),
622            max_tokens: Some(self.config.summary_max_tokens),
623            ..Default::default()
624        };
625
626        let summary_message = complete(&self.model, &context, Some(options))
627            .await
628            .map_err(|e| CompactionError::LlmError(e.to_string()))?;
629
630        Ok(summary_message.text_content())
631    }
632
633    /// Fallback compaction when LLM fails - simple truncation with key preservation
634    async fn compact_fallback(
635        &self,
636        old_messages: &[Message],
637        recent_messages: &[Message],
638    ) -> std::result::Result<CompactedContext, CompactionError> {
639        // Simple fallback: keep first and last message, summarize in between
640        let mut summary_parts = Vec::new();
641
642        if old_messages.len() > 2 {
643            // Keep first message's topic
644            if let Some(first) = old_messages.first() {
645                let content = first.text_content().unwrap_or_default();
646                let preview = safe_truncate(&content, 200);
647                summary_parts.push(format!("Started discussing: {}", preview));
648            }
649
650            // Keep last message (likely the most relevant recent context)
651            if let Some(last) = old_messages.last() {
652                let content = last.text_content().unwrap_or_default();
653                let preview = safe_truncate(&content, 200);
654                summary_parts.push(format!("Ended with: {}", preview));
655            }
656
657            summary_parts.push(format!(
658                "({} messages omitted)",
659                old_messages.len().saturating_sub(2)
660            ));
661        } else if !old_messages.is_empty() {
662            // Just preserve first message content
663            if let Some(msg) = old_messages.first() {
664                let content = msg.text_content().unwrap_or_default();
665                summary_parts.push(format!("Conversation started: {}", content));
666            }
667        }
668
669        let summary = summary_parts.join(" ");
670
671        let mut summary_msg =
672            AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
673        summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
674            "[Previous conversation summary: {}]",
675            summary
676        )))];
677
678        let mut kept = vec![Message::Assistant(summary_msg)];
679        kept.extend(recent_messages.iter().cloned());
680
681        let original_tokens = self.estimate_tokens(old_messages);
682        let compacted_tokens = self.estimate_tokens(&kept);
683        let kept_len = kept.len();
684
685        Ok(CompactedContext::new(
686            summary,
687            kept,
688            old_messages.len(),
689            CompactionMetadata::new(
690                original_tokens,
691                compacted_tokens,
692                old_messages.len(),
693                kept_len,
694                self.config.target_ratio,
695            ),
696        ))
697    }
698}
699
700impl Compactor for LlmCompactor {
701    fn compact<'a>(
702        &'a self,
703        messages: &'a [Message],
704        instruction: Option<&'a str>,
705    ) -> Pin<
706        Box<
707            dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
708        >,
709    > {
710        Box::pin(async move {
711            // Check minimum requirements
712            if messages.is_empty() {
713                return Err(CompactionError::NoMessagesToCompact);
714            }
715
716            if messages.len() <= self.config.keep_recent {
717                // Not enough messages to compact, return as-is with zero compaction
718                let original_tokens = self.estimate_tokens(messages);
719                return Ok(CompactedContext::new(
720                    String::new(),
721                    messages.to_vec(),
722                    0,
723                    CompactionMetadata::new(
724                        original_tokens,
725                        original_tokens,
726                        0,
727                        messages.len(),
728                        self.config.target_ratio,
729                    ),
730                ));
731            }
732
733            // Split into old messages (to compact) and recent messages (to keep).
734            // The naive `messages.len() - keep_recent` split point can bisect
735            // a tool_call/tool_result pair, leaving orphans on either side.
736            // align_split_boundary walks backward from the naive point to the
737            // nearest "stable" boundary (a user message or a tool_call-free
738            // assistant message) so every tool call is wholly old or wholly
739            // recent.
740            let keep_count = self.config.keep_recent.min(messages.len());
741            let raw_split = messages.len() - keep_count;
742            let split = align_split_boundary(messages, raw_split);
743            let old_messages: Vec<Message> = messages[..split].to_vec();
744            let recent_messages: Vec<Message> = messages[split..].to_vec();
745
746            if old_messages.is_empty() {
747                return Err(CompactionError::NoMessagesToCompact);
748            }
749
750            // Handle LLM failure gracefully
751            self.compact_with_fallback(&old_messages, &recent_messages, instruction)
752                .await
753        })
754    }
755}
756
757/// Find a stable split point in `messages` such that no tool_call /
758/// tool_result pair is bisected.
759///
760/// Starting from `raw_split`, walk backward to the nearest index `i` where
761/// `messages[i-1]` is a "boundary" message — either a [`Message::User`] or
762/// an assistant message with no [`ContentBlock::ToolCall`] blocks.
763/// This guarantees the slice `messages[..i]` ends at a stable boundary
764/// and `messages[i..]` starts cleanly.
765///
766/// Returns `raw_split` if it is already at a stable boundary. Returns 0
767/// if no stable boundary is found before it.
768///
769/// Stable boundary rules:
770/// - `[User]` always safe.
771/// - `[Assistant]` without `tool_calls` always safe.
772/// - `[Assistant]` with `tool_calls` → NOT safe (must be kept whole).
773/// - `[ToolResult]` → NOT safe (must stay with its issuing assistant).
774pub(crate) fn align_split_boundary(messages: &[crate::Message], raw_split: usize) -> usize {
775    use crate::{ContentBlock, Message};
776
777    if raw_split == 0 || raw_split >= messages.len() {
778        return raw_split;
779    }
780
781    let is_boundary = |msg: &Message| match msg {
782        Message::User(_) => true,
783        Message::Assistant(a) => !a
784            .content
785            .iter()
786            .any(|b| matches!(b, ContentBlock::ToolCall(_))),
787        Message::ToolResult(_) => false,
788    };
789
790    // Walk backward from raw_split until we find a boundary or hit 0.
791    // messages[raw_split - 1] is the last message in the "old" slice.
792    let mut i = raw_split;
793    while i > 0 && !is_boundary(&messages[i - 1]) {
794        i -= 1;
795    }
796    i
797}
798
799/// Additional methods for LlmCompactor (not part of Compactor trait)
800impl LlmCompactor {
801    /// Summarize a conversation branch for comparison purposes.
802    ///
803    /// This is used when branching occurs and you want to understand
804    /// what changed compared to another branch (e.g., main).
805    pub async fn summarize_branch(
806        &self,
807        messages: &[Message],
808        branch_name: &str,
809    ) -> std::result::Result<String, CompactionError> {
810        if messages.is_empty() {
811            return Ok(format!("Branch '{}' is empty", branch_name));
812        }
813
814        let mut prompt = String::new();
815        prompt.push_str(&format!(
816            "Summarize the conversation branch '{}' concisely. ",
817            branch_name
818        ));
819        prompt.push_str("Focus on: what was discussed, decisions made, and current state.\n\n");
820
821        prompt.push_str("## Branch messages:\n");
822        for (i, msg) in messages.iter().enumerate() {
823            let role = match msg {
824                Message::User(_) => "User",
825                Message::Assistant(_) => "Assistant",
826                Message::ToolResult(_) => "Tool",
827            };
828            let content = msg.text_content().unwrap_or_default();
829            let content_preview = safe_truncate(&content, 300);
830            prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
831        }
832
833        prompt.push_str("\n## Summary (be concise):\n");
834
835        // Use LLM to generate summary
836        let mut context = Context::new();
837        context.set_system_prompt(
838            "You are a helpful assistant that summarizes conversation branches. ",
839        );
840        context.add_message(Message::User(UserMessage::new(prompt)));
841
842        let options = StreamOptions {
843            temperature: Some(0.3),
844            max_tokens: Some(512),
845            ..Default::default()
846        };
847
848        let summary_message = complete(&self.model, &context, Some(options))
849            .await
850            .map_err(|e| CompactionError::LlmError(e.to_string()))?;
851
852        Ok(summary_message.text_content())
853    }
854}
855
856/// Context manager that handles compaction automatically
857pub struct CompactionManager {
858    strategy: CompactionStrategy,
859    compactor: Option<Arc<dyn Compactor>>,
860    context_window: usize,
861    config: CompactionConfig,
862}
863
864impl CompactionManager {
865    /// Create a new compaction manager
866    pub fn new(strategy: CompactionStrategy, context_window: usize) -> Self {
867        Self {
868            strategy,
869            compactor: None,
870            context_window,
871            config: CompactionConfig::new(),
872        }
873    }
874
875    /// Create a new compaction manager with custom config
876    pub fn with_config(
877        strategy: CompactionStrategy,
878        context_window: usize,
879        config: CompactionConfig,
880    ) -> Self {
881        Self {
882            strategy,
883            compactor: None,
884            context_window,
885            config,
886        }
887    }
888
889    /// Set the compactor to use
890    pub fn with_compactor<C: Compactor + 'static>(mut self, compactor: Arc<C>) -> Self {
891        self.compactor = Some(compactor);
892        self
893    }
894
895    /// Set the compactor from a trait object
896    pub fn set_compactor(&mut self, compactor: Arc<dyn Compactor>) {
897        self.compactor = Some(compactor);
898    }
899
900    /// Check if compaction should be triggered
901    pub fn should_compact(&self, context_tokens: usize, iteration: usize) -> bool {
902        self.strategy
903            .should_compact(context_tokens, self.context_window, iteration)
904    }
905
906    /// Get the current strategy
907    pub fn strategy(&self) -> &CompactionStrategy {
908        &self.strategy
909    }
910
911    /// Get the compaction configuration
912    pub fn config(&self) -> &CompactionConfig {
913        &self.config
914    }
915
916    /// Set compaction configuration
917    pub fn set_config(&mut self, config: CompactionConfig) {
918        self.config = config;
919    }
920
921    /// Compact the given messages if appropriate
922    pub async fn compact_if_needed(
923        &self,
924        messages: &[Message],
925        instruction: Option<&str>,
926        context_tokens: usize,
927        iteration: usize,
928    ) -> std::result::Result<Option<CompactedContext>, CompactionError> {
929        if !self.should_compact(context_tokens, iteration) {
930            return Ok(None);
931        }
932
933        let compactor = match &self.compactor {
934            Some(c) => c,
935            None => return Err(CompactionError::CompactionDisabled),
936        };
937
938        let result = compactor.compact(messages, instruction).await?;
939        Ok(Some(result))
940    }
941
942    /// Force compaction regardless of strategy
943    pub async fn compact_now(
944        &self,
945        messages: &[Message],
946        instruction: Option<&str>,
947    ) -> std::result::Result<CompactedContext, CompactionError> {
948        let compactor = match &self.compactor {
949            Some(c) => c,
950            None => return Err(CompactionError::CompactionDisabled),
951        };
952
953        compactor.compact(messages, instruction).await
954    }
955
956    /// Get estimated token count for messages
957    pub fn estimate_tokens(&self, messages: &[Message]) -> usize {
958        messages
959            .iter()
960            .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
961            .sum()
962    }
963}
964
965impl Default for CompactionManager {
966    fn default() -> Self {
967        Self::new(CompactionStrategy::default(), 128_000)
968    }
969}
970
971// ============================================================================
972// Tests
973// ============================================================================
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    // Helper to create test user messages
980    fn make_user_message(content: &str) -> Message {
981        Message::user(content)
982    }
983
984    // Helper to create test assistant messages
985    fn make_assistant_message(content: &str) -> Message {
986        Message::Assistant({
987            let mut msg = AssistantMessage::new(Api::AnthropicMessages, "test", "test-model");
988            msg.content = vec![ContentBlock::Text(TextContent::new(content))];
989            msg
990        })
991    }
992
993    // Helper to create a test model
994    fn make_test_model() -> Model {
995        Model::new(
996            "test-model",
997            "Test Model",
998            Api::AnthropicMessages,
999            "test",
1000            "https://test.example.com",
1001        )
1002    }
1003
1004    #[test]
1005    fn test_compaction_config_defaults() {
1006        let config = CompactionConfig::new();
1007        assert_eq!(config.keep_recent, 4);
1008        assert_eq!(config.max_batch, 20);
1009        assert!((config.target_ratio - 0.5).abs() < 0.001);
1010        assert_eq!(config.summary_max_tokens, 1024);
1011        assert!((config.temperature - 0.3).abs() < 0.001);
1012    }
1013
1014    #[test]
1015    fn test_compaction_config_builder_pattern() {
1016        let config = CompactionConfig::new()
1017            .with_keep_recent(10)
1018            .with_max_batch(30)
1019            .with_target_ratio(0.3)
1020            .with_temperature(0.5);
1021
1022        assert_eq!(config.keep_recent, 10);
1023        assert_eq!(config.max_batch, 30);
1024        assert!((config.target_ratio - 0.3).abs() < 0.001);
1025        assert!((config.temperature - 0.5).abs() < 0.001);
1026    }
1027
1028    #[test]
1029    fn test_compaction_config_ratio_clamping() {
1030        // Test upper bound clamping
1031        let config = CompactionConfig::new().with_target_ratio(1.5);
1032        assert!((config.target_ratio - 0.9).abs() < 0.001);
1033
1034        // Test lower bound clamping
1035        let config = CompactionConfig::new().with_target_ratio(-0.5);
1036        assert!((config.target_ratio - 0.1).abs() < 0.001);
1037    }
1038
1039    #[test]
1040    fn test_compaction_metadata_success() {
1041        let metadata = CompactionMetadata::new(
1042            1000, // original_tokens
1043            500,  // compacted_tokens
1044            10,   // messages_compacted
1045            5,    // messages_kept
1046            0.5,  // target_ratio
1047        );
1048
1049        assert!(metadata.success);
1050        assert_eq!(metadata.original_tokens, 1000);
1051        assert_eq!(metadata.compacted_tokens, 500);
1052        assert_eq!(metadata.messages_compacted, 10);
1053        assert_eq!(metadata.messages_kept, 5);
1054        assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1055        assert!((metadata.compression_factor() - 0.5).abs() < 0.001);
1056        assert_eq!(metadata.tokens_saved(), 500);
1057        assert!(metadata.error.is_none());
1058    }
1059
1060    #[test]
1061    fn test_compaction_metadata_failure() {
1062        let metadata = CompactionError::LlmError("test error".to_string());
1063
1064        // Verify error message
1065        assert!(metadata.to_string().contains("test error"));
1066    }
1067
1068    #[test]
1069    fn test_compaction_metadata_compression_factor() {
1070        // Zero original tokens should result in 1.0 ratio
1071        let metadata = CompactionMetadata::new(0, 0, 0, 0, 0.5);
1072        assert!((metadata.actual_ratio - 1.0).abs() < 0.001);
1073        assert!((metadata.compression_factor() - 0.0).abs() < 0.001);
1074
1075        // Full compression
1076        let metadata = CompactionMetadata::new(1000, 100, 10, 5, 0.5);
1077        assert!((metadata.compression_factor() - 0.9).abs() < 0.001);
1078    }
1079
1080    #[test]
1081    fn test_compaction_metadata_tokens_saved() {
1082        // Normal case
1083        let metadata = CompactionMetadata::new(1000, 400, 10, 5, 0.5);
1084        assert_eq!(metadata.tokens_saved(), 600);
1085
1086        // No savings
1087        let metadata = CompactionMetadata::new(1000, 1000, 0, 0, 0.5);
1088        assert_eq!(metadata.tokens_saved(), 0);
1089
1090        // Compacted is larger than original (should not happen but should be safe)
1091        let metadata = CompactionMetadata::new(500, 600, 5, 3, 0.5);
1092        assert_eq!(metadata.tokens_saved(), 0); // saturating_sub
1093    }
1094
1095    #[test]
1096    fn test_compaction_strategy_disabled() {
1097        let strategy = CompactionStrategy::Disabled;
1098        assert!(!strategy.should_compact(100_000, 128_000, 5));
1099        assert!(!strategy.should_compact(120_000, 128_000, 10));
1100        assert!(!strategy.should_compact(0, 128_000, 1));
1101    }
1102
1103    #[test]
1104    fn test_compaction_strategy_threshold() {
1105        let strategy = CompactionStrategy::Threshold(0.8);
1106
1107        // Below threshold (79%)
1108        assert!(!strategy.should_compact(100_000, 128_000, 1));
1109
1110        // At threshold (exactly 80%)
1111        assert!(strategy.should_compact(102_400, 128_000, 1));
1112
1113        // Above threshold (93%)
1114        assert!(strategy.should_compact(120_000, 128_000, 1));
1115
1116        // Zero context window should return false
1117        assert!(!strategy.should_compact(100_000, 0, 1));
1118    }
1119
1120    #[test]
1121    fn test_compaction_strategy_every_n_turns() {
1122        let strategy = CompactionStrategy::EveryNTurns(5);
1123
1124        // Before threshold iterations
1125        assert!(!strategy.should_compact(0, 128_000, 0));
1126        assert!(!strategy.should_compact(0, 128_000, 3));
1127        assert!(!strategy.should_compact(0, 128_000, 4));
1128
1129        // At threshold iterations
1130        assert!(strategy.should_compact(0, 128_000, 5));
1131        assert!(strategy.should_compact(0, 128_000, 10));
1132        assert!(strategy.should_compact(0, 128_000, 15));
1133
1134        // Not at threshold
1135        assert!(!strategy.should_compact(0, 128_000, 6));
1136        assert!(!strategy.should_compact(0, 128_000, 9));
1137    }
1138
1139    #[test]
1140    fn test_compaction_strategy_absolute_tokens() {
1141        let strategy = CompactionStrategy::AbsoluteTokens(100_000);
1142
1143        // Below threshold
1144        assert!(!strategy.should_compact(50_000, 128_000, 0));
1145        assert!(!strategy.should_compact(99_999, 128_000, 0));
1146
1147        // At threshold
1148        assert!(strategy.should_compact(100_000, 128_000, 0));
1149
1150        // Above threshold
1151        assert!(strategy.should_compact(150_000, 128_000, 0));
1152    }
1153
1154    #[test]
1155    fn test_compacted_context_basic() {
1156        let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1157        let ctx = CompactedContext::new(
1158            "Test summary".to_string(),
1159            vec![make_user_message("test")],
1160            10,
1161            metadata,
1162        );
1163
1164        assert_eq!(ctx.summary(), "Test summary");
1165        assert_eq!(ctx.kept_count(), 1);
1166        assert_eq!(ctx.compacted_count(), 10);
1167        assert!(ctx.is_success());
1168        assert_eq!(ctx.metadata().tokens_saved(), 500);
1169    }
1170
1171    #[test]
1172    fn test_compacted_context_with_empty_summary() {
1173        let metadata = CompactionMetadata::new(100, 100, 0, 2, 0.5);
1174        let ctx = CompactedContext::new(
1175            String::new(), // Empty summary
1176            vec![make_user_message("test1"), make_user_message("test2")],
1177            0,
1178            metadata,
1179        );
1180
1181        assert_eq!(ctx.summary(), "");
1182        assert_eq!(ctx.kept_count(), 2);
1183        assert_eq!(ctx.compacted_count(), 0);
1184    }
1185
1186    #[test]
1187    fn test_llm_compactor_config_builder() {
1188        // Test that LlmCompactor can be created and builder pattern works
1189        use crate::providers::OpenAiProvider;
1190        let provider = OpenAiProvider::new();
1191        let model = make_test_model();
1192        let compactor = LlmCompactor::new(model, Arc::new(provider))
1193            .with_keep_recent(6)
1194            .with_max_batch(25)
1195            .with_target_ratio(0.6);
1196
1197        assert!(compactor.config.keep_recent >= 4);
1198        assert!(compactor.config.max_batch >= 20);
1199    }
1200
1201    #[test]
1202    fn test_compaction_error_display() {
1203        let err = CompactionError::NoMessagesToCompact;
1204        assert_eq!(err.to_string(), "No messages to compact");
1205
1206        let err = CompactionError::TooFewMessages {
1207            total: 3,
1208            keep_recent: 5,
1209        };
1210        assert!(err.to_string().contains("3"));
1211        // The error message says "need at least keep_recent + 1", so with keep_recent=5 it shows 6
1212        assert!(err.to_string().contains("6"));
1213
1214        let err = CompactionError::CompactionDisabled;
1215        assert_eq!(err.to_string(), "Compaction is disabled");
1216
1217        let err = CompactionError::NoContextWindow;
1218        assert_eq!(err.to_string(), "Context window not configured");
1219
1220        let err = CompactionError::LlmError("API timeout".to_string());
1221        assert!(err.to_string().contains("API timeout"));
1222    }
1223
1224    #[test]
1225    fn test_compaction_manager_default() {
1226        let manager = CompactionManager::default();
1227        assert!(matches!(
1228            manager.strategy(),
1229            CompactionStrategy::Threshold(_)
1230        ));
1231        assert_eq!(manager.config().keep_recent, 4);
1232    }
1233
1234    #[test]
1235    fn test_compaction_manager_with_custom_strategy() {
1236        let strategy = CompactionStrategy::AbsoluteTokens(50_000);
1237        let manager = CompactionManager::new(strategy, 200_000);
1238
1239        // Should not compact below threshold
1240        assert!(!manager.should_compact(30_000, 0));
1241
1242        // Should compact above threshold
1243        assert!(manager.should_compact(60_000, 0));
1244    }
1245
1246    #[test]
1247    fn test_compaction_manager_with_config() {
1248        let config = CompactionConfig::new()
1249            .with_keep_recent(8)
1250            .with_target_ratio(0.4);
1251
1252        let manager =
1253            CompactionManager::with_config(CompactionStrategy::default(), 128_000, config);
1254
1255        assert_eq!(manager.config().keep_recent, 8);
1256        assert!((manager.config().target_ratio - 0.4).abs() < 0.001);
1257    }
1258
1259    #[test]
1260    fn test_compaction_manager_should_compact_integration() {
1261        let manager = CompactionManager::new(CompactionStrategy::Threshold(0.75), 100_000);
1262
1263        // Below threshold
1264        assert!(!manager.should_compact(70_000, 0));
1265
1266        // At threshold (75%)
1267        assert!(manager.should_compact(75_000, 0));
1268
1269        // Above threshold
1270        assert!(manager.should_compact(80_000, 0));
1271        assert!(manager.should_compact(100_000, 0));
1272    }
1273
1274    #[test]
1275    fn test_compaction_manager_no_compactor_set() {
1276        let manager = CompactionManager::new(CompactionStrategy::EveryNTurns(5), 128_000);
1277
1278        // should_compact with EveryNTurns(5) at iteration 5 should return true
1279        // (compact_if_needed would return Err when no compactor is set, but should_compact works)
1280        assert!(manager.should_compact(0, 5)); // iteration 5 triggers compaction
1281    }
1282
1283    #[test]
1284    fn test_token_estimation_helper() {
1285        use crate::providers::OpenAiProvider;
1286        let provider = OpenAiProvider::new();
1287        let model = make_test_model();
1288        let compactor = LlmCompactor::new(model, Arc::new(provider));
1289
1290        let messages = vec![
1291            make_user_message("Hello world, this is a test message."),
1292            make_assistant_message("This is a response with some content."),
1293        ];
1294
1295        let tokens = compactor.estimate_tokens(&messages);
1296        assert!(tokens > 0, "Should estimate tokens for messages");
1297    }
1298
1299    #[test]
1300    fn test_compaction_config_custom_instruction() {
1301        let config = CompactionConfig::new()
1302            .with_custom_instruction("Focus on code changes and technical decisions");
1303
1304        assert!(config.custom_instruction.is_some());
1305        assert!(config.custom_instruction.unwrap().contains("code changes"));
1306    }
1307
1308    #[test]
1309    fn test_compaction_metadata_timestamp_is_set() {
1310        let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1311        assert!(metadata.timestamp <= Utc::now());
1312    }
1313
1314    #[test]
1315    fn test_compaction_ratio_achievement() {
1316        // Simulate compaction that achieves target ratio
1317        let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1318        assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1319
1320        // Simulate compaction that exceeds target (more compression)
1321        let metadata = CompactionMetadata::new(1000, 300, 10, 5, 0.5);
1322        assert!((metadata.actual_ratio - 0.3).abs() < 0.001);
1323        assert!(metadata.compression_factor() > 0.5);
1324
1325        // Simulate compaction that doesn't meet target (less compression)
1326        let metadata = CompactionMetadata::new(1000, 700, 10, 5, 0.5);
1327        assert!((metadata.actual_ratio - 0.7).abs() < 0.001);
1328        assert!(metadata.compression_factor() < 0.5);
1329    }
1330
1331    #[test]
1332    fn test_compaction_manager_config_updates() {
1333        let mut manager = CompactionManager::default();
1334
1335        let new_config = CompactionConfig::new()
1336            .with_keep_recent(12)
1337            .with_target_ratio(0.3);
1338
1339        manager.set_config(new_config);
1340
1341        assert_eq!(manager.config().keep_recent, 12);
1342        assert!((manager.config().target_ratio - 0.3).abs() < 0.001);
1343    }
1344
1345    #[test]
1346    fn test_llm_compactor_has_summarize_branch() {
1347        // Verify that LlmCompactor has the summarize_branch method
1348        use crate::providers::OpenAiProvider;
1349        let provider = OpenAiProvider::new();
1350        let model = make_test_model();
1351        let compactor = LlmCompactor::new(model, Arc::new(provider));
1352
1353        // Just verify the method exists (runtime test would require async)
1354        let messages = vec![
1355            make_user_message("Test message 1"),
1356            make_assistant_message("Test response 1"),
1357            make_user_message("Test message 2"),
1358        ];
1359
1360        // The method exists and can be called (we can't test async in sync test)
1361        // We verify it compiles correctly
1362        let branch_name = "test-branch";
1363        // This is a compile-time check that the method exists
1364        let _future = compactor.summarize_branch(&messages, branch_name);
1365    }
1366
1367    #[test]
1368    fn test_summarize_branch_returns_error_on_llm_failure() {
1369        // Test that summarize_branch handles empty messages gracefully
1370        use crate::providers::OpenAiProvider;
1371        let provider = OpenAiProvider::new();
1372        let model = make_test_model();
1373        let compactor = LlmCompactor::new(model, Arc::new(provider));
1374
1375        // Empty messages should return immediately
1376        let messages: Vec<Message> = vec![];
1377
1378        // This should not panic with empty messages
1379        // (We can't test the async result in a sync test, but compile-time check passes)
1380        let _future = compactor.summarize_branch(&messages, "empty-branch");
1381    }
1382
1383    // ---- align_split_boundary tests ----
1384
1385    use crate::{ToolCall, ToolResultMessage};
1386    fn make_user_msg(text: &str) -> Message {
1387        Message::User(UserMessage::new(text))
1388    }
1389
1390    fn make_asst_text(text: &str) -> Message {
1391        let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1392        m.content
1393            .push(ContentBlock::Text(TextContent::new(text.to_string())));
1394        Message::Assistant(m)
1395    }
1396
1397    fn make_asst_with_tool_call(id: &str) -> Message {
1398        let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1399        m.content.push(ContentBlock::ToolCall(ToolCall::new(
1400            id,
1401            "bash",
1402            serde_json::json!({}),
1403        )));
1404        Message::Assistant(m)
1405    }
1406
1407    fn make_tool_result(id: &str) -> Message {
1408        Message::ToolResult(ToolResultMessage::new(
1409            id,
1410            "bash",
1411            vec![ContentBlock::Text(TextContent::new("ok"))],
1412        ))
1413    }
1414
1415    #[test]
1416    fn test_align_boundary_already_at_user() {
1417        // raw_split lands on a User → no adjustment needed.
1418        let msgs = vec![
1419            make_user_msg("a"),
1420            make_user_msg("b"),
1421            make_user_msg("c"),
1422            make_user_msg("d"),
1423        ];
1424        // raw_split = 2 → messages[1] is User → already a boundary.
1425        assert_eq!(align_split_boundary(&msgs, 2), 2);
1426    }
1427
1428    #[test]
1429    fn test_align_boundary_walks_back_from_tool_result() {
1430        // raw_split falls inside a tool_call/tool_result block.
1431        // Should walk back to the assistant that issued the tool_call.
1432        let msgs = vec![
1433            make_user_msg("u1"),
1434            make_asst_with_tool_call("call_1"),
1435            make_tool_result("call_1"),
1436            make_user_msg("u2"),
1437            make_asst_text("done"),
1438        ];
1439        // raw_split = 3 falls between tool_result and user.
1440        // Walking back: messages[2] = tool_result (not boundary),
1441        // messages[1] = assistant with tool_call (not boundary),
1442        // messages[0] = user (boundary). Result: 1.
1443        assert_eq!(align_split_boundary(&msgs, 3), 1);
1444    }
1445
1446    #[test]
1447    fn test_align_boundary_at_zero() {
1448        // Edge case: raw_split = 0.
1449        let msgs = vec![make_user_msg("u1")];
1450        assert_eq!(align_split_boundary(&msgs, 0), 0);
1451    }
1452
1453    #[test]
1454    fn test_align_boundary_past_end() {
1455        // Edge case: raw_split >= len → return as-is.
1456        let msgs = vec![make_user_msg("u1")];
1457        assert_eq!(align_split_boundary(&msgs, 5), 5);
1458    }
1459
1460    #[test]
1461    fn test_align_boundary_assistant_text_is_safe() {
1462        // An assistant with ONLY text (no tool_calls) IS a safe boundary.
1463        let msgs = vec![
1464            make_user_msg("u1"),
1465            make_asst_with_tool_call("call_1"),
1466            make_tool_result("call_1"),
1467            make_asst_text("summary"),
1468            make_user_msg("u2"),
1469        ];
1470        // raw_split = 4 → messages[3] = assistant text → boundary.
1471        assert_eq!(align_split_boundary(&msgs, 4), 4);
1472    }
1473}