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}
26
27pub fn generate_branch_summary(messages: &[Message], n: usize) -> String {
32 if messages.is_empty() {
33 return "(empty conversation)".to_string();
34 }
35
36 let last_n: Vec<_> = if n > 0 {
37 messages.iter().rev().take(n).collect()
38 } else {
39 messages.iter().collect()
40 };
41
42 let mut topics = Vec::new();
43 let mut decisions = Vec::new();
44
45 for msg in last_n.iter().rev() {
46 let role = match msg {
47 Message::User(_) => "user",
48 Message::Assistant(_) => "assistant",
49 Message::ToolResult(_) => "tool",
50 };
51 let content = msg.text_content().unwrap_or_default();
52 let preview = safe_truncate(&content, 120);
53
54 if content.contains("created file") || content.contains("edited file") {
56 topics.push("file modifications".to_string());
57 }
58 if content.contains("implemented") || content.contains("added feature") {
59 topics.push("feature implementation".to_string());
60 }
61 if content.contains("decided") || content.contains("chose") || content.contains("agreed") {
62 decisions.push(preview);
63 }
64 if content.contains("search") || content.contains("debug") || content.contains("fix") {
65 topics.push(format!("inquiry/analysis by {}", role));
66 }
67 }
68
69 topics.dedup();
71 decisions.dedup();
72
73 let summary = if topics.is_empty() && decisions.is_empty() {
74 messages
76 .last()
77 .and_then(|m| m.text_content().ok())
78 .map(|c| safe_truncate(&c, 200))
79 .unwrap_or_else(|| "(no content)".to_string())
80 } else {
81 let mut parts = Vec::new();
82 if !topics.is_empty() {
83 parts.push(format!("Topics: {}", topics.join(", ")));
84 }
85 if !decisions.is_empty() {
86 parts.push(format!("Decisions: {}", decisions.join("; ")));
87 }
88 parts.join(" | ")
89 };
90
91 format!("[Branch summary of {} msgs] {}", messages.len(), summary)
92}
93
94use async_trait::async_trait;
95use chrono::{DateTime, Utc};
96use serde::{Deserialize, Serialize};
97use std::sync::Arc;
98use std::time::Duration;
99
100#[derive(Debug, Clone)]
102pub struct CompactionConfig {
103 pub keep_recent: usize,
105 pub max_batch: usize,
107 pub target_ratio: f32,
109 pub summary_max_tokens: usize,
111 pub temperature: f32,
113 pub timeout: Duration,
115 pub custom_instruction: Option<String>,
117}
118
119impl CompactionConfig {
120 pub fn new() -> Self {
122 Self {
123 keep_recent: 4,
124 max_batch: 20,
125 target_ratio: 0.5,
126 summary_max_tokens: 1024,
127 temperature: 0.3,
128 timeout: Duration::from_secs(60),
129 custom_instruction: None,
130 }
131 }
132
133 pub fn with_keep_recent(mut self, count: usize) -> Self {
135 self.keep_recent = count;
136 self
137 }
138
139 pub fn with_max_batch(mut self, count: usize) -> Self {
141 self.max_batch = count;
142 self
143 }
144
145 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
147 self.target_ratio = ratio.clamp(0.1, 0.9);
148 self
149 }
150
151 pub fn with_summary_max_tokens(mut self, tokens: usize) -> Self {
153 self.summary_max_tokens = tokens;
154 self
155 }
156
157 pub fn with_temperature(mut self, temp: f32) -> Self {
159 self.temperature = temp.clamp(0.0, 1.0);
160 self
161 }
162
163 pub fn with_timeout(mut self, timeout: Duration) -> Self {
165 self.timeout = timeout;
166 self
167 }
168
169 pub fn with_custom_instruction(mut self, instruction: impl Into<String>) -> Self {
171 self.custom_instruction = Some(instruction.into());
172 self
173 }
174}
175
176impl Default for CompactionConfig {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct CompactionMetadata {
185 pub original_tokens: usize,
187 pub compacted_tokens: usize,
189 pub messages_compacted: usize,
191 pub messages_kept: usize,
193 pub timestamp: DateTime<Utc>,
195 pub target_ratio: f32,
197 pub actual_ratio: f32,
199 pub success: bool,
201 pub error: Option<String>,
203}
204
205impl CompactionMetadata {
206 pub fn new(
208 original_tokens: usize,
209 compacted_tokens: usize,
210 messages_compacted: usize,
211 messages_kept: usize,
212 target_ratio: f32,
213 ) -> Self {
214 let actual_ratio = if original_tokens > 0 {
215 compacted_tokens as f32 / original_tokens as f32
216 } else {
217 1.0
218 };
219
220 Self {
221 original_tokens,
222 compacted_tokens,
223 messages_compacted,
224 messages_kept,
225 timestamp: Utc::now(),
226 target_ratio,
227 actual_ratio,
228 success: true,
229 error: None,
230 }
231 }
232
233 pub fn failed(
235 original_tokens: usize,
236 messages_compacted: usize,
237 target_ratio: f32,
238 error: impl Into<String>,
239 ) -> Self {
240 Self {
241 original_tokens,
242 compacted_tokens: original_tokens,
243 messages_compacted,
244 messages_kept: 0,
245 timestamp: Utc::now(),
246 target_ratio,
247 actual_ratio: 1.0,
248 success: false,
249 error: Some(error.into()),
250 }
251 }
252
253 pub fn compression_factor(&self) -> f32 {
255 if self.actual_ratio > 0.0 {
256 1.0 - self.actual_ratio
257 } else {
258 0.0
259 }
260 }
261
262 pub fn tokens_saved(&self) -> usize {
264 self.original_tokens.saturating_sub(self.compacted_tokens)
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct CompactedContext {
271 pub summary: String,
273 pub kept_messages: Vec<Message>,
275 pub compacted_count: usize,
277 pub metadata: CompactionMetadata,
279}
280
281impl CompactedContext {
282 pub fn new(
284 summary: String,
285 kept_messages: Vec<Message>,
286 compacted_count: usize,
287 metadata: CompactionMetadata,
288 ) -> Self {
289 Self {
290 summary,
291 kept_messages,
292 compacted_count,
293 metadata,
294 }
295 }
296
297 pub fn summary(&self) -> &str {
299 &self.summary
300 }
301
302 pub fn kept_count(&self) -> usize {
304 self.kept_messages.len()
305 }
306
307 pub fn compacted_count(&self) -> usize {
309 self.compacted_count
310 }
311
312 pub fn metadata(&self) -> &CompactionMetadata {
314 &self.metadata
315 }
316
317 pub fn is_success(&self) -> bool {
319 self.metadata.success
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
325pub enum CompactionStrategy {
326 Disabled,
328 Threshold(f32),
330 EveryNTurns(usize),
332 AbsoluteTokens(usize),
334}
335
336impl CompactionStrategy {
337 pub fn should_compact(
347 &self,
348 context_tokens: usize,
349 context_window: usize,
350 iteration: usize,
351 ) -> bool {
352 match self {
353 CompactionStrategy::Disabled => false,
354 CompactionStrategy::Threshold(threshold) => {
355 if context_window == 0 {
356 return false;
357 }
358 let usage = context_tokens as f32 / context_window as f32;
359 usage >= *threshold
360 }
361 CompactionStrategy::EveryNTurns(n) => iteration > 0 && iteration.is_multiple_of(*n),
362 CompactionStrategy::AbsoluteTokens(max_tokens) => context_tokens >= *max_tokens,
363 }
364 }
365}
366
367impl Default for CompactionStrategy {
368 fn default() -> Self {
369 CompactionStrategy::Threshold(0.8)
370 }
371}
372
373#[derive(Debug, Clone)]
375pub enum CompactionError {
376 LlmError(String),
378 NoMessagesToCompact,
380 TooFewMessages { total: usize, keep_recent: usize },
382 CompactionDisabled,
384 NoContextWindow,
386}
387
388impl std::fmt::Display for CompactionError {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 match self {
391 CompactionError::LlmError(msg) => write!(f, "LLM compaction failed: {}", msg),
392 CompactionError::NoMessagesToCompact => write!(f, "No messages to compact"),
393 CompactionError::TooFewMessages { total, keep_recent } => {
394 write!(
395 f,
396 "Not enough messages ({}) to compact (need at least {} for keep_recent)",
397 total,
398 keep_recent + 1
399 )
400 }
401 CompactionError::CompactionDisabled => write!(f, "Compaction is disabled"),
402 CompactionError::NoContextWindow => write!(f, "Context window not configured"),
403 }
404 }
405}
406
407impl std::error::Error for CompactionError {}
408
409#[async_trait]
411pub trait Compactor: Send + Sync {
412 async fn compact(
414 &self,
415 messages: &[Message],
416 instruction: Option<&str>,
417 ) -> std::result::Result<CompactedContext, CompactionError>;
418
419 fn estimate_tokens(&self, messages: &[Message]) -> usize {
421 messages
422 .iter()
423 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
424 .sum()
425 }
426}
427
428pub struct LlmCompactor {
430 model: Model,
431 _provider: Arc<dyn Provider>,
432 config: CompactionConfig,
433}
434
435impl LlmCompactor {
436 pub fn new(model: Model, provider: Arc<dyn Provider>) -> Self {
438 Self {
439 model,
440 _provider: provider,
441 config: CompactionConfig::new(),
442 }
443 }
444
445 pub fn with_config(
447 model: Model,
448 provider: Arc<dyn Provider>,
449 config: CompactionConfig,
450 ) -> Self {
451 Self {
452 model,
453 _provider: provider,
454 config,
455 }
456 }
457
458 pub fn with_keep_recent(mut self, count: usize) -> Self {
460 self.config.keep_recent = count;
461 self
462 }
463
464 pub fn with_max_batch(mut self, count: usize) -> Self {
466 self.config.max_batch = count;
467 self
468 }
469
470 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
472 self.config.target_ratio = ratio.clamp(0.1, 0.9);
473 self
474 }
475
476 fn build_summarize_prompt(&self, messages: &[Message], instruction: Option<&str>) -> String {
478 let mut prompt = String::new();
479
480 prompt.push_str("Summarize the following conversation concisely. ");
481 prompt.push_str("Capture the key points, decisions, and any ongoing tasks or context.\n\n");
482
483 if let Some(instr) = instruction {
484 prompt.push_str(&format!("Focus areas: {}\n\n", instr));
485 } else if let Some(ref custom_instr) = self.config.custom_instruction {
486 prompt.push_str(&format!("Focus areas: {}\n\n", custom_instr));
487 }
488
489 prompt.push_str("## Conversation to summarize:\n");
490
491 for (i, msg) in messages.iter().enumerate() {
492 let role = match msg {
493 Message::User(_) => "User",
494 Message::Assistant(_) => "Assistant",
495 Message::ToolResult(_) => "Tool",
496 };
497 let content = msg.text_content().unwrap_or_default();
498 let content_preview = safe_truncate(&content, 500);
499 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
500 }
501
502 prompt.push_str("\n## Summary:\n");
503 prompt
504 .push_str("Provide a concise summary that captures the essence of this conversation.");
505
506 prompt
507 }
508
509 async fn compact_with_fallback(
511 &self,
512 old_messages: &[Message],
513 recent_messages: &[Message],
514 instruction: Option<&str>,
515 ) -> std::result::Result<CompactedContext, CompactionError> {
516 match self.summarize_with_llm(old_messages, instruction).await {
518 Ok(summary) => {
519 let mut summary_msg =
521 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
522 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
523 "[Previous conversation summarized: {}]",
524 summary
525 )))];
526
527 let mut kept = vec![Message::Assistant(summary_msg)];
529 kept.extend(recent_messages.iter().cloned());
530
531 let original_tokens = self.estimate_tokens(old_messages);
532 let compacted_tokens = self.estimate_tokens(&kept);
533 let kept_len = kept.len();
534
535 Ok(CompactedContext::new(
536 summary,
537 kept,
538 old_messages.len(),
539 CompactionMetadata::new(
540 original_tokens,
541 compacted_tokens,
542 old_messages.len(),
543 kept_len,
544 self.config.target_ratio,
545 ),
546 ))
547 }
548 Err(llm_err) => {
549 self.compact_fallback(old_messages, recent_messages)
551 .await
552 .map_err(|_| CompactionError::LlmError(llm_err.to_string()))
553 }
554 }
555 }
556
557 async fn summarize_with_llm(
559 &self,
560 messages: &[Message],
561 instruction: Option<&str>,
562 ) -> std::result::Result<String, CompactionError> {
563 let prompt = self.build_summarize_prompt(messages, instruction);
564
565 let mut context = Context::new();
566 context.set_system_prompt(
567 "You are a helpful assistant that summarizes conversations concisely.",
568 );
569 context.add_message(Message::User(UserMessage::new(prompt)));
570
571 let options = StreamOptions {
572 temperature: Some(self.config.temperature as f64),
573 max_tokens: Some(self.config.summary_max_tokens),
574 ..Default::default()
575 };
576
577 let summary_message = complete(&self.model, &context, Some(options))
578 .await
579 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
580
581 Ok(summary_message.text_content())
582 }
583
584 async fn compact_fallback(
586 &self,
587 old_messages: &[Message],
588 recent_messages: &[Message],
589 ) -> std::result::Result<CompactedContext, CompactionError> {
590 let mut summary_parts = Vec::new();
592
593 if old_messages.len() > 2 {
594 if let Some(first) = old_messages.first() {
596 let content = first.text_content().unwrap_or_default();
597 let preview = safe_truncate(&content, 200);
598 summary_parts.push(format!("Started discussing: {}", preview));
599 }
600
601 if let Some(last) = old_messages.last() {
603 let content = last.text_content().unwrap_or_default();
604 let preview = safe_truncate(&content, 200);
605 summary_parts.push(format!("Ended with: {}", preview));
606 }
607
608 summary_parts.push(format!(
609 "({} messages omitted)",
610 old_messages.len().saturating_sub(2)
611 ));
612 } else if !old_messages.is_empty() {
613 if let Some(msg) = old_messages.first() {
615 let content = msg.text_content().unwrap_or_default();
616 summary_parts.push(format!("Conversation started: {}", content));
617 }
618 }
619
620 let summary = summary_parts.join(" ");
621
622 let mut summary_msg =
623 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
624 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
625 "[Previous conversation summary: {}]",
626 summary
627 )))];
628
629 let mut kept = vec![Message::Assistant(summary_msg)];
630 kept.extend(recent_messages.iter().cloned());
631
632 let original_tokens = self.estimate_tokens(old_messages);
633 let compacted_tokens = self.estimate_tokens(&kept);
634 let kept_len = kept.len();
635
636 Ok(CompactedContext::new(
637 summary,
638 kept,
639 old_messages.len(),
640 CompactionMetadata::new(
641 original_tokens,
642 compacted_tokens,
643 old_messages.len(),
644 kept_len,
645 self.config.target_ratio,
646 ),
647 ))
648 }
649}
650
651#[async_trait]
652impl Compactor for LlmCompactor {
653 async fn compact(
654 &self,
655 messages: &[Message],
656 instruction: Option<&str>,
657 ) -> std::result::Result<CompactedContext, CompactionError> {
658 if messages.is_empty() {
660 return Err(CompactionError::NoMessagesToCompact);
661 }
662
663 if messages.len() <= self.config.keep_recent {
664 let original_tokens = self.estimate_tokens(messages);
666 return Ok(CompactedContext::new(
667 String::new(),
668 messages.to_vec(),
669 0,
670 CompactionMetadata::new(
671 original_tokens,
672 original_tokens,
673 0,
674 messages.len(),
675 self.config.target_ratio,
676 ),
677 ));
678 }
679
680 let keep_count = self.config.keep_recent.min(messages.len());
682 let old_messages: Vec<Message> = messages[..messages.len() - keep_count].to_vec();
683 let recent_messages: Vec<Message> = messages[messages.len() - keep_count..].to_vec();
684
685 if old_messages.is_empty() {
686 return Err(CompactionError::NoMessagesToCompact);
687 }
688
689 self.compact_with_fallback(&old_messages, &recent_messages, instruction)
691 .await
692 }
693}
694
695impl LlmCompactor {
697 pub async fn summarize_branch(
702 &self,
703 messages: &[Message],
704 branch_name: &str,
705 ) -> std::result::Result<String, CompactionError> {
706 if messages.is_empty() {
707 return Ok(format!("Branch '{}' is empty", branch_name));
708 }
709
710 let mut prompt = String::new();
711 prompt.push_str(&format!(
712 "Summarize the conversation branch '{}' concisely. ",
713 branch_name
714 ));
715 prompt.push_str("Focus on: what was discussed, decisions made, and current state.\n\n");
716
717 prompt.push_str("## Branch messages:\n");
718 for (i, msg) in messages.iter().enumerate() {
719 let role = match msg {
720 Message::User(_) => "User",
721 Message::Assistant(_) => "Assistant",
722 Message::ToolResult(_) => "Tool",
723 };
724 let content = msg.text_content().unwrap_or_default();
725 let content_preview = safe_truncate(&content, 300);
726 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
727 }
728
729 prompt.push_str("\n## Summary (be concise):\n");
730
731 let mut context = Context::new();
733 context.set_system_prompt(
734 "You are a helpful assistant that summarizes conversation branches. ",
735 );
736 context.add_message(Message::User(UserMessage::new(prompt)));
737
738 let options = StreamOptions {
739 temperature: Some(0.3),
740 max_tokens: Some(512),
741 ..Default::default()
742 };
743
744 let summary_message = complete(&self.model, &context, Some(options))
745 .await
746 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
747
748 Ok(summary_message.text_content())
749 }
750}
751
752pub struct CompactionManager {
754 strategy: CompactionStrategy,
755 compactor: Option<Arc<dyn Compactor>>,
756 context_window: usize,
757 config: CompactionConfig,
758}
759
760impl CompactionManager {
761 pub fn new(strategy: CompactionStrategy, context_window: usize) -> Self {
763 Self {
764 strategy,
765 compactor: None,
766 context_window,
767 config: CompactionConfig::new(),
768 }
769 }
770
771 pub fn with_config(
773 strategy: CompactionStrategy,
774 context_window: usize,
775 config: CompactionConfig,
776 ) -> Self {
777 Self {
778 strategy,
779 compactor: None,
780 context_window,
781 config,
782 }
783 }
784
785 pub fn with_compactor<C: Compactor + 'static>(mut self, compactor: Arc<C>) -> Self {
787 self.compactor = Some(compactor);
788 self
789 }
790
791 pub fn set_compactor(&mut self, compactor: Arc<dyn Compactor>) {
793 self.compactor = Some(compactor);
794 }
795
796 pub fn should_compact(&self, context_tokens: usize, iteration: usize) -> bool {
798 self.strategy
799 .should_compact(context_tokens, self.context_window, iteration)
800 }
801
802 pub fn strategy(&self) -> &CompactionStrategy {
804 &self.strategy
805 }
806
807 pub fn config(&self) -> &CompactionConfig {
809 &self.config
810 }
811
812 pub fn set_config(&mut self, config: CompactionConfig) {
814 self.config = config;
815 }
816
817 pub async fn compact_if_needed(
819 &self,
820 messages: &[Message],
821 instruction: Option<&str>,
822 context_tokens: usize,
823 iteration: usize,
824 ) -> std::result::Result<Option<CompactedContext>, CompactionError> {
825 if !self.should_compact(context_tokens, iteration) {
826 return Ok(None);
827 }
828
829 let compactor = match &self.compactor {
830 Some(c) => c,
831 None => return Err(CompactionError::CompactionDisabled),
832 };
833
834 let result = compactor.compact(messages, instruction).await?;
835 Ok(Some(result))
836 }
837
838 pub async fn compact_now(
840 &self,
841 messages: &[Message],
842 instruction: Option<&str>,
843 ) -> std::result::Result<CompactedContext, CompactionError> {
844 let compactor = match &self.compactor {
845 Some(c) => c,
846 None => return Err(CompactionError::CompactionDisabled),
847 };
848
849 compactor.compact(messages, instruction).await
850 }
851
852 pub fn estimate_tokens(&self, messages: &[Message]) -> usize {
854 messages
855 .iter()
856 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
857 .sum()
858 }
859}
860
861impl Default for CompactionManager {
862 fn default() -> Self {
863 Self::new(CompactionStrategy::default(), 128_000)
864 }
865}
866
867#[cfg(test)]
872mod tests {
873 use super::*;
874
875 fn make_user_message(content: &str) -> Message {
877 Message::user(content)
878 }
879
880 fn make_assistant_message(content: &str) -> Message {
882 Message::Assistant({
883 let mut msg = AssistantMessage::new(Api::AnthropicMessages, "test", "test-model");
884 msg.content = vec![ContentBlock::Text(TextContent::new(content))];
885 msg
886 })
887 }
888
889 fn make_test_model() -> Model {
891 Model::new(
892 "test-model",
893 "Test Model",
894 Api::AnthropicMessages,
895 "test",
896 "https://test.example.com",
897 )
898 }
899
900 #[test]
901 fn test_compaction_config_defaults() {
902 let config = CompactionConfig::new();
903 assert_eq!(config.keep_recent, 4);
904 assert_eq!(config.max_batch, 20);
905 assert!((config.target_ratio - 0.5).abs() < 0.001);
906 assert_eq!(config.summary_max_tokens, 1024);
907 assert!((config.temperature - 0.3).abs() < 0.001);
908 }
909
910 #[test]
911 fn test_compaction_config_builder_pattern() {
912 let config = CompactionConfig::new()
913 .with_keep_recent(10)
914 .with_max_batch(30)
915 .with_target_ratio(0.3)
916 .with_temperature(0.5);
917
918 assert_eq!(config.keep_recent, 10);
919 assert_eq!(config.max_batch, 30);
920 assert!((config.target_ratio - 0.3).abs() < 0.001);
921 assert!((config.temperature - 0.5).abs() < 0.001);
922 }
923
924 #[test]
925 fn test_compaction_config_ratio_clamping() {
926 let config = CompactionConfig::new().with_target_ratio(1.5);
928 assert!((config.target_ratio - 0.9).abs() < 0.001);
929
930 let config = CompactionConfig::new().with_target_ratio(-0.5);
932 assert!((config.target_ratio - 0.1).abs() < 0.001);
933 }
934
935 #[test]
936 fn test_compaction_metadata_success() {
937 let metadata = CompactionMetadata::new(
938 1000, 500, 10, 5, 0.5, );
944
945 assert!(metadata.success);
946 assert_eq!(metadata.original_tokens, 1000);
947 assert_eq!(metadata.compacted_tokens, 500);
948 assert_eq!(metadata.messages_compacted, 10);
949 assert_eq!(metadata.messages_kept, 5);
950 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
951 assert!((metadata.compression_factor() - 0.5).abs() < 0.001);
952 assert_eq!(metadata.tokens_saved(), 500);
953 assert!(metadata.error.is_none());
954 }
955
956 #[test]
957 fn test_compaction_metadata_failure() {
958 let metadata = CompactionError::LlmError("test error".to_string());
959
960 assert!(metadata.to_string().contains("test error"));
962 }
963
964 #[test]
965 fn test_compaction_metadata_compression_factor() {
966 let metadata = CompactionMetadata::new(0, 0, 0, 0, 0.5);
968 assert!((metadata.actual_ratio - 1.0).abs() < 0.001);
969 assert!((metadata.compression_factor() - 0.0).abs() < 0.001);
970
971 let metadata = CompactionMetadata::new(1000, 100, 10, 5, 0.5);
973 assert!((metadata.compression_factor() - 0.9).abs() < 0.001);
974 }
975
976 #[test]
977 fn test_compaction_metadata_tokens_saved() {
978 let metadata = CompactionMetadata::new(1000, 400, 10, 5, 0.5);
980 assert_eq!(metadata.tokens_saved(), 600);
981
982 let metadata = CompactionMetadata::new(1000, 1000, 0, 0, 0.5);
984 assert_eq!(metadata.tokens_saved(), 0);
985
986 let metadata = CompactionMetadata::new(500, 600, 5, 3, 0.5);
988 assert_eq!(metadata.tokens_saved(), 0); }
990
991 #[test]
992 fn test_compaction_strategy_disabled() {
993 let strategy = CompactionStrategy::Disabled;
994 assert!(!strategy.should_compact(100_000, 128_000, 5));
995 assert!(!strategy.should_compact(120_000, 128_000, 10));
996 assert!(!strategy.should_compact(0, 128_000, 1));
997 }
998
999 #[test]
1000 fn test_compaction_strategy_threshold() {
1001 let strategy = CompactionStrategy::Threshold(0.8);
1002
1003 assert!(!strategy.should_compact(100_000, 128_000, 1));
1005
1006 assert!(strategy.should_compact(102_400, 128_000, 1));
1008
1009 assert!(strategy.should_compact(120_000, 128_000, 1));
1011
1012 assert!(!strategy.should_compact(100_000, 0, 1));
1014 }
1015
1016 #[test]
1017 fn test_compaction_strategy_every_n_turns() {
1018 let strategy = CompactionStrategy::EveryNTurns(5);
1019
1020 assert!(!strategy.should_compact(0, 128_000, 0));
1022 assert!(!strategy.should_compact(0, 128_000, 3));
1023 assert!(!strategy.should_compact(0, 128_000, 4));
1024
1025 assert!(strategy.should_compact(0, 128_000, 5));
1027 assert!(strategy.should_compact(0, 128_000, 10));
1028 assert!(strategy.should_compact(0, 128_000, 15));
1029
1030 assert!(!strategy.should_compact(0, 128_000, 6));
1032 assert!(!strategy.should_compact(0, 128_000, 9));
1033 }
1034
1035 #[test]
1036 fn test_compaction_strategy_absolute_tokens() {
1037 let strategy = CompactionStrategy::AbsoluteTokens(100_000);
1038
1039 assert!(!strategy.should_compact(50_000, 128_000, 0));
1041 assert!(!strategy.should_compact(99_999, 128_000, 0));
1042
1043 assert!(strategy.should_compact(100_000, 128_000, 0));
1045
1046 assert!(strategy.should_compact(150_000, 128_000, 0));
1048 }
1049
1050 #[test]
1051 fn test_compacted_context_basic() {
1052 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1053 let ctx = CompactedContext::new(
1054 "Test summary".to_string(),
1055 vec![make_user_message("test")],
1056 10,
1057 metadata,
1058 );
1059
1060 assert_eq!(ctx.summary(), "Test summary");
1061 assert_eq!(ctx.kept_count(), 1);
1062 assert_eq!(ctx.compacted_count(), 10);
1063 assert!(ctx.is_success());
1064 assert_eq!(ctx.metadata().tokens_saved(), 500);
1065 }
1066
1067 #[test]
1068 fn test_compacted_context_with_empty_summary() {
1069 let metadata = CompactionMetadata::new(100, 100, 0, 2, 0.5);
1070 let ctx = CompactedContext::new(
1071 String::new(), vec![make_user_message("test1"), make_user_message("test2")],
1073 0,
1074 metadata,
1075 );
1076
1077 assert_eq!(ctx.summary(), "");
1078 assert_eq!(ctx.kept_count(), 2);
1079 assert_eq!(ctx.compacted_count(), 0);
1080 }
1081
1082 #[test]
1083 fn test_llm_compactor_config_builder() {
1084 use crate::providers::OpenAiProvider;
1086 let provider = OpenAiProvider::new();
1087 let model = make_test_model();
1088 let compactor = LlmCompactor::new(model, Arc::new(provider))
1089 .with_keep_recent(6)
1090 .with_max_batch(25)
1091 .with_target_ratio(0.6);
1092
1093 assert!(compactor.config.keep_recent >= 4);
1094 assert!(compactor.config.max_batch >= 20);
1095 }
1096
1097 #[test]
1098 fn test_compaction_error_display() {
1099 let err = CompactionError::NoMessagesToCompact;
1100 assert_eq!(err.to_string(), "No messages to compact");
1101
1102 let err = CompactionError::TooFewMessages {
1103 total: 3,
1104 keep_recent: 5,
1105 };
1106 assert!(err.to_string().contains("3"));
1107 assert!(err.to_string().contains("6"));
1109
1110 let err = CompactionError::CompactionDisabled;
1111 assert_eq!(err.to_string(), "Compaction is disabled");
1112
1113 let err = CompactionError::NoContextWindow;
1114 assert_eq!(err.to_string(), "Context window not configured");
1115
1116 let err = CompactionError::LlmError("API timeout".to_string());
1117 assert!(err.to_string().contains("API timeout"));
1118 }
1119
1120 #[test]
1121 fn test_compaction_manager_default() {
1122 let manager = CompactionManager::default();
1123 assert!(matches!(
1124 manager.strategy(),
1125 CompactionStrategy::Threshold(_)
1126 ));
1127 assert_eq!(manager.config().keep_recent, 4);
1128 }
1129
1130 #[test]
1131 fn test_compaction_manager_with_custom_strategy() {
1132 let strategy = CompactionStrategy::AbsoluteTokens(50_000);
1133 let manager = CompactionManager::new(strategy, 200_000);
1134
1135 assert!(!manager.should_compact(30_000, 0));
1137
1138 assert!(manager.should_compact(60_000, 0));
1140 }
1141
1142 #[test]
1143 fn test_compaction_manager_with_config() {
1144 let config = CompactionConfig::new()
1145 .with_keep_recent(8)
1146 .with_target_ratio(0.4);
1147
1148 let manager =
1149 CompactionManager::with_config(CompactionStrategy::default(), 128_000, config);
1150
1151 assert_eq!(manager.config().keep_recent, 8);
1152 assert!((manager.config().target_ratio - 0.4).abs() < 0.001);
1153 }
1154
1155 #[test]
1156 fn test_compaction_manager_should_compact_integration() {
1157 let manager = CompactionManager::new(CompactionStrategy::Threshold(0.75), 100_000);
1158
1159 assert!(!manager.should_compact(70_000, 0));
1161
1162 assert!(manager.should_compact(75_000, 0));
1164
1165 assert!(manager.should_compact(80_000, 0));
1167 assert!(manager.should_compact(100_000, 0));
1168 }
1169
1170 #[test]
1171 fn test_compaction_manager_no_compactor_set() {
1172 let manager = CompactionManager::new(CompactionStrategy::EveryNTurns(5), 128_000);
1173
1174 assert!(manager.should_compact(0, 5)); }
1178
1179 #[test]
1180 fn test_token_estimation_helper() {
1181 use crate::providers::OpenAiProvider;
1182 let provider = OpenAiProvider::new();
1183 let model = make_test_model();
1184 let compactor = LlmCompactor::new(model, Arc::new(provider));
1185
1186 let messages = vec![
1187 make_user_message("Hello world, this is a test message."),
1188 make_assistant_message("This is a response with some content."),
1189 ];
1190
1191 let tokens = compactor.estimate_tokens(&messages);
1192 assert!(tokens > 0, "Should estimate tokens for messages");
1193 }
1194
1195 #[test]
1196 fn test_compaction_config_custom_instruction() {
1197 let config = CompactionConfig::new()
1198 .with_custom_instruction("Focus on code changes and technical decisions");
1199
1200 assert!(config.custom_instruction.is_some());
1201 assert!(config.custom_instruction.unwrap().contains("code changes"));
1202 }
1203
1204 #[test]
1205 fn test_compaction_metadata_timestamp_is_set() {
1206 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1207 assert!(metadata.timestamp <= Utc::now());
1208 }
1209
1210 #[test]
1211 fn test_compaction_ratio_achievement() {
1212 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1214 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1215
1216 let metadata = CompactionMetadata::new(1000, 300, 10, 5, 0.5);
1218 assert!((metadata.actual_ratio - 0.3).abs() < 0.001);
1219 assert!(metadata.compression_factor() > 0.5);
1220
1221 let metadata = CompactionMetadata::new(1000, 700, 10, 5, 0.5);
1223 assert!((metadata.actual_ratio - 0.7).abs() < 0.001);
1224 assert!(metadata.compression_factor() < 0.5);
1225 }
1226
1227 #[test]
1228 fn test_compaction_manager_config_updates() {
1229 let mut manager = CompactionManager::default();
1230
1231 let new_config = CompactionConfig::new()
1232 .with_keep_recent(12)
1233 .with_target_ratio(0.3);
1234
1235 manager.set_config(new_config);
1236
1237 assert_eq!(manager.config().keep_recent, 12);
1238 assert!((manager.config().target_ratio - 0.3).abs() < 0.001);
1239 }
1240
1241 #[test]
1242 fn test_llm_compactor_has_summarize_branch() {
1243 use crate::providers::OpenAiProvider;
1245 let provider = OpenAiProvider::new();
1246 let model = make_test_model();
1247 let compactor = LlmCompactor::new(model, Arc::new(provider));
1248
1249 let messages = vec![
1251 make_user_message("Test message 1"),
1252 make_assistant_message("Test response 1"),
1253 make_user_message("Test message 2"),
1254 ];
1255
1256 let branch_name = "test-branch";
1259 let _future = compactor.summarize_branch(&messages, branch_name);
1261 }
1262
1263 #[test]
1264 fn test_summarize_branch_returns_error_on_llm_failure() {
1265 use crate::providers::OpenAiProvider;
1267 let provider = OpenAiProvider::new();
1268 let model = make_test_model();
1269 let compactor = LlmCompactor::new(model, Arc::new(provider));
1270
1271 let messages: Vec<Message> = vec![];
1273
1274 let _future = compactor.summarize_branch(&messages, "empty-branch");
1277 }
1278}