Skip to main content

robit_agent/
context.rs

1//! Context management — output truncation and history window management.
2
3use async_openai::types::chat::ChatCompletionRequestMessage;
4use robit_ai::config::ContextConfig;
5
6// ============================================================================
7// Truncation result
8// ============================================================================
9
10/// Result of context truncation, used for async compression.
11#[derive(Debug)]
12pub struct TruncationResult {
13    /// Number of conversation rounds removed.
14    pub rounds_removed: usize,
15    /// Number of individual messages removed.
16    pub messages_removed: usize,
17    /// The removed messages (for generating summary).
18    pub removed_messages: Vec<ChatCompletionRequestMessage>,
19    /// Position where summary should be inserted.
20    pub insert_position: usize,
21    /// Whether compression is needed (token count exceeds threshold).
22    pub needs_compression: bool,
23}
24
25// ============================================================================
26// Tool output truncation (Layer 1)
27// ============================================================================
28
29/// Truncate tool output based on line count and byte limits.
30pub fn truncate_output(content: &str, max_lines: usize, max_bytes: usize) -> String {
31    let lines: Vec<&str> = content.lines().collect();
32    let total_lines = lines.len();
33    let total_bytes = content.len();
34
35    // Check if truncation is needed
36    let line_truncated = total_lines > max_lines;
37    let byte_truncated = total_bytes > max_bytes;
38
39    if !line_truncated && !byte_truncated {
40        return content.to_string();
41    }
42
43    let mut output = String::new();
44    let mut byte_count = 0;
45    let mut displayed_lines = 0;
46
47    for (i, line) in lines.iter().enumerate() {
48        if i >= max_lines {
49            break;
50        }
51        let line_with_newline = if i < total_lines - 1 {
52            format!("{}\n", line)
53        } else {
54            line.to_string()
55        };
56
57        if byte_count + line_with_newline.len() > max_bytes {
58            break;
59        }
60
61        output.push_str(&line_with_newline);
62        byte_count += line_with_newline.len();
63        displayed_lines += 1;
64    }
65
66    if line_truncated {
67        output.push_str(&format!(
68            "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)",
69            total_lines, displayed_lines
70        ));
71    } else if byte_truncated {
72        output.push_str(&format!(
73            "\n... (Output truncated, {} bytes total, showing first {} bytes)",
74            total_bytes, byte_count
75        ));
76    }
77
78    output
79}
80
81// ============================================================================
82// Token estimation
83// ============================================================================
84
85/// Estimate token count for a string.
86///
87/// Uses a more nuanced heuristic based on character type:
88/// - ASCII letters/digits: ~3.5 chars/token (BPE tokenizer average)
89/// - CJK characters: ~1.5 chars/token (most CJK chars are 1-2 tokens each)
90/// - Whitespace: minimal token cost (usually merged with adjacent tokens)
91/// - Punctuation/symbols: ~1 token per char (often individual tokens)
92/// - Code (braces, operators): ~1 token per char
93///
94/// This is still an estimate; apply `token_safety_margin` at the message level.
95pub fn estimate_tokens(text: &str) -> usize {
96    if text.is_empty() {
97        return 0;
98    }
99
100    let mut ascii_alnum = 0usize;
101    let mut cjk = 0usize;
102    let mut whitespace = 0usize;
103    let mut other = 0usize; // punctuation, symbols, code characters
104
105    for ch in text.chars() {
106        if ch.is_whitespace() {
107            whitespace += 1;
108        } else if ch.is_ascii_alphanumeric() {
109            ascii_alnum += 1;
110        } else {
111            let cp = ch as u32;
112            // CJK Unified Ideographs + extensions + fullwidth forms
113            // + Hiragana, Katakana, Hangul, CJK punctuation
114            if (0x4E00..=0x9FFF).contains(&cp)
115                || (0x3400..=0x4DBF).contains(&cp)
116                || (0xF900..=0xFAFF).contains(&cp)
117                || (0xFF00..=0xFFEF).contains(&cp)
118                || (0x3000..=0x303F).contains(&cp)
119                || (0x3040..=0x309F).contains(&cp)
120                || (0x30A0..=0x30FF).contains(&cp)
121                || (0xAC00..=0xD7AF).contains(&cp)
122            {
123                cjk += 1;
124            } else {
125                other += 1;
126            }
127        }
128    }
129
130    // BPE tokenizer averages:
131    // - ASCII alphanumeric: ~3.5 chars per token
132    // - CJK: ~1.5 chars per token (most are 1 token each, some pairs)
133    // - Whitespace: negligible (merged with adjacent tokens)
134    // - Other (punctuation/code): ~1 char per token
135    let ascii_tokens = (ascii_alnum as f64 / 3.5).ceil() as usize;
136    let cjk_tokens = (cjk as f64 / 1.5).ceil() as usize;
137    let whitespace_tokens = (whitespace as f64 / 10.0).ceil() as usize;
138    let other_tokens = other; // ~1:1
139
140    ascii_tokens + cjk_tokens + whitespace_tokens + other_tokens
141}
142
143/// Estimate tokens for a list of messages.
144pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
145    let mut total = 0;
146    for msg in messages {
147        // Each message has ~4 tokens of overhead (role, delimiters)
148        total += 4;
149        total += estimate_message_content_tokens(msg);
150    }
151    total
152}
153
154/// Estimate tokens for messages, applying the configured safety margin.
155pub fn estimate_messages_tokens_with_margin(
156    messages: &[ChatCompletionRequestMessage],
157    safety_margin: f32,
158) -> usize {
159    let raw = estimate_messages_tokens(messages);
160    (raw as f32 * safety_margin).ceil() as usize
161}
162
163/// Estimate tokens for a single message's content.
164fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
165    match serde_json::to_string(msg) {
166        Ok(json) => estimate_tokens(&json),
167        Err(_) => 0,
168    }
169}
170
171// ============================================================================
172// Context manager (Layer 2: history truncation)
173// ============================================================================
174
175/// Manages the context window, truncating history when approaching token limits.
176pub struct ContextManager {
177    /// Model's context window size in tokens.
178    pub max_tokens: usize,
179    /// Ratio of context window to reserve for LLM response (default 0.2 = 20%).
180    pub reserve_ratio: f32,
181    /// Fraction of max_tokens at which truncation triggers (default 0.7).
182    pub truncation_ratio: f32,
183    /// Minimum conversation rounds to keep after truncation (default 3).
184    pub min_keep_rounds: usize,
185    /// Safety multiplier for token estimates (default 1.3).
186    pub token_safety_margin: f32,
187    /// Max output lines for tool results.
188    pub max_output_lines: usize,
189    /// Max output bytes for tool results.
190    pub max_output_bytes: usize,
191    /// Token threshold for triggering compression.
192    pub compression_token_threshold: usize,
193    /// Whether compression is enabled.
194    pub compression_enabled: bool,
195    /// Maximum tool calls per turn (default 30).
196    pub max_tool_calls_per_turn: usize,
197}
198
199impl ContextManager {
200    pub fn new(context_window: Option<u64>, config: Option<&ContextConfig>) -> Self {
201        let max_tokens = context_window.unwrap_or(65536) as usize;
202
203        let (
204            max_output_lines,
205            max_output_bytes,
206            reserve_ratio,
207            truncation_ratio,
208            min_keep_rounds,
209            token_safety_margin,
210            compression_token_threshold,
211            compression_enabled,
212            max_tool_calls_per_turn,
213        ) = match config {
214            Some(c) => (
215                c.max_output_lines.unwrap_or(500),
216                c.max_output_bytes.unwrap_or(51200),
217                c.reserve_ratio.unwrap_or(0.2),
218                c.truncation_ratio.unwrap_or(0.7),
219                c.min_keep_rounds.unwrap_or(3),
220                c.token_safety_margin.unwrap_or(1.3),
221                c.compression_token_threshold.unwrap_or(5000),
222                c.compression_enabled.unwrap_or(true),
223                c.max_tool_calls_per_turn.unwrap_or(30),
224            ),
225            None => (500, 51200, 0.2, 0.7, 3, 1.3, 5000, true, 30),
226        };
227
228        Self {
229            max_tokens,
230            reserve_ratio,
231            truncation_ratio,
232            min_keep_rounds,
233            token_safety_margin,
234            max_output_lines,
235            max_output_bytes,
236            compression_token_threshold,
237            compression_enabled,
238            max_tool_calls_per_turn,
239        }
240    }
241
242    /// Maximum tokens at which truncation is triggered.
243    /// Uses `truncation_ratio` (default 0.7) to trigger earlier than the
244    /// absolute limit, leaving headroom for estimation errors and LLM response.
245    pub fn truncation_threshold(&self) -> usize {
246        (self.max_tokens as f32 * self.truncation_ratio) as usize
247    }
248
249    /// Maximum tokens available for input (total - reserved for response).
250    /// Note: this is the absolute upper bound; truncation actually triggers
251    /// earlier via `truncation_threshold()`.
252    pub fn available_tokens(&self) -> usize {
253        (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
254    }
255
256    /// Truncate tool output using the configured limits.
257    pub fn truncate_tool_output(&self, content: &str) -> String {
258        truncate_output(content, self.max_output_lines, self.max_output_bytes)
259    }
260
261    /// Check if history needs truncation and perform it if necessary.
262    /// Returns `TruncationResult` with removed messages for async compression.
263    ///
264    /// Strategy:
265    /// 1. Uses `truncation_threshold()` (default 70% of max_tokens) as trigger point
266    /// 2. Removes oldest non-system rounds first
267    /// 3. Always keeps at least `min_keep_rounds` recent rounds
268    /// 4. Applies `token_safety_margin` to estimates to avoid underestimation
269    pub fn maybe_truncate(
270        &self,
271        messages: &mut Vec<ChatCompletionRequestMessage>,
272    ) -> TruncationResult {
273        let estimated = estimate_messages_tokens_with_margin(messages, self.token_safety_margin);
274        let threshold = self.truncation_threshold();
275
276        if estimated <= threshold {
277            return TruncationResult {
278                rounds_removed: 0,
279                messages_removed: 0,
280                removed_messages: Vec::new(),
281                insert_position: 0,
282                needs_compression: false,
283            };
284        }
285
286        tracing::info!(
287            "Context truncation needed: estimated {} tokens (with {:.1}x margin), threshold {} tokens, max {} tokens",
288            estimated,
289            self.token_safety_margin,
290            threshold,
291            self.max_tokens
292        );
293
294        // Find round boundaries: a round starts with a User message
295        let mut round_starts: Vec<usize> = Vec::new();
296        for (i, msg) in messages.iter().enumerate() {
297            if is_user_message(msg) {
298                round_starts.push(i);
299            }
300        }
301
302        if round_starts.is_empty() {
303            return TruncationResult {
304                rounds_removed: 0,
305                messages_removed: 0,
306                removed_messages: Vec::new(),
307                insert_position: 0,
308                needs_compression: false,
309            };
310        }
311
312        // Count how many rounds we must keep
313        let total_rounds = round_starts.len();
314        let must_keep = self.min_keep_rounds.min(total_rounds);
315
316        let mut removed_messages: Vec<ChatCompletionRequestMessage> = Vec::new();
317        let mut rounds_removed = 0;
318        let mut messages_removed = 0;
319
320        // Remove oldest rounds while:
321        // - estimated tokens still exceed threshold
322        // - we still have more rounds than must_keep
323        while round_starts.len() > must_keep
324            && estimate_messages_tokens_with_margin(messages, self.token_safety_margin) > threshold
325        {
326            let start_idx = round_starts[0];
327            let end_idx = if round_starts.len() > 1 {
328                round_starts[1]
329            } else {
330                messages.len()
331            };
332
333            // Collect removed messages for potential compression
334            if self.compression_enabled {
335                removed_messages.extend(messages[start_idx..end_idx].to_vec());
336            }
337
338            let count = end_idx - start_idx;
339            messages.drain(start_idx..end_idx);
340
341            // Update round_starts indices
342            round_starts.remove(0);
343            for idx in round_starts.iter_mut() {
344                *idx = idx.saturating_sub(count);
345            }
346
347            rounds_removed += 1;
348            messages_removed += count;
349        }
350
351        if rounds_removed == 0 {
352            return TruncationResult {
353                rounds_removed: 0,
354                messages_removed: 0,
355                removed_messages: Vec::new(),
356                insert_position: 0,
357                needs_compression: false,
358            };
359        }
360
361        // Calculate removed tokens for threshold check
362        let removed_tokens = estimate_messages_tokens(&removed_messages);
363        let needs_compression =
364            self.compression_enabled && removed_tokens >= self.compression_token_threshold;
365
366        // Insert informative notice after system messages
367        let system_msg_count = messages
368            .iter()
369            .take_while(|m| is_system_message(m))
370            .count();
371
372        let notice = if needs_compression {
373            format!(
374                "[Context compressed: {} earlier rounds ({} messages, ~{} tokens) have been summarized. {} most recent rounds preserved.]",
375                rounds_removed,
376                messages_removed,
377                removed_tokens,
378                round_starts.len()
379            )
380        } else {
381            format!(
382                "[Context truncated: {} earlier rounds ({} messages) removed to stay within token limit. {} most recent rounds preserved.]",
383                rounds_removed,
384                messages_removed,
385                round_starts.len()
386            )
387        };
388
389        let notice_msg = ChatCompletionRequestMessage::User(
390            async_openai::types::chat::ChatCompletionRequestUserMessage {
391                content: notice.into(),
392                name: Some("system_notice".to_string()),
393            }
394            .into(),
395        );
396
397        messages.insert(system_msg_count, notice_msg);
398
399        tracing::info!(
400            "Context truncation: removed {} rounds ({} messages, ~{} tokens), kept {} rounds, needs_compression={}",
401            rounds_removed,
402            messages_removed,
403            removed_tokens,
404            round_starts.len(),
405            needs_compression
406        );
407
408        TruncationResult {
409            rounds_removed,
410            messages_removed,
411            removed_messages,
412            insert_position: system_msg_count,
413            needs_compression,
414        }
415    }
416}
417
418fn is_user_message(msg: &ChatCompletionRequestMessage) -> bool {
419    matches!(msg, ChatCompletionRequestMessage::User(_))
420}
421
422fn is_system_message(msg: &ChatCompletionRequestMessage) -> bool {
423    matches!(msg, ChatCompletionRequestMessage::System(_))
424}
425
426// ============================================================================
427// Transcript formatting for summary compression
428// ============================================================================
429
430/// Format removed messages into a compact transcript for summary generation.
431/// Extracts user messages, assistant text, and tool call names only.
432/// Truncates each message to keep the transcript concise.
433pub fn format_removed_messages_as_transcript(
434    messages: &[ChatCompletionRequestMessage],
435) -> String {
436    let mut transcript = String::new();
437
438    for msg in messages {
439        match msg {
440            ChatCompletionRequestMessage::User(user_msg) => {
441                let text = match &user_msg.content {
442                    async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) => {
443                        t.clone()
444                    }
445                    async_openai::types::chat::ChatCompletionRequestUserMessageContent::Array(parts) => {
446                        parts.iter()
447                            .filter_map(|p| match p {
448                                async_openai::types::chat::ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text.as_str()),
449                                _ => None,
450                            })
451                            .collect::<Vec<_>>()
452                            .join(" ")
453                    }
454                };
455                let truncated = truncate_str(&text, 200);
456                transcript.push_str(&format!("User: {}\n", truncated));
457            }
458            ChatCompletionRequestMessage::Assistant(assistant_msg) => {
459                let content_str = assistant_msg.content.as_ref().map(|c| {
460                    match serde_json::to_string(c) {
461                        Ok(json) => json.trim_matches('"').to_string(),
462                        Err(_) => format!("{:?}", c),
463                    }
464                }).unwrap_or_default();
465                let truncated = truncate_str(&content_str, 300);
466                transcript.push_str(&format!("Assistant: {}\n", truncated));
467                if let Some(tool_calls) = &assistant_msg.tool_calls {
468                    for tc in tool_calls {
469                        if let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(f) = tc {
470                            transcript.push_str(&format!(
471                                "  [Tool: {}({})]\n",
472                                f.function.name,
473                                truncate_str(&f.function.arguments, 100)
474                            ));
475                        }
476                    }
477                }
478            }
479            ChatCompletionRequestMessage::Tool(tool_msg) => {
480                let content_str = match serde_json::to_string(&tool_msg.content) {
481                    Ok(json) => json.trim_matches('"').to_string(),
482                    Err(_) => format!("{:?}", tool_msg.content),
483                };
484                let truncated = truncate_str(&content_str, 150);
485                transcript.push_str(&format!("  [Result: {}]\n", truncated));
486            }
487            _ => {}
488        }
489    }
490
491    if transcript.is_empty() {
492        transcript.push_str("(no conversation content)");
493    }
494
495    transcript
496}
497
498/// Truncate a string to at most `max_chars` characters, adding "..." if truncated.
499/// Respects UTF-8 character boundaries.
500fn truncate_str(s: &str, max_chars: usize) -> String {
501    if s.len() <= max_chars {
502        s.to_string()
503    } else {
504        let mut end = max_chars;
505        while end > 0 && !s.is_char_boundary(end) {
506            end -= 1;
507        }
508        format!("{}...", &s[..end])
509    }
510}
511
512// ============================================================================
513// Tests
514// ============================================================================
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use async_openai::types::chat::ChatCompletionRequestUserMessage;
520
521    fn make_user_message(content: &str) -> ChatCompletionRequestMessage {
522        ChatCompletionRequestMessage::User(
523            ChatCompletionRequestUserMessage {
524                content: content.into(),
525                name: None,
526            }
527            .into(),
528        )
529    }
530
531    fn make_system_message(content: &str) -> ChatCompletionRequestMessage {
532        ChatCompletionRequestMessage::System(
533            async_openai::types::chat::ChatCompletionRequestSystemMessage {
534                content: content.into(),
535                name: None,
536            }
537            .into(),
538        )
539    }
540
541    fn make_test_config() -> ContextConfig {
542        ContextConfig {
543            max_output_lines: Some(500),
544            max_output_bytes: Some(51200),
545            reserve_ratio: Some(0.2),
546            truncation_ratio: Some(0.7),
547            min_keep_rounds: Some(3),
548            token_safety_margin: Some(1.3),
549            compression_token_threshold: Some(5000),
550            compression_enabled: Some(true),
551            max_tool_calls_per_turn: Some(30),
552        }
553    }
554
555    // ==========================================================================
556    // estimate_tokens tests
557    // ==========================================================================
558
559    #[test]
560    fn test_estimate_tokens_english() {
561        let text = "Hello world, this is a test of the token estimation system.";
562        let tokens = estimate_tokens(text);
563        assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
564        assert!(tokens <= 30, "Expected at most 30 tokens, got {}", tokens);
565    }
566
567    #[test]
568    fn test_estimate_tokens_chinese() {
569        let chinese = "你好世界,这是一个测试。";
570        let tokens = estimate_tokens(chinese);
571        assert!(tokens >= 5, "Expected at least 5 tokens, got {}", tokens);
572        assert!(tokens <= 15, "Expected at most 15 tokens, got {}", tokens);
573    }
574
575    #[test]
576    fn test_estimate_tokens_code() {
577        let code = "fn main() {\n    println!(\"Hello\");\n}";
578        let tokens = estimate_tokens(code);
579        assert!(tokens >= 10, "Expected at least 10 tokens, got {}", tokens);
580        assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
581    }
582
583    #[test]
584    fn test_estimate_tokens_empty() {
585        assert_eq!(estimate_tokens(""), 0);
586    }
587
588    #[test]
589    fn test_estimate_tokens_mixed() {
590        let mixed = "Hello 你好 world 世界!fn test() {}";
591        let tokens = estimate_tokens(mixed);
592        assert!(tokens > 0);
593        assert!(tokens <= 40, "Expected at most 40 tokens, got {}", tokens);
594    }
595
596    #[test]
597    fn test_estimate_messages_tokens_with_margin() {
598        let messages = vec![
599            make_system_message("You are a helpful assistant"),
600            make_user_message("Hello world"),
601        ];
602        let raw = estimate_messages_tokens(&messages);
603        let with_margin = estimate_messages_tokens_with_margin(&messages, 1.3);
604        assert!(with_margin > raw);
605        // 1.3x margin should be ~30% higher
606        let expected = (raw as f32 * 1.3).ceil() as usize;
607        assert_eq!(with_margin, expected);
608    }
609
610    // ==========================================================================
611    // ContextManager tests
612    // ==========================================================================
613
614    #[test]
615    fn test_truncation_threshold() {
616        let config = make_test_config();
617        let manager = ContextManager::new(Some(65536), Some(&config));
618        // 65536 * 0.7 = 45875
619        assert_eq!(manager.truncation_threshold(), 45875);
620    }
621
622    #[test]
623    fn test_truncation_result_no_truncation() {
624        let mut messages = vec![
625            make_system_message("You are a helpful assistant"),
626            make_user_message("Hello"),
627        ];
628
629        let config = make_test_config();
630        let manager = ContextManager::new(Some(65536), Some(&config));
631        let result = manager.maybe_truncate(&mut messages);
632
633        assert_eq!(result.rounds_removed, 0);
634        assert!(!result.needs_compression);
635    }
636
637    #[test]
638    fn test_truncation_respects_min_keep_rounds() {
639        let mut messages = vec![
640            make_system_message("You are a helpful assistant"),
641        ];
642
643        // Add 10 rounds of large messages
644        for i in 0..10 {
645            let content = format!("User message {}: {}", i, "x".repeat(2000));
646            messages.push(make_user_message(&content));
647        }
648
649        let mut config = make_test_config();
650        config.min_keep_rounds = Some(3); // Must keep at least 3 rounds
651
652        // Use small context window to force aggressive truncation
653        let manager = ContextManager::new(Some(5000), Some(&config));
654        let result = manager.maybe_truncate(&mut messages);
655
656        // Should have removed some rounds...
657        assert!(
658            result.rounds_removed > 0,
659            "Should have removed some rounds"
660        );
661        // ...but should still have at least 3 user rounds + notice
662        let user_count = messages
663            .iter()
664            .filter(|m| matches!(m, ChatCompletionRequestMessage::User(_)))
665            .count();
666        assert!(
667            user_count >= 4,
668            "Should have at least 3 user rounds + notice, got {}",
669            user_count
670        );
671    }
672
673    #[test]
674    fn test_truncation_early_trigger() {
675        let mut messages = vec![
676            make_system_message("You are a helpful assistant"),
677        ];
678
679        // Add 8 rounds of messages, each ~2000 chars
680        for i in 0..8 {
681            let content = format!("User message {}: {}", i, "x".repeat(2000));
682            messages.push(make_user_message(&content));
683        }
684
685        let mut config = make_test_config();
686        config.truncation_ratio = Some(0.7);
687        config.min_keep_rounds = Some(2);
688        config.token_safety_margin = Some(1.3);
689
690        // With 65536 context, truncation threshold = 45875
691        // 8 rounds * ~2000 chars each ≈ much less than 45875, so no truncation
692        let manager = ContextManager::new(Some(65536), Some(&config));
693        let result = manager.maybe_truncate(&mut messages);
694        assert_eq!(
695            result.rounds_removed, 0,
696            "Should not truncate small messages in large window"
697        );
698
699        // With 8000 context, truncation threshold = 5600
700        let manager2 = ContextManager::new(Some(8000), Some(&config));
701        let mut messages2 = messages.clone();
702        let result2 = manager2.maybe_truncate(&mut messages2);
703        assert!(
704            result2.rounds_removed > 0,
705            "Should truncate when exceeding small window"
706        );
707    }
708
709    #[test]
710    fn test_token_safety_margin_effect() {
711        let mut messages = vec![
712            make_system_message("You are a helpful assistant"),
713        ];
714
715        for i in 0..10 {
716            let content = format!("User message {}: {}", i, "x".repeat(500));
717            messages.push(make_user_message(&content));
718        }
719
720        // With margin 1.0 (no safety), truncation may not trigger
721        let mut config_low = make_test_config();
722        config_low.token_safety_margin = Some(1.0);
723        config_low.truncation_ratio = Some(0.7);
724        config_low.min_keep_rounds = Some(1);
725
726        let mut msgs_low = messages.clone();
727        let manager_low = ContextManager::new(Some(8000), Some(&config_low));
728        let result_low = manager_low.maybe_truncate(&mut msgs_low);
729
730        // With margin 2.0 (very conservative), truncation more likely triggers
731        let mut config_high = make_test_config();
732        config_high.token_safety_margin = Some(2.0);
733        config_high.truncation_ratio = Some(0.7);
734        config_high.min_keep_rounds = Some(1);
735
736        let mut msgs_high = messages.clone();
737        let manager_high = ContextManager::new(Some(8000), Some(&config_high));
738        let result_high = manager_high.maybe_truncate(&mut msgs_high);
739
740        // Higher margin should result in >= rounds removed
741        assert!(
742            result_high.rounds_removed >= result_low.rounds_removed,
743            "Higher safety margin should trigger at least as much truncation: high={}, low={}",
744            result_high.rounds_removed,
745            result_low.rounds_removed
746        );
747    }
748
749    #[test]
750    fn test_compression_flag_in_old_tests() {
751        let mut messages = vec![
752            make_system_message("You are a helpful assistant"),
753        ];
754
755        // Add 20 rounds of large messages
756        for i in 0..20 {
757            let content = format!("User message {}: {}", i, "x".repeat(2000));
758            messages.push(make_user_message(&content));
759        }
760
761        let mut config = make_test_config();
762        config.compression_enabled = Some(false);
763        config.compression_token_threshold = Some(1000);
764        config.min_keep_rounds = Some(1);
765
766        let manager = ContextManager::new(Some(5000), Some(&config));
767        let result = manager.maybe_truncate(&mut messages);
768
769        assert!(result.rounds_removed > 0);
770        assert!(
771            !result.needs_compression,
772            "Should be false when compression disabled"
773        );
774    }
775
776    #[test]
777    fn test_truncate_output() {
778        let content = "line1\nline2\nline3\nline4\nline5";
779        let truncated = truncate_output(content, 3, 100);
780        assert!(truncated.contains("line1"));
781        assert!(truncated.contains("line2"));
782        assert!(truncated.contains("line3"));
783        assert!(!truncated.contains("line4"));
784        assert!(truncated.contains("Output truncated"));
785    }
786
787    // ==========================================================================
788    // Transcript formatting tests
789    // ==========================================================================
790
791    #[test]
792    fn test_truncate_str_no_truncation() {
793        let result = truncate_str("hello", 10);
794        assert_eq!(result, "hello");
795    }
796
797    #[test]
798    fn test_truncate_str_with_truncation() {
799        let result = truncate_str("hello world this is long", 10);
800        assert_eq!(result, "hello worl...");
801    }
802
803    #[test]
804    fn test_format_transcript_user_and_assistant() {
805        let messages = vec![
806            make_user_message("Fix the bug in auth.rs"),
807            make_system_message("System message should be skipped"),
808        ];
809
810        let transcript = format_removed_messages_as_transcript(&messages);
811        assert!(transcript.contains("User: Fix the bug in auth.rs"));
812        assert!(!transcript.contains("System message"), "System messages should be skipped");
813    }
814
815    #[test]
816    fn test_format_transcript_empty() {
817        let messages: Vec<ChatCompletionRequestMessage> = vec![];
818        let transcript = format_removed_messages_as_transcript(&messages);
819        assert!(transcript.contains("no conversation content"));
820    }
821
822    #[test]
823    fn test_format_transcript_truncates_long_messages() {
824        let long_text = "x".repeat(500);
825        let messages = vec![
826            make_user_message(&long_text),
827        ];
828
829        let transcript = format_removed_messages_as_transcript(&messages);
830        assert!(transcript.contains("..."));
831        // Should not contain the full 500 chars
832        assert!(transcript.len() < long_text.len() + 50);
833    }
834}