Skip to main content

robit_agent/
context.rs

1//! Context management — output truncation and history window management.
2
3use async_openai::types::chat::{
4    ChatCompletionRequestMessage, ChatCompletionRequestUserMessage,
5    ChatCompletionRequestUserMessageContent,
6};
7use robit_ai::config::ContextConfig;
8
9// ============================================================================
10// Truncation result
11// ============================================================================
12
13/// Type of truncation action, determines how the caller should handle the result.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum TruncationAction {
16    /// Generate a new summary segment from full conversation rounds.
17    /// The removed messages in `TruncationResult` are the full rounds to summarize.
18    NewSegment,
19    /// Merge multiple existing summary segments into one.
20    /// `summaries` contains the text of segments to merge (oldest first).
21    /// `start_position` is the index of the first segment in history.
22    /// `count` is how many consecutive segments to merge.
23    MergeSegments {
24        summaries: Vec<String>,
25        start_position: usize,
26        count: usize,
27    },
28    /// No compression needed — history was truncated but the removed content
29    /// is too small to justify an LLM summary call.
30    TruncateOnly,
31}
32
33/// Result of context truncation, used for async compression.
34#[derive(Debug)]
35pub struct TruncationResult {
36    /// Number of conversation rounds removed.
37    pub rounds_removed: usize,
38    /// Number of individual messages removed.
39    pub messages_removed: usize,
40    /// The removed messages (for generating summary — only for NewSegment action).
41    pub removed_messages: Vec<ChatCompletionRequestMessage>,
42    /// Position where summary should be inserted / replaced.
43    pub insert_position: usize,
44    /// Whether compression is needed (token count exceeds threshold).
45    pub needs_compression: bool,
46    /// The type of truncation action taken.
47    pub action: TruncationAction,
48}
49
50// ============================================================================
51// Tool output truncation (Layer 1)
52// ============================================================================
53
54/// Truncate tool output based on line count and byte limits.
55pub fn truncate_output(content: &str, max_lines: usize, max_bytes: usize) -> String {
56    let lines: Vec<&str> = content.lines().collect();
57    let total_lines = lines.len();
58    let total_bytes = content.len();
59
60    // Check if truncation is needed
61    let line_truncated = total_lines > max_lines;
62    let byte_truncated = total_bytes > max_bytes;
63
64    if !line_truncated && !byte_truncated {
65        return content.to_string();
66    }
67
68    let mut output = String::new();
69    let mut byte_count = 0;
70    let mut displayed_lines = 0;
71
72    for (i, line) in lines.iter().enumerate() {
73        if i >= max_lines {
74            break;
75        }
76        let line_with_newline = if i < total_lines - 1 {
77            format!("{}\n", line)
78        } else {
79            line.to_string()
80        };
81
82        if byte_count + line_with_newline.len() > max_bytes {
83            break;
84        }
85
86        output.push_str(&line_with_newline);
87        byte_count += line_with_newline.len();
88        displayed_lines += 1;
89    }
90
91    if line_truncated {
92        output.push_str(&format!(
93            "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)",
94            total_lines, displayed_lines
95        ));
96    } else if byte_truncated {
97        output.push_str(&format!(
98            "\n... (Output truncated, {} bytes total, showing first {} bytes)",
99            total_bytes, byte_count
100        ));
101    }
102
103    output
104}
105
106// ============================================================================
107// Token estimation
108// ============================================================================
109
110/// Estimate token count for a string.
111///
112/// Uses a more nuanced heuristic based on character type:
113/// - ASCII letters/digits: ~3.5 chars/token (BPE tokenizer average)
114/// - CJK characters: ~1.5 chars/token (most CJK chars are 1-2 tokens each)
115/// - Whitespace: minimal token cost (usually merged with adjacent tokens)
116/// - Punctuation/symbols: ~1 token per char (often individual tokens)
117/// - Code (braces, operators): ~1 token per char
118///
119/// This is still an estimate; apply `token_safety_margin` at the message level.
120pub fn estimate_tokens(text: &str) -> usize {
121    if text.is_empty() {
122        return 0;
123    }
124
125    let mut ascii_alnum = 0usize;
126    let mut cjk = 0usize;
127    let mut whitespace = 0usize;
128    let mut other = 0usize; // punctuation, symbols, code characters
129
130    for ch in text.chars() {
131        if ch.is_whitespace() {
132            whitespace += 1;
133        } else if ch.is_ascii_alphanumeric() {
134            ascii_alnum += 1;
135        } else {
136            let cp = ch as u32;
137            // CJK Unified Ideographs + extensions + fullwidth forms
138            // + Hiragana, Katakana, Hangul, CJK punctuation
139            if (0x4E00..=0x9FFF).contains(&cp)
140                || (0x3400..=0x4DBF).contains(&cp)
141                || (0xF900..=0xFAFF).contains(&cp)
142                || (0xFF00..=0xFFEF).contains(&cp)
143                || (0x3000..=0x303F).contains(&cp)
144                || (0x3040..=0x309F).contains(&cp)
145                || (0x30A0..=0x30FF).contains(&cp)
146                || (0xAC00..=0xD7AF).contains(&cp)
147            {
148                cjk += 1;
149            } else {
150                other += 1;
151            }
152        }
153    }
154
155    // BPE tokenizer averages:
156    // - ASCII alphanumeric: ~3.5 chars per token
157    // - CJK: ~1.5 chars per token (most are 1 token each, some pairs)
158    // - Whitespace: negligible (merged with adjacent tokens)
159    // - Other (punctuation/code): ~1 char per token
160    let ascii_tokens = (ascii_alnum as f64 / 3.5).ceil() as usize;
161    let cjk_tokens = (cjk as f64 / 1.5).ceil() as usize;
162    let whitespace_tokens = (whitespace as f64 / 10.0).ceil() as usize;
163    let other_tokens = other; // ~1:1
164
165    ascii_tokens + cjk_tokens + whitespace_tokens + other_tokens
166}
167
168/// Estimate tokens for a list of messages.
169pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
170    let mut total = 0;
171    for msg in messages {
172        // Each message has ~4 tokens of overhead (role, delimiters)
173        total += 4;
174        total += estimate_message_content_tokens(msg);
175    }
176    total
177}
178
179/// Estimate tokens for messages, applying the configured safety margin.
180pub fn estimate_messages_tokens_with_margin(
181    messages: &[ChatCompletionRequestMessage],
182    safety_margin: f32,
183) -> usize {
184    let raw = estimate_messages_tokens(messages);
185    (raw as f32 * safety_margin).ceil() as usize
186}
187
188/// Estimate tokens for a single message's content.
189fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
190    match serde_json::to_string(msg) {
191        Ok(json) => estimate_tokens(&json),
192        Err(_) => 0,
193    }
194}
195
196// ============================================================================
197// Context manager (Layer 2: history truncation)
198// ============================================================================
199
200/// Manages the context window, truncating history when approaching token limits.
201pub struct ContextManager {
202    /// Model's context window size in tokens.
203    pub max_tokens: usize,
204    /// Ratio of context window to reserve for LLM response (default 0.2 = 20%).
205    pub reserve_ratio: f32,
206    /// Fraction of max_tokens at which truncation triggers (default 0.7).
207    pub truncation_ratio: f32,
208    /// Minimum conversation rounds to keep after truncation (default 3).
209    pub min_keep_rounds: usize,
210    /// Safety multiplier for token estimates (default 1.3).
211    pub token_safety_margin: f32,
212    /// Max output lines for tool results.
213    pub max_output_lines: usize,
214    /// Max output bytes for tool results.
215    pub max_output_bytes: usize,
216    /// Token threshold for triggering compression.
217    pub compression_token_threshold: usize,
218    /// Whether compression is enabled.
219    pub compression_enabled: bool,
220    /// Maximum tool calls per turn (default 30).
221    pub max_tool_calls_per_turn: usize,
222    /// Whether progressive segmented compression is enabled (default true).
223    pub progressive_compression: bool,
224    /// Number of full rounds per summary segment (default 3).
225    pub rounds_per_summary: usize,
226    /// Maximum number of summary segments to keep (default 5).
227    pub max_summary_segments: usize,
228    /// Number of segments to merge at a time (default 2).
229    pub merge_count: usize,
230    /// Maximum merges per segment before discarding (default 2).
231    pub max_merges_per_segment: usize,
232}
233
234impl ContextManager {
235    pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
236        let max_tokens = context_window.unwrap_or(65536) as usize;
237
238        let (
239            max_output_lines,
240            max_output_bytes,
241            reserve_ratio,
242            truncation_ratio,
243            min_keep_rounds,
244            token_safety_margin,
245            compression_token_threshold,
246            compression_enabled,
247            max_tool_calls_per_turn,
248            progressive_compression,
249            rounds_per_summary,
250            max_summary_segments,
251            merge_count,
252            max_merges_per_segment,
253        ) = match config {
254            Some(c) => (
255                c.max_output_lines.unwrap_or(500),
256                c.max_output_bytes.unwrap_or(51200),
257                c.reserve_ratio.unwrap_or(0.2),
258                c.truncation_ratio.unwrap_or(0.7),
259                c.min_keep_rounds.unwrap_or(3),
260                c.token_safety_margin.unwrap_or(1.3),
261                c.compression_token_threshold.unwrap_or(5000),
262                c.compression_enabled.unwrap_or(true),
263                c.max_tool_calls_per_turn.unwrap_or(30),
264                c.progressive_compression.unwrap_or(true),
265                c.rounds_per_summary.unwrap_or(3),
266                c.max_summary_segments.unwrap_or(5),
267                c.merge_count.unwrap_or(2),
268                c.max_merges_per_segment.unwrap_or(2),
269            ),
270            None => (500, 51200, 0.2, 0.7, 3, 1.3, 5000, true, 30, true, 3, 5, 2, 2),
271        };
272
273        Self {
274            max_tokens,
275            reserve_ratio,
276            truncation_ratio,
277            min_keep_rounds,
278            token_safety_margin,
279            max_output_lines,
280            max_output_bytes,
281            compression_token_threshold,
282            compression_enabled,
283            max_tool_calls_per_turn,
284            progressive_compression,
285            rounds_per_summary,
286            max_summary_segments,
287            merge_count,
288            max_merges_per_segment,
289        }
290    }
291
292    /// Maximum tokens at which truncation is triggered.
293    /// Uses `truncation_ratio` (default 0.7) to trigger earlier than the
294    /// absolute limit, leaving headroom for estimation errors and LLM response.
295    pub fn truncation_threshold(&self) -> usize {
296        (self.max_tokens as f32 * self.truncation_ratio) as usize
297    }
298
299    /// Maximum tokens available for input (total - reserved for response).
300    /// Note: this is the absolute upper bound; truncation actually triggers
301    /// earlier via `truncation_threshold()`.
302    pub fn available_tokens(&self) -> usize {
303        (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
304    }
305
306    /// Truncate tool output using the configured limits.
307    pub fn truncate_tool_output(&self, content: &str) -> String {
308        truncate_output(content, self.max_output_lines, self.max_output_bytes)
309    }
310
311    /// Check if history needs truncation and perform it if necessary.
312    /// Returns `TruncationResult` with removed messages for async compression.
313    ///
314    /// Strategy (progressive compression):
315    /// 1. Uses `truncation_threshold()` (default 70% of max_tokens) as trigger point.
316    /// 2. When over threshold, performs exactly one compression action per call:
317    ///    - Priority 1: Compress the oldest `rounds_per_summary` full rounds into a new summary segment.
318    ///    - Priority 2: Merge the oldest `merge_count` summary segments into one.
319    ///    - Priority 3: Discard the oldest summary segment (if merge limit reached).
320    ///    - Priority 4: Fall back to aggressive truncation (old behavior).
321    /// 3. If `progressive_compression` is disabled, falls back to single-shot truncation.
322    pub fn maybe_truncate(
323        &self,
324        messages: &mut Vec<ChatCompletionRequestMessage>,
325    ) -> TruncationResult {
326        let estimated = estimate_messages_tokens_with_margin(messages, self.token_safety_margin);
327        let threshold = self.truncation_threshold();
328
329        tracing::debug!("maybe_truncate: initial message count = {}, estimated tokens = {}, threshold = {}",
330            messages.len(), estimated, threshold);
331
332        if estimated <= threshold {
333            tracing::debug!("maybe_truncate: No truncation needed ({} <= {})", estimated, threshold);
334            return TruncationResult {
335                rounds_removed: 0,
336                messages_removed: 0,
337                removed_messages: Vec::new(),
338                insert_position: 0,
339                needs_compression: false,
340                action: TruncationAction::TruncateOnly,
341            };
342        }
343
344        tracing::info!(
345            "=== Context truncation triggered ==="
346        );
347        tracing::info!(
348            "Estimated: {} tokens (with {:.1}x margin), Threshold: {} tokens, Max: {} tokens",
349            estimated,
350            self.token_safety_margin,
351            threshold,
352            self.max_tokens
353        );
354        tracing::info!("Compression enabled: {}, Progressive: {}",
355            self.compression_enabled, self.progressive_compression);
356
357        // Fall back to legacy single-shot truncation if progressive is disabled
358        if !self.progressive_compression {
359            tracing::info!("Progressive compression disabled, using legacy single-shot truncation");
360            return self.legacy_truncate(messages, threshold);
361        }
362
363        // Try progressive compression actions in priority order
364        if let Some(result) = self.try_new_segment(messages) {
365            tracing::info!("Progressive action: NewSegment ({} rounds)", result.rounds_removed);
366            return result;
367        }
368
369        if let Some(result) = self.try_merge_segments(messages) {
370            tracing::info!("Progressive action: MergeSegments");
371            return result;
372        }
373
374        if let Some(result) = self.try_discard_oldest_segment(messages) {
375            tracing::info!("Progressive action: Discard oldest segment");
376            return result;
377        }
378
379        // Fallback: aggressive single-shot truncation
380        tracing::warn!("All progressive actions exhausted, falling back to legacy truncation");
381        self.legacy_truncate(messages, threshold)
382    }
383
384    // ------------------------------------------------------------------------
385    // Progressive compression: Priority 1 — new summary segment
386    // ------------------------------------------------------------------------
387
388    fn try_new_segment(
389        &self,
390        messages: &mut Vec<ChatCompletionRequestMessage>,
391    ) -> Option<TruncationResult> {
392        if !self.compression_enabled {
393            return None;
394        }
395
396        // Find all full (non-summary, non-system) user rounds,
397        // skipping summary segments and the discard notice.
398        let round_starts: Vec<usize> = messages
399            .iter()
400            .enumerate()
401            .filter(|(_, m)| is_user_message(m) && !is_summary_segment(m) && !is_discard_notice(m))
402            .map(|(i, _)| i)
403            .collect();
404
405        if round_starts.len() <= self.min_keep_rounds + self.rounds_per_summary {
406            tracing::debug!("Not enough full rounds to compress (have {}, need min_keep + rounds_per_summary = {})",
407                round_starts.len(), self.min_keep_rounds + self.rounds_per_summary);
408            return None;
409        }
410
411        // Take the oldest `rounds_per_summary` rounds
412        let take_rounds = self.rounds_per_summary.min(round_starts.len() - self.min_keep_rounds);
413        if take_rounds == 0 {
414            return None;
415        }
416
417        let start_idx = round_starts[0];
418        let end_idx = if take_rounds < round_starts.len() {
419            round_starts[take_rounds]
420        } else {
421            messages.len()
422        };
423
424        let removed_messages: Vec<ChatCompletionRequestMessage> =
425            messages[start_idx..end_idx].to_vec();
426        let messages_removed = removed_messages.len();
427        let removed_tokens = estimate_messages_tokens(&removed_messages);
428
429        // Need enough tokens to justify compression
430        if removed_tokens < self.compression_token_threshold {
431            tracing::debug!("Removed tokens ({}) below compression threshold ({})",
432                removed_tokens, self.compression_token_threshold);
433            return None;
434        }
435
436        // Remove the rounds
437        messages.drain(start_idx..end_idx);
438
439        // Insert placeholder after system messages + discard notice (if any)
440        // i.e. before any other summary segments
441        let system_msg_count = messages.iter().take_while(|m| is_system_message(m)).count();
442        let has_discard = find_discard_notice_pos(messages).is_some();
443        let insert_pos = system_msg_count + if has_discard { 1 } else { 0 };
444
445        let placeholder = make_summary_placeholder(take_rounds);
446        messages.insert(insert_pos, placeholder);
447
448        tracing::info!(
449            "New summary segment: removed {} rounds ({} messages, ~{} tokens), insert at {}",
450            take_rounds, messages_removed, removed_tokens, insert_pos
451        );
452
453        Some(TruncationResult {
454            rounds_removed: take_rounds,
455            messages_removed,
456            removed_messages,
457            insert_position: insert_pos,
458            needs_compression: true,
459            action: TruncationAction::NewSegment,
460        })
461    }
462
463    // ------------------------------------------------------------------------
464    // Progressive compression: Priority 2 — merge summary segments
465    // ------------------------------------------------------------------------
466
467    fn try_merge_segments(
468        &self,
469        messages: &mut Vec<ChatCompletionRequestMessage>,
470    ) -> Option<TruncationResult> {
471        if !self.compression_enabled {
472            return None;
473        }
474
475        let segments = find_summary_segments(messages);
476        if segments.len() <= self.max_summary_segments {
477            tracing::debug!("Segment count ({}) within limit ({})", segments.len(), self.max_summary_segments);
478            return None;
479        }
480
481        // Check if the oldest segment can still be merged
482        let oldest = segments.first()?;
483        if oldest.merge_level >= self.max_merges_per_segment {
484            tracing::debug!("Oldest segment at merge level {} >= max {}, will discard instead",
485                oldest.merge_level, self.max_merges_per_segment);
486            return None;
487        }
488
489        // Take the oldest `merge_count` segments
490        let take_count = self.merge_count.min(segments.len());
491        if take_count < 2 {
492            return None;
493        }
494
495        let start_pos = segments[0].index;
496        let end_pos = segments[take_count - 1].index + 1;
497
498        let summaries: Vec<String> = segments[..take_count]
499            .iter()
500            .map(|s| s.content.clone())
501            .collect();
502
503        let max_level = segments[..take_count]
504            .iter()
505            .map(|s| s.merge_level)
506            .max()
507            .unwrap_or(0);
508        let new_level = max_level + 1;
509
510        // Remove the old segments
511        messages.drain(start_pos..end_pos);
512
513        // Insert merged placeholder
514        let placeholder = make_merge_placeholder(new_level, take_count);
515        messages.insert(start_pos, placeholder);
516
517        tracing::info!(
518            "Merging {} summary segments into one (level {}), start pos {}",
519            take_count, new_level, start_pos
520        );
521
522        Some(TruncationResult {
523            rounds_removed: 0,
524            messages_removed: take_count,
525            removed_messages: Vec::new(),
526            insert_position: start_pos,
527            needs_compression: true,
528            action: TruncationAction::MergeSegments {
529                summaries,
530                start_position: start_pos,
531                count: take_count,
532            },
533        })
534    }
535
536    // ------------------------------------------------------------------------
537    // Progressive compression: Priority 3 — discard oldest summary segment
538    // ------------------------------------------------------------------------
539
540    fn try_discard_oldest_segment(
541        &self,
542        messages: &mut Vec<ChatCompletionRequestMessage>,
543    ) -> Option<TruncationResult> {
544        let segments = find_summary_segments(messages);
545        if segments.len() <= self.max_summary_segments {
546            return None;
547        }
548
549        let oldest = segments.first()?;
550        if oldest.merge_level < self.max_merges_per_segment {
551            // Should have been handled by try_merge_segments
552            return None;
553        }
554
555        // Remove the oldest segment
556        let pos = oldest.index;
557        messages.remove(pos);
558
559        tracing::info!("Discarded oldest summary segment at position {}", pos);
560
561        // Ensure discard notice exists
562        let system_msg_count = messages.iter().take_while(|m| is_system_message(m)).count();
563        if find_discard_notice_pos(messages).is_none() {
564            let notice = make_discard_notice();
565            messages.insert(system_msg_count, notice);
566            tracing::debug!("Added discard notice at position {}", system_msg_count);
567        }
568
569        Some(TruncationResult {
570            rounds_removed: 0,
571            messages_removed: 1,
572            removed_messages: Vec::new(),
573            insert_position: pos,
574            needs_compression: false,
575            action: TruncationAction::TruncateOnly,
576        })
577    }
578
579    // ------------------------------------------------------------------------
580    // Legacy single-shot truncation (fallback)
581    // ------------------------------------------------------------------------
582
583    fn legacy_truncate(
584        &self,
585        messages: &mut Vec<ChatCompletionRequestMessage>,
586        threshold: usize,
587    ) -> TruncationResult {
588        // Find round boundaries: a round starts with a User message
589        let mut round_starts: Vec<usize> = Vec::new();
590        for (i, msg) in messages.iter().enumerate() {
591            if is_user_message(msg) {
592                round_starts.push(i);
593            }
594        }
595        tracing::debug!("Found {} user message round boundaries", round_starts.len());
596
597        if round_starts.is_empty() {
598            tracing::debug!("No user messages found, no truncation performed");
599            return TruncationResult {
600                rounds_removed: 0,
601                messages_removed: 0,
602                removed_messages: Vec::new(),
603                insert_position: 0,
604                needs_compression: false,
605                action: TruncationAction::TruncateOnly,
606            };
607        }
608
609        let total_rounds = round_starts.len();
610        let must_keep = self.min_keep_rounds.min(total_rounds);
611        tracing::debug!("Total rounds: {}, Must keep at least: {} rounds", total_rounds, must_keep);
612
613        let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
614        let mut rounds_removed = 0;
615        let mut messages_removed = 0;
616
617        while round_starts.len() > must_keep
618            && estimate_messages_tokens_with_margin(messages, self.token_safety_margin) > threshold
619        {
620            let start_idx = round_starts[0];
621            let end_idx = if round_starts.len() > 1 {
622                round_starts[1]
623            } else {
624                messages.len()
625            };
626
627            if self.compression_enabled {
628                removed_messages.extend(messages[start_idx..end_idx].to_vec());
629            }
630
631            let count = end_idx - start_idx;
632            messages.drain(start_idx..end_idx);
633
634            round_starts.remove(0);
635            for idx in round_starts.iter_mut() {
636                *idx = idx.saturating_sub(count);
637            }
638
639            rounds_removed += 1;
640            messages_removed += count;
641        }
642
643        if rounds_removed == 0 {
644            tracing::debug!("No rounds removed after checks");
645            return TruncationResult {
646                rounds_removed: 0,
647                messages_removed: 0,
648                removed_messages: Vec::new(),
649                insert_position: 0,
650                needs_compression: false,
651                action: TruncationAction::TruncateOnly,
652            };
653        }
654
655        let removed_tokens = estimate_messages_tokens(&removed_messages);
656        let needs_compression =
657            self.compression_enabled && removed_tokens >= self.compression_token_threshold;
658
659        let system_msg_count = messages
660            .iter()
661            .take_while(|m| is_system_message(m))
662            .count();
663
664        let notice = if needs_compression {
665            format!(
666                "[Context compressed: {} earlier rounds ({} messages, ~{} tokens) have been summarized. {} most recent rounds preserved.]",
667                rounds_removed, messages_removed, removed_tokens, round_starts.len()
668            )
669        } else {
670            format!(
671                "[Context truncated: {} earlier rounds ({} messages) removed to stay within token limit. {} most recent rounds preserved.]",
672                rounds_removed, messages_removed, round_starts.len()
673            )
674        };
675
676        let notice_msg = ChatCompletionRequestMessage::User(
677            async_openai::types::chat::ChatCompletionRequestUserMessage {
678                content: notice.into(),
679                name: Some("system_notice".to_string()),
680            }
681            .into(),
682        );
683
684        messages.insert(system_msg_count, notice_msg);
685
686        tracing::info!(
687            "Legacy truncation: removed {} rounds ({} messages), kept {} rounds",
688            rounds_removed, messages_removed, round_starts.len()
689        );
690
691        TruncationResult {
692            rounds_removed,
693            messages_removed,
694            removed_messages,
695            insert_position: system_msg_count,
696            needs_compression,
697            action: if needs_compression {
698                // For legacy mode, treat single-shot summary as NewSegment
699                // (one summary from many rounds)
700                TruncationAction::NewSegment
701            } else {
702                TruncationAction::TruncateOnly
703            },
704        }
705    }
706}
707
708fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
709    matches!(msg, ChatCompletionRequestMessage::User(_))
710}
711
712fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
713    matches!(msg, ChatCompletionRequestMessage::System(_))
714}
715
716// ============================================================================
717// Summary segment helpers (progressive compression)
718// ============================================================================
719
720const SUMMARY_SEGMENT_PREFIX: &str = "summary_segment";
721const DISCARD_NOTICE_NAME: &str = "discard_notice";
722const LEGACY_NOTICE_NAME: &str = "system_notice";
723
724/// Returns the `name` field of a User message, if any.
725fn user_message_name(msg: &ChatCompletionRequestMessage) -> Option<&str> {
726    match msg {
727        ChatCompletionRequestMessage::User(u) => u.name.as_deref(),
728        _ => None,
729    }
730}
731
732/// Returns the text content of a User message, empty if not text.
733fn user_message_text(msg: &ChatCompletionRequestMessage) -> String {
734    match msg {
735        ChatCompletionRequestMessage::User(u) => match &u.content {
736            ChatCompletionRequestUserMessageContent::Text(t) => t.clone(),
737            ChatCompletionRequestUserMessageContent::Array(parts) => parts
738                .iter()
739                .filter_map(|p| match p {
740                    async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
741                    _ => None,
742                })
743                .collect::<Vec<_>>()
744                .join(" "),
745        },
746        _ => String::new(),
747    }
748}
749
750/// Check whether a message is a summary segment (any merge level, including legacy notice).
751pub fn is_summary_segment(msg: &ChatCompletionRequestMessage) -> bool {
752    match user_message_name(msg) {
753        Some(name) => {
754            name.starts_with(SUMMARY_SEGMENT_PREFIX) || name == LEGACY_NOTICE_NAME
755        }
756        None => false,
757    }
758}
759
760/// Get the merge level (number of times this segment has been merged).
761/// 0 = fresh segment, 1 = merged once, etc.
762pub fn get_merge_level(msg: &ChatCompletionRequestMessage) -> usize {
763    match user_message_name(msg) {
764        Some(name) => {
765            if name == LEGACY_NOTICE_NAME {
766                0
767            } else if let Some(suffix) = name.strip_prefix(SUMMARY_SEGMENT_PREFIX) {
768                if suffix.is_empty() {
769                    0
770                } else if let Some(num_str) = suffix.strip_prefix("_m") {
771                    num_str.parse::<usize>().unwrap_or(0)
772                } else {
773                    0
774                }
775            } else {
776                0
777            }
778        }
779        None => 0,
780    }
781}
782
783/// Check if a message is the discard notice.
784fn is_discard_notice(msg: &ChatCompletionRequestMessage) -> bool {
785    matches!(user_message_name(msg), Some(name) if name == DISCARD_NOTICE_NAME)
786}
787
788/// Build the `name` value for a summary segment at the given merge level.
789fn summary_segment_name(merge_level: usize) -> String {
790    if merge_level == 0 {
791        SUMMARY_SEGMENT_PREFIX.to_string()
792    } else {
793        format!("{}_m{}", SUMMARY_SEGMENT_PREFIX, merge_level)
794    }
795}
796
797/// Build a placeholder summary segment message (pending LLM generation).
798fn make_summary_placeholder(rounds_removed: usize) -> ChatCompletionRequestMessage {
799    let text = format!(
800        "[Compressing {} earlier conversation rounds into a summary...]",
801        rounds_removed
802    );
803    ChatCompletionRequestMessage::User(
804        ChatCompletionRequestUserMessage {
805            content: text.into(),
806            name: Some(summary_segment_name(0)),
807        }
808        .into(),
809    )
810}
811
812/// Build a placeholder for merged segments (pending LLM generation).
813fn make_merge_placeholder(merge_level: usize, count: usize) -> ChatCompletionRequestMessage {
814    let text = format!(
815        "[Merging {} earlier summary segments...]",
816        count
817    );
818    ChatCompletionRequestMessage::User(
819        ChatCompletionRequestUserMessage {
820            content: text.into(),
821            name: Some(summary_segment_name(merge_level)),
822        }
823        .into(),
824    )
825}
826
827/// Build the discard notice message.
828fn make_discard_notice() -> ChatCompletionRequestMessage {
829    let text = "[Note: Earlier conversation history beyond the earliest summary has been discarded to save context space.]";
830    ChatCompletionRequestMessage::User(
831        ChatCompletionRequestUserMessage {
832            content: text.into(),
833            name: Some(DISCARD_NOTICE_NAME.to_string()),
834        }
835        .into(),
836    )
837}
838
839/// Information about a summary segment found in history.
840#[derive(Debug, Clone)]
841struct SummarySegmentInfo {
842    index: usize,
843    merge_level: usize,
844    content: String,
845}
846
847/// Scan message history and collect all summary segments, ordered from oldest to newest.
848fn find_summary_segments(messages: &[ChatCompletionRequestMessage]) -> Vec<SummarySegmentInfo> {
849    let mut segments = Vec::new();
850    for (i, msg) in messages.iter().enumerate() {
851        if is_summary_segment(msg) {
852            segments.push(SummarySegmentInfo {
853                index: i,
854                merge_level: get_merge_level(msg),
855                content: user_message_text(msg),
856            });
857        }
858    }
859    segments
860}
861
862/// Find the position of the discard notice, if any.
863fn find_discard_notice_pos(messages: &[ChatCompletionRequestMessage]) -> Option<usize> {
864    messages.iter().position(is_discard_notice)
865}
866
867// ============================================================================
868// Transcript formatting for summary compression
869// ============================================================================
870
871/// Format removed messages into a compact transcript for summary generation.
872/// Extracts user messages, assistant text, and tool call names only.
873/// Truncates each message to keep the transcript concise.
874pub fn format_removed_messages_as_transcript(
875    messages: &[ChatCompletionRequestMessage],
876) -> String {
877    let mut transcript = String::new();
878
879    for msg in messages {
880        match msg {
881            ChatCompletionRequestMessage::User(user_msg) => {
882                let text = match &user_msg.content {
883                    async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) => {
884                        t.clone()
885                    }
886                    async_openai::types::chat::ChatCompletionRequestUserMessageContent::Array(parts) => {
887                        parts.iter()
888                            .filter_map(|p| match p {
889                                async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
890                                _ => None,
891                            })
892                            .collect::<Vec<_>>()
893                            .join(" ")
894                    }
895                };
896                let truncated = truncate_str(&text, 200);
897                transcript.push_str(&format!("User: {}\n", truncated));
898            }
899            ChatCompletionRequestMessage::Assistant(assistant_msg) => {
900                let content_str = assistant_msg.content.as_ref().map(|c| {
901                    match serde_json::to_string(c) {
902                        Ok(json) => json.trim_matches('"').to_string(),
903                        Err(_) => format!("{:?}", c),
904                    }
905                }).unwrap_or_default();
906                let truncated = truncate_str(&content_str, 300);
907                transcript.push_str(&format!("Assistant: {}\n", truncated));
908                if let Some(tool_calls) = &assistant_msg.tool_calls {
909                    for tc in tool_calls {
910                        if let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(f) = tc {
911                            transcript.push_str(&format!(
912                                "  [Tool: {}({})]\n",
913                                f.function.name,
914                                truncate_str(&f.function.arguments, 100)
915                            ));
916                        }
917                    }
918                }
919            }
920            ChatCompletionRequestMessage::Tool(tool_msg) => {
921                let content_str = match serde_json::to_string(&tool_msg.content) {
922                    Ok(json) => json.trim_matches('"').to_string(),
923                    Err(_) => format!("{:?}", tool_msg.content),
924                };
925                let truncated = truncate_str(&content_str, 150);
926                transcript.push_str(&format!("  [Result: {}]\n", truncated));
927            }
928            _ => {}
929        }
930    }
931
932    if transcript.is_empty() {
933        transcript.push_str("(no conversation content)");
934    }
935
936    transcript
937}
938
939/// Truncate a string to at most `max_chars` characters, adding "..." if truncated.
940/// Respects UTF-8 character boundaries.
941fn truncate_str(s: &str, max_chars: usize) -> String {
942    if s.len() <= max_chars {
943        s.to_string()
944    } else {
945        let mut end = max_chars;
946        while end > 0 && !s.is_char_boundary(end) {
947            end -= 1;
948        }
949        format!("{}...", &s[..end])
950    }
951}
952
953// ============================================================================
954// Tests
955// ============================================================================
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use async_openai::types::chat::ChatCompletionRequestUserMessage;
961
962    fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
963        ChatCompletionRequestMessage::User(
964            ChatCompletionRequestUserMessage {
965                content: content.into(),
966                name: None,
967            }
968            .into(),
969        )
970    }
971
972    fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
973        ChatCompletionRequestMessage::System(
974            async_openai::types::chat::ChatCompletionRequestSystemMessage {
975                content: content.into(),
976                name: None,
977            }
978            .into(),
979        )
980    }
981
982    fn make_test_config() -> ContextConfig {
983        ContextConfig {
984            max_output_lines: Some(500),
985            max_output_bytes: Some(51200),
986            reserve_ratio: Some(0.2),
987            truncation_ratio: Some(0.7),
988            min_keep_rounds: Some(3),
989            token_safety_margin: Some(1.3),
990            compression_token_threshold: Some(5000),
991            compression_enabled: Some(true),
992            max_tool_calls_per_turn: Some(30),
993            progressive_compression: Some(true),
994            rounds_per_summary: Some(3),
995            max_summary_segments: Some(5),
996            merge_count: Some(2),
997            max_merges_per_segment: Some(2),
998        }
999    }
1000
1001    fn make_user_message_named(content: &str, name: &str) -> ChatCompletionRequestMessage {
1002        ChatCompletionRequestMessage::User(
1003            ChatCompletionRequestUserMessage {
1004                content: content.into(),
1005                name: Some(name.to_string()),
1006            }
1007            .into(),
1008        )
1009    }
1010
1011    fn make_summary_segment(content: &str, merge_level: usize) -> ChatCompletionRequestMessage {
1012        let name = if merge_level == 0 {
1013            "summary_segment".to_string()
1014        } else {
1015            format!("summary_segment_m{}", merge_level)
1016        };
1017        make_user_message_named(content, &name)
1018    }
1019
1020    fn make_legacy_notice(content: &str) -> ChatCompletionRequestMessage {
1021        make_user_message_named(content, "system_notice")
1022    }
1023
1024    // fn make_discard_notice_msg() -> ChatCompletionRequestMessage {
1025    //     make_user_message_named(
1026    //         "[Note: Earlier conversation history beyond the earliest summary has been discarded.]",
1027    //         "discard_notice",
1028    //     )
1029    // }
1030
1031    // ==========================================================================
1032    // estimate_tokens tests
1033    // ==========================================================================
1034
1035    #[test]
1036    fn test_estimate_tokens_english() {
1037        let text = "Hello world, this is a test of the token estimation system.";
1038        let tokens = estimate_tokens(text);
1039        assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
1040        assert!(tokens <= 30, "Expected at most 30 tokens, got {}", tokens);
1041    }
1042
1043    #[test]
1044    fn test_estimate_tokens_chinese() {
1045        let chinese = "你好世界,这是一个测试。";
1046        let tokens = estimate_tokens(chinese);
1047        assert!(tokens >= 5, "Expected at least 5 tokens, got {}", tokens);
1048        assert!(tokens <= 15, "Expected at most 15 tokens, got {}", tokens);
1049    }
1050
1051    #[test]
1052    fn test_estimate_tokens_code() {
1053        let code = "fn main() {\n    println!(\"Hello\");\n}";
1054        let tokens = estimate_tokens(code);
1055        assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
1056        assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
1057    }
1058
1059    #[test]
1060    fn test_estimate_tokens_empty() {
1061        assert_eq!(estimate_tokens(""), 0);
1062    }
1063
1064    #[test]
1065    fn test_estimate_tokens_mixed() {
1066        let mixed = "Hello 你好 world 世界!fn test() {}";
1067        let tokens = estimate_tokens(mixed);
1068        assert!(tokens > 0);
1069        assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
1070    }
1071
1072    #[test]
1073    fn test_estimate_messages_tokens_with_margin() {
1074        let messages = vec![
1075            make_system_message("You are a helpful assistant"),
1076            make_user_message("Hello world"),
1077        ];
1078        let raw = estimate_messages_tokens(&messages);
1079        let with_margin = estimate_messages_tokens_with_margin(&messages, 1.3);
1080        assert!(with_margin > raw);
1081        // 1.3x margin should be ~30% higher
1082        let expected = (raw as f32 * 1.3).ceil() as usize;
1083        assert_eq!(with_margin, expected);
1084    }
1085
1086    // ==========================================================================
1087    // ContextManager tests
1088    // ==========================================================================
1089
1090    #[test]
1091    fn test_truncation_threshold() {
1092        let config = make_test_config();
1093        let manager = ContextManager::new(Some(65536), Some(&config));
1094        // 65536 * 0.7 = 45875
1095        assert_eq!(manager.truncation_threshold(), 45875);
1096    }
1097
1098    #[test]
1099    fn test_truncation_result_no_truncation() {
1100        let mut messages = vec![
1101            make_system_message("You are a helpful assistant"),
1102            make_user_message("Hello"),
1103        ];
1104
1105        let config = make_test_config();
1106        let manager = ContextManager::new(Some(65536), Some(&config));
1107        let result = manager.maybe_truncate(&mut messages);
1108
1109        assert_eq!(result.rounds_removed, 0);
1110        assert!(!result.needs_compression);
1111    }
1112
1113    #[test]
1114    fn test_truncation_respects_min_keep_rounds() {
1115        let mut messages = vec![
1116            make_system_message("You are a helpful assistant"),
1117        ];
1118
1119        // Add 10 rounds of large messages
1120        for i in 0..10 {
1121            let content = format!("User message {}: {}", i, "x".repeat(2000));
1122            messages.push(make_user_message(&content));
1123        }
1124
1125        let mut config = make_test_config();
1126        config.min_keep_rounds = Some(3); // Must keep at least 3 rounds
1127
1128        // Use small context window to force aggressive truncation
1129        let manager = ContextManager::new(Some(5000), Some(&config));
1130        let result = manager.maybe_truncate(&mut messages);
1131
1132        // Should have removed some rounds...
1133        assert!(
1134            result.rounds_removed > 0,
1135            "Should have removed some rounds"
1136        );
1137        // ...but should still have at least 3 user rounds + notice
1138        let user_count = messages
1139            .iter()
1140            .filter(|m| matches!(m, ChatCompletionRequestMessage::User(_)))
1141            .count();
1142        assert!(
1143            user_count >= 4,
1144            "Should have at least 3 user rounds + notice, got {}",
1145            user_count
1146        );
1147    }
1148
1149    #[test]
1150    fn test_truncation_early_trigger() {
1151        let mut messages = vec![
1152            make_system_message("You are a helpful assistant"),
1153        ];
1154
1155        // Add 8 rounds of messages, each ~2000 chars
1156        for i in 0..8 {
1157            let content = format!("User message {}: {}", i, "x".repeat(2000));
1158            messages.push(make_user_message(&content));
1159        }
1160
1161        let mut config = make_test_config();
1162        config.truncation_ratio = Some(0.7);
1163        config.min_keep_rounds = Some(2);
1164        config.token_safety_margin = Some(1.3);
1165
1166        // With 65536 context, truncation threshold = 45875
1167        // 8 rounds * ~2000 chars each ≈ much less than 45875, so no truncation
1168        let manager = ContextManager::new(Some(65536), Some(&config));
1169        let result = manager.maybe_truncate(&mut messages);
1170        assert_eq!(
1171            result.rounds_removed, 0,
1172            "Should not truncate small messages in large window"
1173        );
1174
1175        // With 8000 context, truncation threshold = 5600
1176        let manager2 = ContextManager::new(Some(8000), Some(&config));
1177        let mut messages2 = messages.clone();
1178        let result2 = manager2.maybe_truncate(&mut messages2);
1179        assert!(
1180            result2.rounds_removed > 0,
1181            "Should truncate when exceeding small window"
1182        );
1183    }
1184
1185    #[test]
1186    fn test_token_safety_margin_effect() {
1187        let mut messages = vec![
1188            make_system_message("You are a helpful assistant"),
1189        ];
1190
1191        for i in 0..10 {
1192            let content = format!("User message {}: {}", i, "x".repeat(500));
1193            messages.push(make_user_message(&content));
1194        }
1195
1196        // With margin 1.0 (no safety), truncation may not trigger
1197        let mut config_low = make_test_config();
1198        config_low.token_safety_margin = Some(1.0);
1199        config_low.truncation_ratio = Some(0.7);
1200        config_low.min_keep_rounds = Some(1);
1201
1202        let mut msgs_low = messages.clone();
1203        let manager_low = ContextManager::new(Some(8000), Some(&config_low));
1204        let result_low = manager_low.maybe_truncate(&mut msgs_low);
1205
1206        // With margin 2.0 (very conservative), truncation more likely triggers
1207        let mut config_high = make_test_config();
1208        config_high.token_safety_margin = Some(2.0);
1209        config_high.truncation_ratio = Some(0.7);
1210        config_high.min_keep_rounds = Some(1);
1211
1212        let mut msgs_high = messages.clone();
1213        let manager_high = ContextManager::new(Some(8000), Some(&config_high));
1214        let result_high = manager_high.maybe_truncate(&mut msgs_high);
1215
1216        // Higher margin should result in >= rounds removed
1217        assert!(
1218            result_high.rounds_removed >= result_low.rounds_removed,
1219            "Higher safety margin should trigger at least as much truncation: high={}, low={}",
1220            result_high.rounds_removed,
1221            result_low.rounds_removed
1222        );
1223    }
1224
1225    #[test]
1226    fn test_compression_flag_in_old_tests() {
1227        let mut messages = vec![
1228            make_system_message("You are a helpful assistant"),
1229        ];
1230
1231        // Add 20 rounds of large messages
1232        for i in 0..20 {
1233            let content = format!("User message {}: {}", i, "x".repeat(2000));
1234            messages.push(make_user_message(&content));
1235        }
1236
1237        let mut config = make_test_config();
1238        config.compression_enabled = Some(false);
1239        config.compression_token_threshold = Some(1000);
1240        config.min_keep_rounds = Some(1);
1241
1242        let manager = ContextManager::new(Some(5000), Some(&config));
1243        let result = manager.maybe_truncate(&mut messages);
1244
1245        assert!(result.rounds_removed > 0);
1246        assert!(
1247            !result.needs_compression,
1248            "Should be false when compression disabled"
1249        );
1250    }
1251
1252    #[test]
1253    fn test_truncate_output() {
1254        let content = "line1\nline2\nline3\nline4\nline5";
1255        let truncated = truncate_output(content, 3, 100);
1256        assert!(truncated.contains("line1"));
1257        assert!(truncated.contains("line2"));
1258        assert!(truncated.contains("line3"));
1259        assert!(!truncated.contains("line4"));
1260        assert!(truncated.contains("Output truncated"));
1261    }
1262
1263    // ==========================================================================
1264    // Transcript formatting tests
1265    // ==========================================================================
1266
1267    #[test]
1268    fn test_truncate_str_no_truncation() {
1269        let result = truncate_str("hello", 10);
1270        assert_eq!(result, "hello");
1271    }
1272
1273    #[test]
1274    fn test_truncate_str_with_truncation() {
1275        let result = truncate_str("hello world this is long", 10);
1276        assert_eq!(result, "hello worl...");
1277    }
1278
1279    #[test]
1280    fn test_format_transcript_user_and_assistant() {
1281        let messages = vec![
1282            make_user_message("Fix the bug in auth.rs"),
1283            make_system_message("System message should be skipped"),
1284        ];
1285
1286        let transcript = format_removed_messages_as_transcript(&messages);
1287        assert!(transcript.contains("User: Fix the bug in auth.rs"));
1288        assert!(!transcript.contains("System message"), "System messages should be skipped");
1289    }
1290
1291    #[test]
1292    fn test_format_transcript_empty() {
1293        let messages: Vec<ChatCompletionRequestMessage> = vec![];
1294        let transcript = format_removed_messages_as_transcript(&messages);
1295        assert!(transcript.contains("no conversation content"));
1296    }
1297
1298    #[test]
1299    fn test_format_transcript_truncates_long_messages() {
1300        let long_text = "x".repeat(500);
1301        let messages = vec![
1302            make_user_message(&long_text),
1303        ];
1304
1305        let transcript = format_removed_messages_as_transcript(&messages);
1306        assert!(transcript.contains("..."));
1307        // Should not contain the full 500 chars
1308        assert!(transcript.len() < long_text.len() + 50);
1309    }
1310
1311    // ==========================================================================
1312    // Progressive compression tests
1313    // ==========================================================================
1314
1315    #[test]
1316    fn test_is_summary_segment_recognizes_all_levels() {
1317        let m0 = make_summary_segment("summary 0", 0);
1318        let m1 = make_summary_segment("summary 1", 1);
1319        let m2 = make_summary_segment("summary 2", 2);
1320        let legacy = make_legacy_notice("old notice");
1321        let normal = make_user_message("hello");
1322
1323        assert!(is_summary_segment(&m0));
1324        assert!(is_summary_segment(&m1));
1325        assert!(is_summary_segment(&m2));
1326        assert!(is_summary_segment(&legacy));
1327        assert!(!is_summary_segment(&normal));
1328    }
1329
1330    #[test]
1331    fn test_get_merge_level() {
1332        assert_eq!(get_merge_level(&make_summary_segment("a", 0)), 0);
1333        assert_eq!(get_merge_level(&make_summary_segment("b", 1)), 1);
1334        assert_eq!(get_merge_level(&make_summary_segment("c", 2)), 2);
1335        assert_eq!(get_merge_level(&make_legacy_notice("d")), 0);
1336        assert_eq!(get_merge_level(&make_user_message("e")), 0);
1337    }
1338
1339    #[test]
1340    fn test_progressive_no_truncation_needed() {
1341        let mut messages = vec![
1342            make_system_message("sys"),
1343            make_user_message("hi"),
1344        ];
1345        let config = make_test_config();
1346        let manager = ContextManager::new(Some(65536), Some(&config));
1347        let result = manager.maybe_truncate(&mut messages);
1348
1349        assert_eq!(result.rounds_removed, 0);
1350        assert!(!result.needs_compression);
1351        assert_eq!(result.action, TruncationAction::TruncateOnly);
1352    }
1353
1354    #[test]
1355    fn test_progressive_new_segment() {
1356        let mut messages = vec![make_system_message("sys")];
1357        // 10 rounds of large content
1358        for i in 0..10 {
1359            let content = format!("Round {}: {}", i, "x".repeat(2000));
1360            messages.push(make_user_message(&content));
1361        }
1362
1363        let mut config = make_test_config();
1364        config.min_keep_rounds = Some(3);
1365        config.rounds_per_summary = Some(3);
1366        config.compression_token_threshold = Some(100); // low threshold
1367
1368        let manager = ContextManager::new(Some(8000), Some(&config));
1369        let result = manager.maybe_truncate(&mut messages);
1370
1371        assert_eq!(result.action, TruncationAction::NewSegment);
1372        assert!(result.needs_compression);
1373        assert_eq!(result.rounds_removed, 3);
1374        assert!(result.removed_messages.len() > 0);
1375
1376        // Verify the placeholder was inserted
1377        let has_seg = messages.iter().any(|m| is_summary_segment(m));
1378        assert!(has_seg, "Should have a summary segment placeholder");
1379    }
1380
1381    #[test]
1382    fn test_progressive_disabled_falls_back_to_legacy() {
1383        let mut messages = vec![make_system_message("sys")];
1384        // 20 rounds of large content — definitely over threshold
1385        for i in 0..20 {
1386            let content = format!("Round {}: {}", i, "x".repeat(2000));
1387            messages.push(make_user_message(&content));
1388        }
1389
1390        let mut config = make_test_config();
1391        config.progressive_compression = Some(false);
1392        config.min_keep_rounds = Some(3);
1393        config.compression_token_threshold = Some(100);
1394
1395        let manager = ContextManager::new(Some(8000), Some(&config));
1396        let result = manager.maybe_truncate(&mut messages);
1397
1398        // Legacy mode removes as many rounds as needed to go below threshold,
1399        // which for 20 rounds of 2000 chars in an 8000-token window is more than 3.
1400        assert!(result.rounds_removed > 3,
1401            "Legacy should remove more than rounds_per_summary (3) rounds, removed {}",
1402            result.rounds_removed);
1403        // And the action type should be NewSegment (legacy single summary)
1404        assert!(matches!(result.action, TruncationAction::NewSegment));
1405    }
1406
1407    #[test]
1408    fn test_progressive_merge_segments() {
1409        // Build history where full rounds are within budget (fewer than min_keep + rounds_per_summary)
1410        // but we have too many summary segments, forcing a merge.
1411        let mut messages = vec![make_system_message("sys")];
1412        // 6 summary segments at level 0 — exceeds max of 5, each large enough to matter
1413        for i in 0..6 {
1414            let content = format!("Summary {}: {}", i, "x".repeat(500));
1415            messages.push(make_summary_segment(&content, 0));
1416        }
1417        // Only 2 full user messages — less than min_keep, so NewSegment won't trigger
1418        for i in 0..2 {
1419            let content = format!("User {}: {}", i, "x".repeat(300));
1420            messages.push(make_user_message(&content));
1421        }
1422
1423        let mut config = make_test_config();
1424        config.max_summary_segments = Some(5);
1425        config.merge_count = Some(2);
1426        config.min_keep_rounds = Some(3);
1427        config.rounds_per_summary = Some(3);
1428        config.compression_token_threshold = Some(10);
1429
1430        // Tiny context window to force over-threshold
1431        let manager = ContextManager::new(Some(2000), Some(&config));
1432
1433        // First check: confirm we're over threshold
1434        let estimated = estimate_messages_tokens_with_margin(&messages, 1.3);
1435        assert!(estimated > manager.truncation_threshold(),
1436            "Test setup error: should be over threshold, est={}, threshold={}",
1437            estimated, manager.truncation_threshold());
1438
1439        let result = manager.maybe_truncate(&mut messages);
1440
1441        // With full rounds < min_keep + rounds_per_summary, and segments > max,
1442        // try_new_segment returns None, try_merge_segments should run
1443        match &result.action {
1444            TruncationAction::MergeSegments { summaries, start_position, count } => {
1445                assert_eq!(*count, 2, "Should merge 2 segments");
1446                assert_eq!(summaries.len(), 2);
1447                assert!(*start_position >= 1, "Start after system message");
1448            }
1449            other => {
1450                panic!("Expected MergeSegments, got {:?}", other);
1451            }
1452        }
1453
1454        // After merge: 6 - 2 + 1 = 5 segments (including the placeholder)
1455        let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1456        assert_eq!(seg_count, 5, "Should have 5 segments after merge");
1457    }
1458
1459    #[test]
1460    fn test_progressive_discard_after_merge_limit() {
1461        // 6 summary segments at merge level 2 (at the max), plus some full rounds
1462        let mut messages = vec![make_system_message("sys")];
1463        for i in 0..6 {
1464            messages.push(make_summary_segment(&format!("Old summary {}", i), 2));
1465        }
1466        for i in 0..5 {
1467            let content = format!("User {}: {}", i, "x".repeat(2000));
1468            messages.push(make_user_message(&content));
1469        }
1470
1471        let mut config = make_test_config();
1472        config.max_summary_segments = Some(5);
1473        config.max_merges_per_segment = Some(2);
1474        config.merge_count = Some(2);
1475
1476        let manager = ContextManager::new(Some(8000), Some(&config));
1477
1478        // First call may do NewSegment, so call a few times to reach discard
1479        let mut did_discard = false;
1480        for _ in 0..5 {
1481            let result = manager.maybe_truncate(&mut messages);
1482            if result.rounds_removed == 0 && result.messages_removed > 0 && !result.needs_compression {
1483                // Likely a discard
1484                if find_discard_notice_pos(&messages).is_some() {
1485                    did_discard = true;
1486                    break;
1487                }
1488            }
1489        }
1490
1491        // Verify segment count went down or discard notice appeared
1492        let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1493        assert!(
1494            seg_count <= 6,
1495            "Segment count should decrease or stay same, got {}",
1496            seg_count
1497        );
1498
1499        // Just verify no panics and something happened
1500        let _ = did_discard;
1501    }
1502
1503    #[test]
1504    fn test_legacy_notice_recognized_as_summary_segment() {
1505        let mut messages = vec![
1506            make_system_message("sys"),
1507            make_legacy_notice("[Old compressed context notice]"),
1508        ];
1509        // Add several full rounds to push over threshold
1510        for i in 0..8 {
1511            let content = format!("User {}: {}", i, "x".repeat(2000));
1512            messages.push(make_user_message(&content));
1513        }
1514
1515        let mut config = make_test_config();
1516        config.min_keep_rounds = Some(3);
1517        config.max_summary_segments = Some(5);
1518        config.compression_token_threshold = Some(100);
1519
1520        let manager = ContextManager::new(Some(8000), Some(&config));
1521        let result = manager.maybe_truncate(&mut messages);
1522
1523        // Should not crash; legacy notice is treated as a summary segment
1524        assert!(result.messages_removed > 0 || result.rounds_removed > 0);
1525    }
1526
1527    #[test]
1528    fn test_discard_notice_inserted_once() {
1529        let mut messages = vec![
1530            make_system_message("sys"),
1531            make_summary_segment("old seg 1", 2),
1532            make_summary_segment("old seg 2", 2),
1533            make_summary_segment("old seg 3", 2),
1534            make_summary_segment("old seg 4", 2),
1535            make_summary_segment("old seg 5", 2),
1536            make_summary_segment("old seg 6", 2),
1537        ];
1538        for i in 0..4 {
1539            let content = format!("User {}: {}", i, "x".repeat(2000));
1540            messages.push(make_user_message(&content));
1541        }
1542
1543        let mut config = make_test_config();
1544        config.max_summary_segments = Some(5);
1545        config.max_merges_per_segment = Some(2);
1546
1547        let manager = ContextManager::new(Some(6000), Some(&config));
1548
1549        // Trigger a few discards
1550        for _ in 0..3 {
1551            let _ = manager.maybe_truncate(&mut messages);
1552        }
1553
1554        // Count discard notices — should be at most 1
1555        let discard_count = messages.iter().filter(|m| is_discard_notice(m)).count();
1556        assert!(
1557            discard_count <= 1,
1558            "Should have at most 1 discard notice, found {}",
1559            discard_count
1560        );
1561    }
1562
1563    #[test]
1564    fn test_multiple_progressive_rounds_gradual() {
1565        // Build a long history and verify compression happens gradually
1566        let mut messages = vec![make_system_message("sys")];
1567        for i in 0..20 {
1568            let content = format!("Round {} user message: {}", i, "x".repeat(1500));
1569            messages.push(make_user_message(&content));
1570        }
1571
1572        let mut config = make_test_config();
1573        config.min_keep_rounds = Some(3);
1574        config.rounds_per_summary = Some(3);
1575        config.max_summary_segments = Some(4);
1576        config.compression_token_threshold = Some(100);
1577
1578        let manager = ContextManager::new(Some(10000), Some(&config));
1579
1580        let mut seg_count_before = 0;
1581        let mut did_new_segment = false;
1582        let mut did_merge = false;
1583
1584        for round in 0..10 {
1585            let result = manager.maybe_truncate(&mut messages);
1586
1587            let seg_count = messages.iter().filter(|m| is_summary_segment(m)).count();
1588
1589            match &result.action {
1590                TruncationAction::NewSegment => {
1591                    did_new_segment = true;
1592                    assert_eq!(result.rounds_removed, 3);
1593                    assert!(seg_count > seg_count_before || seg_count_before == 0);
1594                }
1595                TruncationAction::MergeSegments { .. } => {
1596                    did_merge = true;
1597                    assert!(seg_count <= seg_count_before);
1598                }
1599                TruncationAction::TruncateOnly => {
1600                    // Could be discard or nothing
1601                }
1602            }
1603
1604            seg_count_before = seg_count;
1605
1606            let estimated = estimate_messages_tokens_with_margin(&messages, 1.3);
1607            if estimated <= manager.truncation_threshold() {
1608                break;
1609            }
1610
1611            tracing::debug!("Round {}: segments={}, estimated={}", round, seg_count, estimated);
1612        }
1613
1614        // With 20 rounds and a small window, we should see at least new segments
1615        assert!(did_new_segment, "Should have created at least one new summary segment");
1616        let _ = did_merge;
1617    }
1618}