1use crate::high_level::complete;
7use crate::high_level::tokens::estimate as estimate_tokens;
8use crate::{
9 Api, AssistantMessage, ContentBlock, Context, Message, Model, Provider, StreamOptions,
10 TextContent, UserMessage,
11};
12
13fn safe_truncate(s: &str, max_chars: usize) -> String {
15 if s.len() <= max_chars {
16 return s.to_string();
17 }
18 let boundary = s
19 .char_indices()
20 .take_while(|(i, _)| *i <= max_chars)
21 .last()
22 .map(|(i, c)| i + c.len_utf8())
23 .unwrap_or(0);
24 format!("{}...", &s[..boundary])
25}
26use async_trait::async_trait;
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use std::sync::Arc;
30use std::time::Duration;
31
32#[derive(Debug, Clone)]
34pub struct CompactionConfig {
35 pub keep_recent: usize,
37 pub max_batch: usize,
39 pub target_ratio: f32,
41 pub summary_max_tokens: usize,
43 pub temperature: f32,
45 pub timeout: Duration,
47 pub custom_instruction: Option<String>,
49}
50
51impl CompactionConfig {
52 pub fn new() -> Self {
54 Self {
55 keep_recent: 4,
56 max_batch: 20,
57 target_ratio: 0.5,
58 summary_max_tokens: 1024,
59 temperature: 0.3,
60 timeout: Duration::from_secs(60),
61 custom_instruction: None,
62 }
63 }
64
65 pub fn with_keep_recent(mut self, count: usize) -> Self {
67 self.keep_recent = count;
68 self
69 }
70
71 pub fn with_max_batch(mut self, count: usize) -> Self {
73 self.max_batch = count;
74 self
75 }
76
77 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
79 self.target_ratio = ratio.clamp(0.1, 0.9);
80 self
81 }
82
83 pub fn with_summary_max_tokens(mut self, tokens: usize) -> Self {
85 self.summary_max_tokens = tokens;
86 self
87 }
88
89 pub fn with_temperature(mut self, temp: f32) -> Self {
91 self.temperature = temp.clamp(0.0, 1.0);
92 self
93 }
94
95 pub fn with_timeout(mut self, timeout: Duration) -> Self {
97 self.timeout = timeout;
98 self
99 }
100
101 pub fn with_custom_instruction(mut self, instruction: impl Into<String>) -> Self {
103 self.custom_instruction = Some(instruction.into());
104 self
105 }
106}
107
108impl Default for CompactionConfig {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CompactionMetadata {
117 pub original_tokens: usize,
119 pub compacted_tokens: usize,
121 pub messages_compacted: usize,
123 pub messages_kept: usize,
125 pub timestamp: DateTime<Utc>,
127 pub target_ratio: f32,
129 pub actual_ratio: f32,
131 pub success: bool,
133 pub error: Option<String>,
135}
136
137impl CompactionMetadata {
138 pub fn new(
140 original_tokens: usize,
141 compacted_tokens: usize,
142 messages_compacted: usize,
143 messages_kept: usize,
144 target_ratio: f32,
145 ) -> Self {
146 let actual_ratio = if original_tokens > 0 {
147 compacted_tokens as f32 / original_tokens as f32
148 } else {
149 1.0
150 };
151
152 Self {
153 original_tokens,
154 compacted_tokens,
155 messages_compacted,
156 messages_kept,
157 timestamp: Utc::now(),
158 target_ratio,
159 actual_ratio,
160 success: true,
161 error: None,
162 }
163 }
164
165 pub fn failed(
167 original_tokens: usize,
168 messages_compacted: usize,
169 target_ratio: f32,
170 error: impl Into<String>,
171 ) -> Self {
172 Self {
173 original_tokens,
174 compacted_tokens: original_tokens,
175 messages_compacted,
176 messages_kept: 0,
177 timestamp: Utc::now(),
178 target_ratio,
179 actual_ratio: 1.0,
180 success: false,
181 error: Some(error.into()),
182 }
183 }
184
185 pub fn compression_factor(&self) -> f32 {
187 if self.actual_ratio > 0.0 {
188 1.0 - self.actual_ratio
189 } else {
190 0.0
191 }
192 }
193
194 pub fn tokens_saved(&self) -> usize {
196 self.original_tokens.saturating_sub(self.compacted_tokens)
197 }
198}
199
200#[derive(Debug, Clone)]
202pub struct CompactedContext {
203 pub summary: String,
205 pub kept_messages: Vec<Message>,
207 pub compacted_count: usize,
209 pub metadata: CompactionMetadata,
211}
212
213impl CompactedContext {
214 pub fn new(
216 summary: String,
217 kept_messages: Vec<Message>,
218 compacted_count: usize,
219 metadata: CompactionMetadata,
220 ) -> Self {
221 Self {
222 summary,
223 kept_messages,
224 compacted_count,
225 metadata,
226 }
227 }
228
229 pub fn summary(&self) -> &str {
231 &self.summary
232 }
233
234 pub fn kept_count(&self) -> usize {
236 self.kept_messages.len()
237 }
238
239 pub fn compacted_count(&self) -> usize {
241 self.compacted_count
242 }
243
244 pub fn metadata(&self) -> &CompactionMetadata {
246 &self.metadata
247 }
248
249 pub fn is_success(&self) -> bool {
251 self.metadata.success
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
257pub enum CompactionStrategy {
258 Disabled,
260 Threshold(f32),
262 EveryNTurns(usize),
264 AbsoluteTokens(usize),
266}
267
268impl CompactionStrategy {
269 pub fn should_compact(
279 &self,
280 context_tokens: usize,
281 context_window: usize,
282 iteration: usize,
283 ) -> bool {
284 match self {
285 CompactionStrategy::Disabled => false,
286 CompactionStrategy::Threshold(threshold) => {
287 if context_window == 0 {
288 return false;
289 }
290 let usage = context_tokens as f32 / context_window as f32;
291 usage >= *threshold
292 }
293 CompactionStrategy::EveryNTurns(n) => iteration > 0 && iteration.is_multiple_of(*n),
294 CompactionStrategy::AbsoluteTokens(max_tokens) => context_tokens >= *max_tokens,
295 }
296 }
297}
298
299impl Default for CompactionStrategy {
300 fn default() -> Self {
301 CompactionStrategy::Threshold(0.8)
302 }
303}
304
305#[derive(Debug, Clone)]
307pub enum CompactionError {
308 LlmError(String),
310 NoMessagesToCompact,
312 TooFewMessages { total: usize, keep_recent: usize },
314 CompactionDisabled,
316 NoContextWindow,
318}
319
320impl std::fmt::Display for CompactionError {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 match self {
323 CompactionError::LlmError(msg) => write!(f, "LLM compaction failed: {}", msg),
324 CompactionError::NoMessagesToCompact => write!(f, "No messages to compact"),
325 CompactionError::TooFewMessages { total, keep_recent } => {
326 write!(
327 f,
328 "Not enough messages ({}) to compact (need at least {} for keep_recent)",
329 total,
330 keep_recent + 1
331 )
332 }
333 CompactionError::CompactionDisabled => write!(f, "Compaction is disabled"),
334 CompactionError::NoContextWindow => write!(f, "Context window not configured"),
335 }
336 }
337}
338
339impl std::error::Error for CompactionError {}
340
341#[async_trait]
343pub trait Compactor: Send + Sync {
344 async fn compact(
346 &self,
347 messages: &[Message],
348 instruction: Option<&str>,
349 ) -> std::result::Result<CompactedContext, CompactionError>;
350
351 fn estimate_tokens(&self, messages: &[Message]) -> usize {
353 messages
354 .iter()
355 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
356 .sum()
357 }
358}
359
360pub struct LlmCompactor {
362 model: Model,
363 _provider: Arc<dyn Provider>,
364 config: CompactionConfig,
365}
366
367impl LlmCompactor {
368 pub fn new(model: Model, provider: Arc<dyn Provider>) -> Self {
370 Self {
371 model,
372 _provider: provider,
373 config: CompactionConfig::new(),
374 }
375 }
376
377 pub fn with_config(
379 model: Model,
380 provider: Arc<dyn Provider>,
381 config: CompactionConfig,
382 ) -> Self {
383 Self {
384 model,
385 _provider: provider,
386 config,
387 }
388 }
389
390 pub fn with_keep_recent(mut self, count: usize) -> Self {
392 self.config.keep_recent = count;
393 self
394 }
395
396 pub fn with_max_batch(mut self, count: usize) -> Self {
398 self.config.max_batch = count;
399 self
400 }
401
402 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
404 self.config.target_ratio = ratio.clamp(0.1, 0.9);
405 self
406 }
407
408 fn build_summarize_prompt(&self, messages: &[Message], instruction: Option<&str>) -> String {
410 let mut prompt = String::new();
411
412 prompt.push_str("Summarize the following conversation concisely. ");
413 prompt.push_str("Capture the key points, decisions, and any ongoing tasks or context.\n\n");
414
415 if let Some(instr) = instruction {
416 prompt.push_str(&format!("Focus areas: {}\n\n", instr));
417 } else if let Some(ref custom_instr) = self.config.custom_instruction {
418 prompt.push_str(&format!("Focus areas: {}\n\n", custom_instr));
419 }
420
421 prompt.push_str("## Conversation to summarize:\n");
422
423 for (i, msg) in messages.iter().enumerate() {
424 let role = match msg {
425 Message::User(_) => "User",
426 Message::Assistant(_) => "Assistant",
427 Message::ToolResult(_) => "Tool",
428 };
429 let content = msg.text_content().unwrap_or_default();
430 let content_preview = safe_truncate(&content, 500);
431 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
432 }
433
434 prompt.push_str("\n## Summary:\n");
435 prompt
436 .push_str("Provide a concise summary that captures the essence of this conversation.");
437
438 prompt
439 }
440
441 async fn compact_with_fallback(
443 &self,
444 old_messages: &[Message],
445 recent_messages: &[Message],
446 instruction: Option<&str>,
447 ) -> std::result::Result<CompactedContext, CompactionError> {
448 match self.summarize_with_llm(old_messages, instruction).await {
450 Ok(summary) => {
451 let mut summary_msg =
453 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
454 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
455 "[Previous conversation summarized: {}]",
456 summary
457 )))];
458
459 let mut kept = vec![Message::Assistant(summary_msg)];
461 kept.extend(recent_messages.iter().cloned());
462
463 let original_tokens = self.estimate_tokens(old_messages);
464 let compacted_tokens = self.estimate_tokens(&kept);
465 let kept_len = kept.len();
466
467 Ok(CompactedContext::new(
468 summary,
469 kept,
470 old_messages.len(),
471 CompactionMetadata::new(
472 original_tokens,
473 compacted_tokens,
474 old_messages.len(),
475 kept_len,
476 self.config.target_ratio,
477 ),
478 ))
479 }
480 Err(llm_err) => {
481 self.compact_fallback(old_messages, recent_messages)
483 .await
484 .map_err(|_| CompactionError::LlmError(llm_err.to_string()))
485 }
486 }
487 }
488
489 async fn summarize_with_llm(
491 &self,
492 messages: &[Message],
493 instruction: Option<&str>,
494 ) -> std::result::Result<String, CompactionError> {
495 let prompt = self.build_summarize_prompt(messages, instruction);
496
497 let mut context = Context::new();
498 context.set_system_prompt(
499 "You are a helpful assistant that summarizes conversations concisely.",
500 );
501 context.add_message(Message::User(UserMessage::new(prompt)));
502
503 let options = StreamOptions {
504 temperature: Some(self.config.temperature as f64),
505 max_tokens: Some(self.config.summary_max_tokens),
506 ..Default::default()
507 };
508
509 let summary_message = complete(&self.model, &context, Some(options))
510 .await
511 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
512
513 Ok(summary_message.text_content())
514 }
515
516 async fn compact_fallback(
518 &self,
519 old_messages: &[Message],
520 recent_messages: &[Message],
521 ) -> std::result::Result<CompactedContext, CompactionError> {
522 let mut summary_parts = Vec::new();
524
525 if old_messages.len() > 2 {
526 if let Some(first) = old_messages.first() {
528 let content = first.text_content().unwrap_or_default();
529 let preview = safe_truncate(&content, 200);
530 summary_parts.push(format!("Started discussing: {}", preview));
531 }
532
533 if let Some(last) = old_messages.last() {
535 let content = last.text_content().unwrap_or_default();
536 let preview = safe_truncate(&content, 200);
537 summary_parts.push(format!("Ended with: {}", preview));
538 }
539
540 summary_parts.push(format!(
541 "({} messages omitted)",
542 old_messages.len().saturating_sub(2)
543 ));
544 } else if !old_messages.is_empty() {
545 if let Some(msg) = old_messages.first() {
547 let content = msg.text_content().unwrap_or_default();
548 summary_parts.push(format!("Conversation started: {}", content));
549 }
550 }
551
552 let summary = summary_parts.join(" ");
553
554 let mut summary_msg =
555 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
556 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
557 "[Previous conversation summary: {}]",
558 summary
559 )))];
560
561 let mut kept = vec![Message::Assistant(summary_msg)];
562 kept.extend(recent_messages.iter().cloned());
563
564 let original_tokens = self.estimate_tokens(old_messages);
565 let compacted_tokens = self.estimate_tokens(&kept);
566 let kept_len = kept.len();
567
568 Ok(CompactedContext::new(
569 summary,
570 kept,
571 old_messages.len(),
572 CompactionMetadata::new(
573 original_tokens,
574 compacted_tokens,
575 old_messages.len(),
576 kept_len,
577 self.config.target_ratio,
578 ),
579 ))
580 }
581}
582
583#[async_trait]
584impl Compactor for LlmCompactor {
585 async fn compact(
586 &self,
587 messages: &[Message],
588 instruction: Option<&str>,
589 ) -> std::result::Result<CompactedContext, CompactionError> {
590 if messages.is_empty() {
592 return Err(CompactionError::NoMessagesToCompact);
593 }
594
595 if messages.len() <= self.config.keep_recent {
596 let original_tokens = self.estimate_tokens(messages);
598 return Ok(CompactedContext::new(
599 String::new(),
600 messages.to_vec(),
601 0,
602 CompactionMetadata::new(
603 original_tokens,
604 original_tokens,
605 0,
606 messages.len(),
607 self.config.target_ratio,
608 ),
609 ));
610 }
611
612 let keep_count = self.config.keep_recent.min(messages.len());
614 let old_messages: Vec<Message> = messages[..messages.len() - keep_count].to_vec();
615 let recent_messages: Vec<Message> = messages[messages.len() - keep_count..].to_vec();
616
617 if old_messages.is_empty() {
618 return Err(CompactionError::NoMessagesToCompact);
619 }
620
621 self.compact_with_fallback(&old_messages, &recent_messages, instruction)
623 .await
624 }
625}
626
627impl LlmCompactor {
629 pub async fn summarize_branch(
634 &self,
635 messages: &[Message],
636 branch_name: &str,
637 ) -> std::result::Result<String, CompactionError> {
638 if messages.is_empty() {
639 return Ok(format!("Branch '{}' is empty", branch_name));
640 }
641
642 let mut prompt = String::new();
643 prompt.push_str(&format!(
644 "Summarize the conversation branch '{}' concisely. ",
645 branch_name
646 ));
647 prompt.push_str("Focus on: what was discussed, decisions made, and current state.\n\n");
648
649 prompt.push_str("## Branch messages:\n");
650 for (i, msg) in messages.iter().enumerate() {
651 let role = match msg {
652 Message::User(_) => "User",
653 Message::Assistant(_) => "Assistant",
654 Message::ToolResult(_) => "Tool",
655 };
656 let content = msg.text_content().unwrap_or_default();
657 let content_preview = safe_truncate(&content, 300);
658 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
659 }
660
661 prompt.push_str("\n## Summary (be concise):\n");
662
663 let mut context = Context::new();
665 context.set_system_prompt(
666 "You are a helpful assistant that summarizes conversation branches. ",
667 );
668 context.add_message(Message::User(UserMessage::new(prompt)));
669
670 let options = StreamOptions {
671 temperature: Some(0.3),
672 max_tokens: Some(512),
673 ..Default::default()
674 };
675
676 let summary_message = complete(&self.model, &context, Some(options))
677 .await
678 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
679
680 Ok(summary_message.text_content())
681 }
682}
683
684pub struct CompactionManager {
686 strategy: CompactionStrategy,
687 compactor: Option<Arc<dyn Compactor>>,
688 context_window: usize,
689 config: CompactionConfig,
690}
691
692impl CompactionManager {
693 pub fn new(strategy: CompactionStrategy, context_window: usize) -> Self {
695 Self {
696 strategy,
697 compactor: None,
698 context_window,
699 config: CompactionConfig::new(),
700 }
701 }
702
703 pub fn with_config(
705 strategy: CompactionStrategy,
706 context_window: usize,
707 config: CompactionConfig,
708 ) -> Self {
709 Self {
710 strategy,
711 compactor: None,
712 context_window,
713 config,
714 }
715 }
716
717 pub fn with_compactor<C: Compactor + 'static>(mut self, compactor: Arc<C>) -> Self {
719 self.compactor = Some(compactor);
720 self
721 }
722
723 pub fn set_compactor(&mut self, compactor: Arc<dyn Compactor>) {
725 self.compactor = Some(compactor);
726 }
727
728 pub fn should_compact(&self, context_tokens: usize, iteration: usize) -> bool {
730 self.strategy
731 .should_compact(context_tokens, self.context_window, iteration)
732 }
733
734 pub fn strategy(&self) -> &CompactionStrategy {
736 &self.strategy
737 }
738
739 pub fn config(&self) -> &CompactionConfig {
741 &self.config
742 }
743
744 pub fn set_config(&mut self, config: CompactionConfig) {
746 self.config = config;
747 }
748
749 pub async fn compact_if_needed(
751 &self,
752 messages: &[Message],
753 instruction: Option<&str>,
754 context_tokens: usize,
755 iteration: usize,
756 ) -> std::result::Result<Option<CompactedContext>, CompactionError> {
757 if !self.should_compact(context_tokens, iteration) {
758 return Ok(None);
759 }
760
761 let compactor = match &self.compactor {
762 Some(c) => c,
763 None => return Err(CompactionError::CompactionDisabled),
764 };
765
766 let result = compactor.compact(messages, instruction).await?;
767 Ok(Some(result))
768 }
769
770 pub async fn compact_now(
772 &self,
773 messages: &[Message],
774 instruction: Option<&str>,
775 ) -> std::result::Result<CompactedContext, CompactionError> {
776 let compactor = match &self.compactor {
777 Some(c) => c,
778 None => return Err(CompactionError::CompactionDisabled),
779 };
780
781 compactor.compact(messages, instruction).await
782 }
783
784 pub fn estimate_tokens(&self, messages: &[Message]) -> usize {
786 messages
787 .iter()
788 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
789 .sum()
790 }
791}
792
793impl Default for CompactionManager {
794 fn default() -> Self {
795 Self::new(CompactionStrategy::default(), 128_000)
796 }
797}
798
799#[cfg(test)]
804mod tests {
805 use super::*;
806
807 fn make_user_message(content: &str) -> Message {
809 Message::user(content)
810 }
811
812 fn make_assistant_message(content: &str) -> Message {
814 Message::Assistant({
815 let mut msg = AssistantMessage::new(Api::AnthropicMessages, "test", "test-model");
816 msg.content = vec![ContentBlock::Text(TextContent::new(content))];
817 msg
818 })
819 }
820
821 fn make_test_model() -> Model {
823 Model::new(
824 "test-model",
825 "Test Model",
826 Api::AnthropicMessages,
827 "test",
828 "https://test.example.com",
829 )
830 }
831
832 #[test]
833 fn test_compaction_config_defaults() {
834 let config = CompactionConfig::new();
835 assert_eq!(config.keep_recent, 4);
836 assert_eq!(config.max_batch, 20);
837 assert!((config.target_ratio - 0.5).abs() < 0.001);
838 assert_eq!(config.summary_max_tokens, 1024);
839 assert!((config.temperature - 0.3).abs() < 0.001);
840 }
841
842 #[test]
843 fn test_compaction_config_builder_pattern() {
844 let config = CompactionConfig::new()
845 .with_keep_recent(10)
846 .with_max_batch(30)
847 .with_target_ratio(0.3)
848 .with_temperature(0.5);
849
850 assert_eq!(config.keep_recent, 10);
851 assert_eq!(config.max_batch, 30);
852 assert!((config.target_ratio - 0.3).abs() < 0.001);
853 assert!((config.temperature - 0.5).abs() < 0.001);
854 }
855
856 #[test]
857 fn test_compaction_config_ratio_clamping() {
858 let config = CompactionConfig::new().with_target_ratio(1.5);
860 assert!((config.target_ratio - 0.9).abs() < 0.001);
861
862 let config = CompactionConfig::new().with_target_ratio(-0.5);
864 assert!((config.target_ratio - 0.1).abs() < 0.001);
865 }
866
867 #[test]
868 fn test_compaction_metadata_success() {
869 let metadata = CompactionMetadata::new(
870 1000, 500, 10, 5, 0.5, );
876
877 assert!(metadata.success);
878 assert_eq!(metadata.original_tokens, 1000);
879 assert_eq!(metadata.compacted_tokens, 500);
880 assert_eq!(metadata.messages_compacted, 10);
881 assert_eq!(metadata.messages_kept, 5);
882 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
883 assert!((metadata.compression_factor() - 0.5).abs() < 0.001);
884 assert_eq!(metadata.tokens_saved(), 500);
885 assert!(metadata.error.is_none());
886 }
887
888 #[test]
889 fn test_compaction_metadata_failure() {
890 let metadata = CompactionError::LlmError("test error".to_string());
891
892 assert!(metadata.to_string().contains("test error"));
894 }
895
896 #[test]
897 fn test_compaction_metadata_compression_factor() {
898 let metadata = CompactionMetadata::new(0, 0, 0, 0, 0.5);
900 assert!((metadata.actual_ratio - 1.0).abs() < 0.001);
901 assert!((metadata.compression_factor() - 0.0).abs() < 0.001);
902
903 let metadata = CompactionMetadata::new(1000, 100, 10, 5, 0.5);
905 assert!((metadata.compression_factor() - 0.9).abs() < 0.001);
906 }
907
908 #[test]
909 fn test_compaction_metadata_tokens_saved() {
910 let metadata = CompactionMetadata::new(1000, 400, 10, 5, 0.5);
912 assert_eq!(metadata.tokens_saved(), 600);
913
914 let metadata = CompactionMetadata::new(1000, 1000, 0, 0, 0.5);
916 assert_eq!(metadata.tokens_saved(), 0);
917
918 let metadata = CompactionMetadata::new(500, 600, 5, 3, 0.5);
920 assert_eq!(metadata.tokens_saved(), 0); }
922
923 #[test]
924 fn test_compaction_strategy_disabled() {
925 let strategy = CompactionStrategy::Disabled;
926 assert!(!strategy.should_compact(100_000, 128_000, 5));
927 assert!(!strategy.should_compact(120_000, 128_000, 10));
928 assert!(!strategy.should_compact(0, 128_000, 1));
929 }
930
931 #[test]
932 fn test_compaction_strategy_threshold() {
933 let strategy = CompactionStrategy::Threshold(0.8);
934
935 assert!(!strategy.should_compact(100_000, 128_000, 1));
937
938 assert!(strategy.should_compact(102_400, 128_000, 1));
940
941 assert!(strategy.should_compact(120_000, 128_000, 1));
943
944 assert!(!strategy.should_compact(100_000, 0, 1));
946 }
947
948 #[test]
949 fn test_compaction_strategy_every_n_turns() {
950 let strategy = CompactionStrategy::EveryNTurns(5);
951
952 assert!(!strategy.should_compact(0, 128_000, 0));
954 assert!(!strategy.should_compact(0, 128_000, 3));
955 assert!(!strategy.should_compact(0, 128_000, 4));
956
957 assert!(strategy.should_compact(0, 128_000, 5));
959 assert!(strategy.should_compact(0, 128_000, 10));
960 assert!(strategy.should_compact(0, 128_000, 15));
961
962 assert!(!strategy.should_compact(0, 128_000, 6));
964 assert!(!strategy.should_compact(0, 128_000, 9));
965 }
966
967 #[test]
968 fn test_compaction_strategy_absolute_tokens() {
969 let strategy = CompactionStrategy::AbsoluteTokens(100_000);
970
971 assert!(!strategy.should_compact(50_000, 128_000, 0));
973 assert!(!strategy.should_compact(99_999, 128_000, 0));
974
975 assert!(strategy.should_compact(100_000, 128_000, 0));
977
978 assert!(strategy.should_compact(150_000, 128_000, 0));
980 }
981
982 #[test]
983 fn test_compacted_context_basic() {
984 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
985 let ctx = CompactedContext::new(
986 "Test summary".to_string(),
987 vec![make_user_message("test")],
988 10,
989 metadata,
990 );
991
992 assert_eq!(ctx.summary(), "Test summary");
993 assert_eq!(ctx.kept_count(), 1);
994 assert_eq!(ctx.compacted_count(), 10);
995 assert!(ctx.is_success());
996 assert_eq!(ctx.metadata().tokens_saved(), 500);
997 }
998
999 #[test]
1000 fn test_compacted_context_with_empty_summary() {
1001 let metadata = CompactionMetadata::new(100, 100, 0, 2, 0.5);
1002 let ctx = CompactedContext::new(
1003 String::new(), vec![make_user_message("test1"), make_user_message("test2")],
1005 0,
1006 metadata,
1007 );
1008
1009 assert_eq!(ctx.summary(), "");
1010 assert_eq!(ctx.kept_count(), 2);
1011 assert_eq!(ctx.compacted_count(), 0);
1012 }
1013
1014 #[test]
1015 fn test_llm_compactor_config_builder() {
1016 use crate::providers::OpenAiProvider;
1018 let provider = OpenAiProvider::new();
1019 let model = make_test_model();
1020 let compactor = LlmCompactor::new(model, Arc::new(provider))
1021 .with_keep_recent(6)
1022 .with_max_batch(25)
1023 .with_target_ratio(0.6);
1024
1025 assert!(compactor.config.keep_recent >= 4);
1026 assert!(compactor.config.max_batch >= 20);
1027 }
1028
1029 #[test]
1030 fn test_compaction_error_display() {
1031 let err = CompactionError::NoMessagesToCompact;
1032 assert_eq!(err.to_string(), "No messages to compact");
1033
1034 let err = CompactionError::TooFewMessages {
1035 total: 3,
1036 keep_recent: 5,
1037 };
1038 assert!(err.to_string().contains("3"));
1039 assert!(err.to_string().contains("6"));
1041
1042 let err = CompactionError::CompactionDisabled;
1043 assert_eq!(err.to_string(), "Compaction is disabled");
1044
1045 let err = CompactionError::NoContextWindow;
1046 assert_eq!(err.to_string(), "Context window not configured");
1047
1048 let err = CompactionError::LlmError("API timeout".to_string());
1049 assert!(err.to_string().contains("API timeout"));
1050 }
1051
1052 #[test]
1053 fn test_compaction_manager_default() {
1054 let manager = CompactionManager::default();
1055 assert!(matches!(
1056 manager.strategy(),
1057 CompactionStrategy::Threshold(_)
1058 ));
1059 assert_eq!(manager.config().keep_recent, 4);
1060 }
1061
1062 #[test]
1063 fn test_compaction_manager_with_custom_strategy() {
1064 let strategy = CompactionStrategy::AbsoluteTokens(50_000);
1065 let manager = CompactionManager::new(strategy, 200_000);
1066
1067 assert!(!manager.should_compact(30_000, 0));
1069
1070 assert!(manager.should_compact(60_000, 0));
1072 }
1073
1074 #[test]
1075 fn test_compaction_manager_with_config() {
1076 let config = CompactionConfig::new()
1077 .with_keep_recent(8)
1078 .with_target_ratio(0.4);
1079
1080 let manager =
1081 CompactionManager::with_config(CompactionStrategy::default(), 128_000, config);
1082
1083 assert_eq!(manager.config().keep_recent, 8);
1084 assert!((manager.config().target_ratio - 0.4).abs() < 0.001);
1085 }
1086
1087 #[test]
1088 fn test_compaction_manager_should_compact_integration() {
1089 let manager = CompactionManager::new(CompactionStrategy::Threshold(0.75), 100_000);
1090
1091 assert!(!manager.should_compact(70_000, 0));
1093
1094 assert!(manager.should_compact(75_000, 0));
1096
1097 assert!(manager.should_compact(80_000, 0));
1099 assert!(manager.should_compact(100_000, 0));
1100 }
1101
1102 #[test]
1103 fn test_compaction_manager_no_compactor_set() {
1104 let manager = CompactionManager::new(CompactionStrategy::EveryNTurns(5), 128_000);
1105
1106 assert!(manager.should_compact(0, 5)); }
1110
1111 #[test]
1112 fn test_token_estimation_helper() {
1113 use crate::providers::OpenAiProvider;
1114 let provider = OpenAiProvider::new();
1115 let model = make_test_model();
1116 let compactor = LlmCompactor::new(model, Arc::new(provider));
1117
1118 let messages = vec![
1119 make_user_message("Hello world, this is a test message."),
1120 make_assistant_message("This is a response with some content."),
1121 ];
1122
1123 let tokens = compactor.estimate_tokens(&messages);
1124 assert!(tokens > 0, "Should estimate tokens for messages");
1125 }
1126
1127 #[test]
1128 fn test_compaction_config_custom_instruction() {
1129 let config = CompactionConfig::new()
1130 .with_custom_instruction("Focus on code changes and technical decisions");
1131
1132 assert!(config.custom_instruction.is_some());
1133 assert!(config.custom_instruction.unwrap().contains("code changes"));
1134 }
1135
1136 #[test]
1137 fn test_compaction_metadata_timestamp_is_set() {
1138 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1139 assert!(metadata.timestamp <= Utc::now());
1140 }
1141
1142 #[test]
1143 fn test_compaction_ratio_achievement() {
1144 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1146 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1147
1148 let metadata = CompactionMetadata::new(1000, 300, 10, 5, 0.5);
1150 assert!((metadata.actual_ratio - 0.3).abs() < 0.001);
1151 assert!(metadata.compression_factor() > 0.5);
1152
1153 let metadata = CompactionMetadata::new(1000, 700, 10, 5, 0.5);
1155 assert!((metadata.actual_ratio - 0.7).abs() < 0.001);
1156 assert!(metadata.compression_factor() < 0.5);
1157 }
1158
1159 #[test]
1160 fn test_compaction_manager_config_updates() {
1161 let mut manager = CompactionManager::default();
1162
1163 let new_config = CompactionConfig::new()
1164 .with_keep_recent(12)
1165 .with_target_ratio(0.3);
1166
1167 manager.set_config(new_config);
1168
1169 assert_eq!(manager.config().keep_recent, 12);
1170 assert!((manager.config().target_ratio - 0.3).abs() < 0.001);
1171 }
1172
1173 #[test]
1174 fn test_llm_compactor_has_summarize_branch() {
1175 use crate::providers::OpenAiProvider;
1177 let provider = OpenAiProvider::new();
1178 let model = make_test_model();
1179 let compactor = LlmCompactor::new(model, Arc::new(provider));
1180
1181 let messages = vec![
1183 make_user_message("Test message 1"),
1184 make_assistant_message("Test response 1"),
1185 make_user_message("Test message 2"),
1186 ];
1187
1188 let branch_name = "test-branch";
1191 let _future = compactor.summarize_branch(&messages, branch_name);
1193 }
1194
1195 #[test]
1196 fn test_summarize_branch_returns_error_on_llm_failure() {
1197 use crate::providers::OpenAiProvider;
1199 let provider = OpenAiProvider::new();
1200 let model = make_test_model();
1201 let compactor = LlmCompactor::new(model, Arc::new(provider));
1202
1203 let messages: Vec<Message> = vec![];
1205
1206 let _future = compactor.summarize_branch(&messages, "empty-branch");
1209 }
1210}