Skip to main content

oxicode_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 `oxicode-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    /// Get the context window this manager computes thresholds against.
922    pub fn context_window(&self) -> usize {
923        self.context_window
924    }
925
926    /// Update the context window (e.g. after a model switch changed the
927    /// real capacity). Affects subsequent [`should_compact`] checks.
928    pub fn set_context_window(&mut self, context_window: usize) {
929        self.context_window = context_window;
930    }
931
932    /// Compact the given messages if appropriate
933    pub async fn compact_if_needed(
934        &self,
935        messages: &[Message],
936        instruction: Option<&str>,
937        context_tokens: usize,
938        iteration: usize,
939    ) -> std::result::Result<Option<CompactedContext>, CompactionError> {
940        if !self.should_compact(context_tokens, iteration) {
941            return Ok(None);
942        }
943
944        let compactor = match &self.compactor {
945            Some(c) => c,
946            None => return Err(CompactionError::CompactionDisabled),
947        };
948
949        let result = compactor.compact(messages, instruction).await?;
950        Ok(Some(result))
951    }
952
953    /// Force compaction regardless of strategy
954    pub async fn compact_now(
955        &self,
956        messages: &[Message],
957        instruction: Option<&str>,
958    ) -> std::result::Result<CompactedContext, CompactionError> {
959        let compactor = match &self.compactor {
960            Some(c) => c,
961            None => return Err(CompactionError::CompactionDisabled),
962        };
963
964        compactor.compact(messages, instruction).await
965    }
966
967    /// Get estimated token count for messages
968    pub fn estimate_tokens(&self, messages: &[Message]) -> usize {
969        messages
970            .iter()
971            .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
972            .sum()
973    }
974}
975
976impl Default for CompactionManager {
977    fn default() -> Self {
978        Self::new(CompactionStrategy::default(), 128_000)
979    }
980}
981
982// ============================================================================
983// Tests
984// ============================================================================
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    // Helper to create test user messages
991    fn make_user_message(content: &str) -> Message {
992        Message::user(content)
993    }
994
995    // Helper to create test assistant messages
996    fn make_assistant_message(content: &str) -> Message {
997        Message::Assistant({
998            let mut msg = AssistantMessage::new(Api::AnthropicMessages, "test", "test-model");
999            msg.content = vec![ContentBlock::Text(TextContent::new(content))];
1000            msg
1001        })
1002    }
1003
1004    // Helper to create a test model
1005    fn make_test_model() -> Model {
1006        Model::new(
1007            "test-model",
1008            "Test Model",
1009            Api::AnthropicMessages,
1010            "test",
1011            "https://test.example.com",
1012        )
1013    }
1014
1015    #[test]
1016    fn test_compaction_config_defaults() {
1017        let config = CompactionConfig::new();
1018        assert_eq!(config.keep_recent, 4);
1019        assert_eq!(config.max_batch, 20);
1020        assert!((config.target_ratio - 0.5).abs() < 0.001);
1021        assert_eq!(config.summary_max_tokens, 1024);
1022        assert!((config.temperature - 0.3).abs() < 0.001);
1023    }
1024
1025    #[test]
1026    fn test_compaction_config_builder_pattern() {
1027        let config = CompactionConfig::new()
1028            .with_keep_recent(10)
1029            .with_max_batch(30)
1030            .with_target_ratio(0.3)
1031            .with_temperature(0.5);
1032
1033        assert_eq!(config.keep_recent, 10);
1034        assert_eq!(config.max_batch, 30);
1035        assert!((config.target_ratio - 0.3).abs() < 0.001);
1036        assert!((config.temperature - 0.5).abs() < 0.001);
1037    }
1038
1039    #[test]
1040    fn test_compaction_config_ratio_clamping() {
1041        // Test upper bound clamping
1042        let config = CompactionConfig::new().with_target_ratio(1.5);
1043        assert!((config.target_ratio - 0.9).abs() < 0.001);
1044
1045        // Test lower bound clamping
1046        let config = CompactionConfig::new().with_target_ratio(-0.5);
1047        assert!((config.target_ratio - 0.1).abs() < 0.001);
1048    }
1049
1050    #[test]
1051    fn test_compaction_metadata_success() {
1052        let metadata = CompactionMetadata::new(
1053            1000, // original_tokens
1054            500,  // compacted_tokens
1055            10,   // messages_compacted
1056            5,    // messages_kept
1057            0.5,  // target_ratio
1058        );
1059
1060        assert!(metadata.success);
1061        assert_eq!(metadata.original_tokens, 1000);
1062        assert_eq!(metadata.compacted_tokens, 500);
1063        assert_eq!(metadata.messages_compacted, 10);
1064        assert_eq!(metadata.messages_kept, 5);
1065        assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1066        assert!((metadata.compression_factor() - 0.5).abs() < 0.001);
1067        assert_eq!(metadata.tokens_saved(), 500);
1068        assert!(metadata.error.is_none());
1069    }
1070
1071    #[test]
1072    fn test_compaction_metadata_failure() {
1073        let metadata = CompactionError::LlmError("test error".to_string());
1074
1075        // Verify error message
1076        assert!(metadata.to_string().contains("test error"));
1077    }
1078
1079    #[test]
1080    fn test_compaction_metadata_compression_factor() {
1081        // Zero original tokens should result in 1.0 ratio
1082        let metadata = CompactionMetadata::new(0, 0, 0, 0, 0.5);
1083        assert!((metadata.actual_ratio - 1.0).abs() < 0.001);
1084        assert!((metadata.compression_factor() - 0.0).abs() < 0.001);
1085
1086        // Full compression
1087        let metadata = CompactionMetadata::new(1000, 100, 10, 5, 0.5);
1088        assert!((metadata.compression_factor() - 0.9).abs() < 0.001);
1089    }
1090
1091    #[test]
1092    fn test_compaction_metadata_tokens_saved() {
1093        // Normal case
1094        let metadata = CompactionMetadata::new(1000, 400, 10, 5, 0.5);
1095        assert_eq!(metadata.tokens_saved(), 600);
1096
1097        // No savings
1098        let metadata = CompactionMetadata::new(1000, 1000, 0, 0, 0.5);
1099        assert_eq!(metadata.tokens_saved(), 0);
1100
1101        // Compacted is larger than original (should not happen but should be safe)
1102        let metadata = CompactionMetadata::new(500, 600, 5, 3, 0.5);
1103        assert_eq!(metadata.tokens_saved(), 0); // saturating_sub
1104    }
1105
1106    #[test]
1107    fn test_compaction_strategy_disabled() {
1108        let strategy = CompactionStrategy::Disabled;
1109        assert!(!strategy.should_compact(100_000, 128_000, 5));
1110        assert!(!strategy.should_compact(120_000, 128_000, 10));
1111        assert!(!strategy.should_compact(0, 128_000, 1));
1112    }
1113
1114    #[test]
1115    fn test_compaction_strategy_threshold() {
1116        let strategy = CompactionStrategy::Threshold(0.8);
1117
1118        // Below threshold (79%)
1119        assert!(!strategy.should_compact(100_000, 128_000, 1));
1120
1121        // At threshold (exactly 80%)
1122        assert!(strategy.should_compact(102_400, 128_000, 1));
1123
1124        // Above threshold (93%)
1125        assert!(strategy.should_compact(120_000, 128_000, 1));
1126
1127        // Zero context window should return false
1128        assert!(!strategy.should_compact(100_000, 0, 1));
1129    }
1130
1131    #[test]
1132    fn test_compaction_strategy_every_n_turns() {
1133        let strategy = CompactionStrategy::EveryNTurns(5);
1134
1135        // Before threshold iterations
1136        assert!(!strategy.should_compact(0, 128_000, 0));
1137        assert!(!strategy.should_compact(0, 128_000, 3));
1138        assert!(!strategy.should_compact(0, 128_000, 4));
1139
1140        // At threshold iterations
1141        assert!(strategy.should_compact(0, 128_000, 5));
1142        assert!(strategy.should_compact(0, 128_000, 10));
1143        assert!(strategy.should_compact(0, 128_000, 15));
1144
1145        // Not at threshold
1146        assert!(!strategy.should_compact(0, 128_000, 6));
1147        assert!(!strategy.should_compact(0, 128_000, 9));
1148    }
1149
1150    #[test]
1151    fn test_compaction_strategy_absolute_tokens() {
1152        let strategy = CompactionStrategy::AbsoluteTokens(100_000);
1153
1154        // Below threshold
1155        assert!(!strategy.should_compact(50_000, 128_000, 0));
1156        assert!(!strategy.should_compact(99_999, 128_000, 0));
1157
1158        // At threshold
1159        assert!(strategy.should_compact(100_000, 128_000, 0));
1160
1161        // Above threshold
1162        assert!(strategy.should_compact(150_000, 128_000, 0));
1163    }
1164
1165    #[test]
1166    fn test_compacted_context_basic() {
1167        let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1168        let ctx = CompactedContext::new(
1169            "Test summary".to_string(),
1170            vec![make_user_message("test")],
1171            10,
1172            metadata,
1173        );
1174
1175        assert_eq!(ctx.summary(), "Test summary");
1176        assert_eq!(ctx.kept_count(), 1);
1177        assert_eq!(ctx.compacted_count(), 10);
1178        assert!(ctx.is_success());
1179        assert_eq!(ctx.metadata().tokens_saved(), 500);
1180    }
1181
1182    #[test]
1183    fn test_compacted_context_with_empty_summary() {
1184        let metadata = CompactionMetadata::new(100, 100, 0, 2, 0.5);
1185        let ctx = CompactedContext::new(
1186            String::new(), // Empty summary
1187            vec![make_user_message("test1"), make_user_message("test2")],
1188            0,
1189            metadata,
1190        );
1191
1192        assert_eq!(ctx.summary(), "");
1193        assert_eq!(ctx.kept_count(), 2);
1194        assert_eq!(ctx.compacted_count(), 0);
1195    }
1196
1197    #[test]
1198    fn test_llm_compactor_config_builder() {
1199        // Test that LlmCompactor can be created and builder pattern works
1200        use crate::providers::OpenAiProvider;
1201        let provider = OpenAiProvider::new();
1202        let model = make_test_model();
1203        let compactor = LlmCompactor::new(model, Arc::new(provider))
1204            .with_keep_recent(6)
1205            .with_max_batch(25)
1206            .with_target_ratio(0.6);
1207
1208        assert!(compactor.config.keep_recent >= 4);
1209        assert!(compactor.config.max_batch >= 20);
1210    }
1211
1212    #[test]
1213    fn test_compaction_error_display() {
1214        let err = CompactionError::NoMessagesToCompact;
1215        assert_eq!(err.to_string(), "No messages to compact");
1216
1217        let err = CompactionError::TooFewMessages {
1218            total: 3,
1219            keep_recent: 5,
1220        };
1221        assert!(err.to_string().contains("3"));
1222        // The error message says "need at least keep_recent + 1", so with keep_recent=5 it shows 6
1223        assert!(err.to_string().contains("6"));
1224
1225        let err = CompactionError::CompactionDisabled;
1226        assert_eq!(err.to_string(), "Compaction is disabled");
1227
1228        let err = CompactionError::NoContextWindow;
1229        assert_eq!(err.to_string(), "Context window not configured");
1230
1231        let err = CompactionError::LlmError("API timeout".to_string());
1232        assert!(err.to_string().contains("API timeout"));
1233    }
1234
1235    #[test]
1236    fn test_compaction_manager_default() {
1237        let manager = CompactionManager::default();
1238        assert!(matches!(
1239            manager.strategy(),
1240            CompactionStrategy::Threshold(_)
1241        ));
1242        assert_eq!(manager.config().keep_recent, 4);
1243    }
1244
1245    #[test]
1246    fn test_compaction_manager_with_custom_strategy() {
1247        let strategy = CompactionStrategy::AbsoluteTokens(50_000);
1248        let manager = CompactionManager::new(strategy, 200_000);
1249
1250        // Should not compact below threshold
1251        assert!(!manager.should_compact(30_000, 0));
1252
1253        // Should compact above threshold
1254        assert!(manager.should_compact(60_000, 0));
1255    }
1256
1257    #[test]
1258    fn test_compaction_manager_with_config() {
1259        let config = CompactionConfig::new()
1260            .with_keep_recent(8)
1261            .with_target_ratio(0.4);
1262
1263        let manager =
1264            CompactionManager::with_config(CompactionStrategy::default(), 128_000, config);
1265
1266        assert_eq!(manager.config().keep_recent, 8);
1267        assert!((manager.config().target_ratio - 0.4).abs() < 0.001);
1268    }
1269
1270    #[test]
1271    fn test_compaction_manager_should_compact_integration() {
1272        let manager = CompactionManager::new(CompactionStrategy::Threshold(0.75), 100_000);
1273
1274        // Below threshold
1275        assert!(!manager.should_compact(70_000, 0));
1276
1277        // At threshold (75%)
1278        assert!(manager.should_compact(75_000, 0));
1279
1280        // Above threshold
1281        assert!(manager.should_compact(80_000, 0));
1282        assert!(manager.should_compact(100_000, 0));
1283    }
1284
1285    #[test]
1286    fn test_compaction_manager_no_compactor_set() {
1287        let manager = CompactionManager::new(CompactionStrategy::EveryNTurns(5), 128_000);
1288
1289        // should_compact with EveryNTurns(5) at iteration 5 should return true
1290        // (compact_if_needed would return Err when no compactor is set, but should_compact works)
1291        assert!(manager.should_compact(0, 5)); // iteration 5 triggers compaction
1292    }
1293
1294    #[test]
1295    fn test_token_estimation_helper() {
1296        use crate::providers::OpenAiProvider;
1297        let provider = OpenAiProvider::new();
1298        let model = make_test_model();
1299        let compactor = LlmCompactor::new(model, Arc::new(provider));
1300
1301        let messages = vec![
1302            make_user_message("Hello world, this is a test message."),
1303            make_assistant_message("This is a response with some content."),
1304        ];
1305
1306        let tokens = compactor.estimate_tokens(&messages);
1307        assert!(tokens > 0, "Should estimate tokens for messages");
1308    }
1309
1310    #[test]
1311    fn test_compaction_config_custom_instruction() {
1312        let config = CompactionConfig::new()
1313            .with_custom_instruction("Focus on code changes and technical decisions");
1314
1315        assert!(config.custom_instruction.is_some());
1316        assert!(config.custom_instruction.unwrap().contains("code changes"));
1317    }
1318
1319    #[test]
1320    fn test_compaction_metadata_timestamp_is_set() {
1321        let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1322        assert!(metadata.timestamp <= Utc::now());
1323    }
1324
1325    #[test]
1326    fn test_compaction_ratio_achievement() {
1327        // Simulate compaction that achieves target ratio
1328        let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1329        assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1330
1331        // Simulate compaction that exceeds target (more compression)
1332        let metadata = CompactionMetadata::new(1000, 300, 10, 5, 0.5);
1333        assert!((metadata.actual_ratio - 0.3).abs() < 0.001);
1334        assert!(metadata.compression_factor() > 0.5);
1335
1336        // Simulate compaction that doesn't meet target (less compression)
1337        let metadata = CompactionMetadata::new(1000, 700, 10, 5, 0.5);
1338        assert!((metadata.actual_ratio - 0.7).abs() < 0.001);
1339        assert!(metadata.compression_factor() < 0.5);
1340    }
1341
1342    #[test]
1343    fn test_compaction_manager_config_updates() {
1344        let mut manager = CompactionManager::default();
1345
1346        let new_config = CompactionConfig::new()
1347            .with_keep_recent(12)
1348            .with_target_ratio(0.3);
1349
1350        manager.set_config(new_config);
1351
1352        assert_eq!(manager.config().keep_recent, 12);
1353        assert!((manager.config().target_ratio - 0.3).abs() < 0.001);
1354    }
1355
1356    #[test]
1357    fn test_llm_compactor_has_summarize_branch() {
1358        // Verify that LlmCompactor has the summarize_branch method
1359        use crate::providers::OpenAiProvider;
1360        let provider = OpenAiProvider::new();
1361        let model = make_test_model();
1362        let compactor = LlmCompactor::new(model, Arc::new(provider));
1363
1364        // Just verify the method exists (runtime test would require async)
1365        let messages = vec![
1366            make_user_message("Test message 1"),
1367            make_assistant_message("Test response 1"),
1368            make_user_message("Test message 2"),
1369        ];
1370
1371        // The method exists and can be called (we can't test async in sync test)
1372        // We verify it compiles correctly
1373        let branch_name = "test-branch";
1374        // This is a compile-time check that the method exists
1375        let _future = compactor.summarize_branch(&messages, branch_name);
1376    }
1377
1378    #[test]
1379    fn test_summarize_branch_returns_error_on_llm_failure() {
1380        // Test that summarize_branch handles empty messages gracefully
1381        use crate::providers::OpenAiProvider;
1382        let provider = OpenAiProvider::new();
1383        let model = make_test_model();
1384        let compactor = LlmCompactor::new(model, Arc::new(provider));
1385
1386        // Empty messages should return immediately
1387        let messages: Vec<Message> = vec![];
1388
1389        // This should not panic with empty messages
1390        // (We can't test the async result in a sync test, but compile-time check passes)
1391        let _future = compactor.summarize_branch(&messages, "empty-branch");
1392    }
1393
1394    // ---- align_split_boundary tests ----
1395
1396    use crate::{ToolCall, ToolResultMessage};
1397    fn make_user_msg(text: &str) -> Message {
1398        Message::User(UserMessage::new(text))
1399    }
1400
1401    fn make_asst_text(text: &str) -> Message {
1402        let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1403        m.content
1404            .push(ContentBlock::Text(TextContent::new(text.to_string())));
1405        Message::Assistant(m)
1406    }
1407
1408    fn make_asst_with_tool_call(id: &str) -> Message {
1409        let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1410        m.content.push(ContentBlock::ToolCall(ToolCall::new(
1411            id,
1412            "bash",
1413            serde_json::json!({}),
1414        )));
1415        Message::Assistant(m)
1416    }
1417
1418    fn make_tool_result(id: &str) -> Message {
1419        Message::ToolResult(ToolResultMessage::new(
1420            id,
1421            "bash",
1422            vec![ContentBlock::Text(TextContent::new("ok"))],
1423        ))
1424    }
1425
1426    #[test]
1427    fn test_align_boundary_already_at_user() {
1428        // raw_split lands on a User → no adjustment needed.
1429        let msgs = vec![
1430            make_user_msg("a"),
1431            make_user_msg("b"),
1432            make_user_msg("c"),
1433            make_user_msg("d"),
1434        ];
1435        // raw_split = 2 → messages[1] is User → already a boundary.
1436        assert_eq!(align_split_boundary(&msgs, 2), 2);
1437    }
1438
1439    #[test]
1440    fn test_align_boundary_walks_back_from_tool_result() {
1441        // raw_split falls inside a tool_call/tool_result block.
1442        // Should walk back to the assistant that issued the tool_call.
1443        let msgs = vec![
1444            make_user_msg("u1"),
1445            make_asst_with_tool_call("call_1"),
1446            make_tool_result("call_1"),
1447            make_user_msg("u2"),
1448            make_asst_text("done"),
1449        ];
1450        // raw_split = 3 falls between tool_result and user.
1451        // Walking back: messages[2] = tool_result (not boundary),
1452        // messages[1] = assistant with tool_call (not boundary),
1453        // messages[0] = user (boundary). Result: 1.
1454        assert_eq!(align_split_boundary(&msgs, 3), 1);
1455    }
1456
1457    #[test]
1458    fn test_align_boundary_at_zero() {
1459        // Edge case: raw_split = 0.
1460        let msgs = vec![make_user_msg("u1")];
1461        assert_eq!(align_split_boundary(&msgs, 0), 0);
1462    }
1463
1464    #[test]
1465    fn test_align_boundary_past_end() {
1466        // Edge case: raw_split >= len → return as-is.
1467        let msgs = vec![make_user_msg("u1")];
1468        assert_eq!(align_split_boundary(&msgs, 5), 5);
1469    }
1470
1471    #[test]
1472    fn test_align_boundary_assistant_text_is_safe() {
1473        // An assistant with ONLY text (no tool_calls) IS a safe boundary.
1474        let msgs = vec![
1475            make_user_msg("u1"),
1476            make_asst_with_tool_call("call_1"),
1477            make_tool_result("call_1"),
1478            make_asst_text("summary"),
1479            make_user_msg("u2"),
1480        ];
1481        // raw_split = 4 → messages[3] = assistant text → boundary.
1482        assert_eq!(align_split_boundary(&msgs, 4), 4);
1483    }
1484}