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