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