1use async_openai::types::chat::{
4 ChatCompletionRequestMessage, ChatCompletionRequestUserMessage,
5 ChatCompletionRequestUserMessageContent,
6};
7use robit_ai::config::ContextConfig;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum TruncationAction {
16 NewSegment,
19 MergeSegments {
24 summaries: Vec<String>,
25 start_position: usize,
26 count: usize,
27 },
28 TruncateOnly,
31}
32
33#[derive(Debug)]
35pub struct TruncationResult {
36 pub rounds_removed: usize,
38 pub messages_removed: usize,
40 pub removed_messages: Vec<ChatCompletionRequestMessage>,
42 pub insert_position: usize,
44 pub needs_compression: bool,
46 pub action: TruncationAction,
48}
49
50pub 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 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
106pub 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; 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 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 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; ascii_tokens + cjk_tokens + whitespace_tokens + other_tokens
166}
167
168pub fn estimate_messages_tokens(messages: &[ChatCompletionRequestMessage]) -> usize {
170 let mut total = 0;
171 for msg in messages {
172 total += 4;
174 total += estimate_message_content_tokens(msg);
175 }
176 total
177}
178
179pub 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
188fn estimate_message_content_tokens(msg: &ChatCompletionRequestMessage) -> usize {
190 use async_openai::types::chat::ChatCompletionRequestUserMessageContentPart;
191
192 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 total += 2000;
210 }
211 _ => {
212 }
214 }
215 }
216 return total;
217 }
218 }
219
220 match serde_json::to_string(msg) {
222 Ok(json) => estimate_tokens(&json),
223 Err(_) => 0,
224 }
225}
226
227pub struct ContextManager {
233 pub max_tokens: usize,
235 pub reserve_ratio: f32,
237 pub truncation_ratio: f32,
239 pub min_keep_rounds: usize,
241 pub token_safety_margin: f32,
243 pub max_output_lines: usize,
245 pub max_output_bytes: usize,
247 pub compression_token_threshold: usize,
249 pub compression_enabled: bool,
251 pub max_tool_calls_per_turn: usize,
253 pub progressive_compression: bool,
255 pub rounds_per_summary: usize,
257 pub max_summary_segments: usize,
259 pub merge_count: usize,
261 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 pub fn truncation_threshold(&self) -> usize {
327 (self.max_tokens as f32 * self.truncation_ratio) as usize
328 }
329
330 pub fn available_tokens(&self) -> usize {
334 (self.max_tokens as f32 * (1.0 - self.reserve_ratio)) as usize
335 }
336
337 pub fn truncate_tool_output(&self, content: &str) -> String {
339 truncate_output(content, self.max_output_lines, self.max_output_bytes)
340 }
341
342 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 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 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 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 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 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 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 tracing::warn!("All progressive actions exhausted, falling back to legacy truncation");
457 self.legacy_truncate(messages, threshold)
458 }
459
460 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 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 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 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 messages.drain(start_idx..end_idx);
514
515 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 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 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 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 messages.drain(start_pos..end_pos);
588
589 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 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 return None;
629 }
630
631 let pos = oldest.index;
633 messages.remove(pos);
634
635 tracing::info!("Discarded oldest summary segment at position {}", pos);
636
637 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 fn legacy_truncate(
660 &self,
661 messages: &mut Vec<ChatCompletionRequestMessage>,
662 threshold: usize,
663 ) -> TruncationResult {
664 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 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
792const SUMMARY_SEGMENT_PREFIX: &str = "summary_segment";
797const DISCARD_NOTICE_NAME: &str = "discard_notice";
798const LEGACY_NOTICE_NAME: &str = "system_notice";
799
800fn user_message_name(msg: &ChatCompletionRequestMessage) -> Option<&str> {
802 match msg {
803 ChatCompletionRequestMessage::User(u) => u.name.as_deref(),
804 _ => None,
805 }
806}
807
808fn 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
826pub 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
836pub 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
859fn is_discard_notice(msg: &ChatCompletionRequestMessage) -> bool {
861 matches!(user_message_name(msg), Some(name) if name == DISCARD_NOTICE_NAME)
862}
863
864fn 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
873fn 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
888fn 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
903fn 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#[derive(Debug, Clone)]
917struct SummarySegmentInfo {
918 index: usize,
919 merge_level: usize,
920 content: String,
921}
922
923fn 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
938fn find_discard_notice_pos(messages: &[ChatCompletionRequestMessage]) -> Option<usize> {
940 messages.iter().position(is_discard_notice)
941}
942
943pub 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
1015fn 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#[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 #[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 let expected = (raw as f32 * 1.3).ceil() as usize;
1159 assert_eq!(with_margin, expected);
1160 }
1161
1162 #[test]
1167 fn test_truncation_threshold() {
1168 let config = make_test_config();
1169 let manager = ContextManager::new(Some(65536), Some(&config));
1170 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 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); let manager = ContextManager::new(Some(5000), Some(&config));
1206 let result = manager.maybe_truncate(&mut messages, None, 0);
1207
1208 assert!(
1210 result.rounds_removed > 0,
1211 "Should have removed some rounds"
1212 );
1213 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 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 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 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 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 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 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 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 #[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 assert!(transcript.len() < long_text.len() + 50);
1385 }
1386
1387 #[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 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); 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 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 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 assert!(result.rounds_removed > 3,
1477 "Legacy should remove more than rounds_per_summary (3) rounds, removed {}",
1478 result.rounds_removed);
1479 assert!(matches!(result.action, TruncationAction::NewSegment));
1481 }
1482
1483 #[test]
1484 fn test_progressive_merge_segments() {
1485 let mut messages = vec![make_system_message("sys")];
1488 for i in 0..6 {
1490 let content = format!("Summary {}: {}", i, "x".repeat(500));
1491 messages.push(make_summary_segment(&content, 0));
1492 }
1493 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 let manager = ContextManager::new(Some(2000), Some(&config));
1508
1509 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 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 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 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 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 if find_discard_notice_pos(&messages).is_some() {
1561 did_discard = true;
1562 break;
1563 }
1564 }
1565 }
1566
1567 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 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 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 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 for _ in 0..3 {
1627 let _ = manager.maybe_truncate(&mut messages, None, 0);
1628 }
1629
1630 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 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 }
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 assert!(did_new_segment, "Should have created at least one new summary segment");
1692 let _ = did_merge;
1693 }
1694
1695 #[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 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 let result = manager.estimate_context_tokens(&messages, Some(100), 2);
1725 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 messages.push(make_user_message("world"));
1741 let result = manager.estimate_context_tokens(&messages, Some(100), 2);
1742
1743 let new_msg_tokens = estimate_messages_tokens(&messages[2..]);
1745 assert_eq!(result, 100 + new_msg_tokens);
1746 let heuristic = estimate_messages_tokens_with_margin(&messages, 1.3);
1749 assert!(new_msg_tokens > 0, "New message should have non-zero token estimate");
1752 let _ = heuristic; }
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 let result = manager.estimate_context_tokens(&messages, Some(100), 5);
1766 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 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 let manager = ContextManager::new(Some(3000), Some(&config));
1785
1786 let mut msgs_no_cal = messages.clone();
1788 let result_no_cal = manager.maybe_truncate(&mut msgs_no_cal, None, 0);
1789
1790 let mut msgs_cal = messages.clone();
1792 let result_cal = manager.maybe_truncate(&mut msgs_cal, Some(500), messages.len());
1793
1794 assert_eq!(result_cal.rounds_removed, 0,
1796 "Calibrated estimation should not trigger premature truncation");
1797
1798 tracing::debug!(
1800 "no_cal: rounds_removed={}, cal: rounds_removed={}",
1801 result_no_cal.rounds_removed, result_cal.rounds_removed
1802 );
1803 }
1804}