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 chrono::{DateTime, Utc};
95use serde::{Deserialize, Serialize};
96use std::future::Future;
97use std::pin::Pin;
98use std::sync::Arc;
99use std::time::Duration;
100
101#[derive(Debug, Clone)]
103pub struct CompactionConfig {
104 pub keep_recent: usize,
106 pub max_batch: usize,
108 pub target_ratio: f32,
110 pub summary_max_tokens: usize,
112 pub temperature: f32,
114 pub timeout: Duration,
116 pub custom_instruction: Option<String>,
118}
119
120impl CompactionConfig {
121 pub fn new() -> Self {
123 Self {
124 keep_recent: 4,
125 max_batch: 20,
126 target_ratio: 0.5,
127 summary_max_tokens: 1024,
128 temperature: 0.3,
129 timeout: Duration::from_secs(60),
130 custom_instruction: None,
131 }
132 }
133
134 pub fn with_keep_recent(mut self, count: usize) -> Self {
136 self.keep_recent = count;
137 self
138 }
139
140 pub fn with_max_batch(mut self, count: usize) -> Self {
142 self.max_batch = count;
143 self
144 }
145
146 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
148 self.target_ratio = ratio.clamp(0.1, 0.9);
149 self
150 }
151
152 pub fn with_summary_max_tokens(mut self, tokens: usize) -> Self {
154 self.summary_max_tokens = tokens;
155 self
156 }
157
158 pub fn with_temperature(mut self, temp: f32) -> Self {
160 self.temperature = temp.clamp(0.0, 1.0);
161 self
162 }
163
164 pub fn with_timeout(mut self, timeout: Duration) -> Self {
166 self.timeout = timeout;
167 self
168 }
169
170 pub fn with_custom_instruction(mut self, instruction: impl Into<String>) -> Self {
172 self.custom_instruction = Some(instruction.into());
173 self
174 }
175}
176
177impl Default for CompactionConfig {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CompactionMetadata {
186 pub original_tokens: usize,
188 pub compacted_tokens: usize,
190 pub messages_compacted: usize,
192 pub messages_kept: usize,
194 pub timestamp: DateTime<Utc>,
196 pub target_ratio: f32,
198 pub actual_ratio: f32,
200 pub success: bool,
202 pub error: Option<String>,
204}
205
206impl CompactionMetadata {
207 pub fn new(
209 original_tokens: usize,
210 compacted_tokens: usize,
211 messages_compacted: usize,
212 messages_kept: usize,
213 target_ratio: f32,
214 ) -> Self {
215 let actual_ratio = if original_tokens > 0 {
216 compacted_tokens as f32 / original_tokens as f32
217 } else {
218 1.0
219 };
220
221 Self {
222 original_tokens,
223 compacted_tokens,
224 messages_compacted,
225 messages_kept,
226 timestamp: Utc::now(),
227 target_ratio,
228 actual_ratio,
229 success: true,
230 error: None,
231 }
232 }
233
234 pub fn failed(
236 original_tokens: usize,
237 messages_compacted: usize,
238 target_ratio: f32,
239 error: impl Into<String>,
240 ) -> Self {
241 Self {
242 original_tokens,
243 compacted_tokens: original_tokens,
244 messages_compacted,
245 messages_kept: 0,
246 timestamp: Utc::now(),
247 target_ratio,
248 actual_ratio: 1.0,
249 success: false,
250 error: Some(error.into()),
251 }
252 }
253
254 pub fn compression_factor(&self) -> f32 {
256 if self.actual_ratio > 0.0 {
257 1.0 - self.actual_ratio
258 } else {
259 0.0
260 }
261 }
262
263 pub fn tokens_saved(&self) -> usize {
265 self.original_tokens.saturating_sub(self.compacted_tokens)
266 }
267}
268
269#[derive(Debug, Clone)]
271pub struct CompactedContext {
272 pub summary: String,
274 pub kept_messages: Vec<Message>,
276 pub compacted_count: usize,
278 pub metadata: CompactionMetadata,
280}
281
282impl CompactedContext {
283 pub fn new(
285 summary: String,
286 kept_messages: Vec<Message>,
287 compacted_count: usize,
288 metadata: CompactionMetadata,
289 ) -> Self {
290 Self {
291 summary,
292 kept_messages,
293 compacted_count,
294 metadata,
295 }
296 }
297
298 pub fn summary(&self) -> &str {
300 &self.summary
301 }
302
303 pub fn kept_count(&self) -> usize {
305 self.kept_messages.len()
306 }
307
308 pub fn compacted_count(&self) -> usize {
310 self.compacted_count
311 }
312
313 pub fn metadata(&self) -> &CompactionMetadata {
315 &self.metadata
316 }
317
318 pub fn is_success(&self) -> bool {
320 self.metadata.success
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
326pub enum CompactionStrategy {
327 Disabled,
329 Threshold(f32),
331 EveryNTurns(usize),
333 AbsoluteTokens(usize),
335}
336
337impl CompactionStrategy {
338 pub fn should_compact(
348 &self,
349 context_tokens: usize,
350 context_window: usize,
351 iteration: usize,
352 ) -> bool {
353 match self {
354 CompactionStrategy::Disabled => false,
355 CompactionStrategy::Threshold(threshold) => {
356 if context_window == 0 {
357 return false;
358 }
359 let usage = context_tokens as f32 / context_window as f32;
360 usage >= *threshold
361 }
362 CompactionStrategy::EveryNTurns(n) => iteration > 0 && iteration.is_multiple_of(*n),
363 CompactionStrategy::AbsoluteTokens(max_tokens) => context_tokens >= *max_tokens,
364 }
365 }
366}
367
368impl Default for CompactionStrategy {
369 fn default() -> Self {
370 CompactionStrategy::Threshold(0.8)
371 }
372}
373
374#[derive(Debug, Clone)]
376pub enum CompactionError {
377 LlmError(String),
379 NoMessagesToCompact,
381 TooFewMessages { total: usize, keep_recent: usize },
383 CompactionDisabled,
385 NoContextWindow,
387}
388
389impl std::fmt::Display for CompactionError {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 CompactionError::LlmError(msg) => write!(f, "LLM compaction failed: {}", msg),
393 CompactionError::NoMessagesToCompact => write!(f, "No messages to compact"),
394 CompactionError::TooFewMessages { total, keep_recent } => {
395 write!(
396 f,
397 "Not enough messages ({}) to compact (need at least {} for keep_recent)",
398 total,
399 keep_recent + 1
400 )
401 }
402 CompactionError::CompactionDisabled => write!(f, "Compaction is disabled"),
403 CompactionError::NoContextWindow => write!(f, "Context window not configured"),
404 }
405 }
406}
407
408impl std::error::Error for CompactionError {}
409
410pub trait Compactor: Send + Sync {
412 fn compact<'a>(
414 &'a self,
415 messages: &'a [Message],
416 instruction: Option<&'a str>,
417 ) -> Pin<
418 Box<
419 dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
420 >,
421 >;
422
423 fn estimate_tokens(&self, messages: &[Message]) -> usize {
425 messages
426 .iter()
427 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
428 .sum()
429 }
430}
431
432pub trait ContextTransformer: Send + Sync {
437 fn transform<'a>(
439 &'a self,
440 context: &'a Context,
441 model: &'a Model,
442 ) -> Pin<Box<dyn Future<Output = Context> + Send + 'a>>;
443}
444
445pub struct NoopContextTransformer;
447
448impl ContextTransformer for NoopContextTransformer {
449 fn transform<'a>(
450 &'a self,
451 context: &'a Context,
452 _model: &'a Model,
453 ) -> Pin<Box<dyn Future<Output = Context> + Send + 'a>> {
454 Box::pin(async move { context.clone() })
455 }
456}
457
458pub struct LlmCompactor {
460 model: Model,
461 _provider: Arc<dyn Provider>,
462 config: CompactionConfig,
463}
464
465impl LlmCompactor {
466 pub fn new(model: Model, provider: Arc<dyn Provider>) -> Self {
468 Self {
469 model,
470 _provider: provider,
471 config: CompactionConfig::new(),
472 }
473 }
474
475 pub fn with_config(
477 model: Model,
478 provider: Arc<dyn Provider>,
479 config: CompactionConfig,
480 ) -> Self {
481 Self {
482 model,
483 _provider: provider,
484 config,
485 }
486 }
487
488 pub fn with_keep_recent(mut self, count: usize) -> Self {
490 self.config.keep_recent = count;
491 self
492 }
493
494 pub fn with_max_batch(mut self, count: usize) -> Self {
496 self.config.max_batch = count;
497 self
498 }
499
500 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
502 self.config.target_ratio = ratio.clamp(0.1, 0.9);
503 self
504 }
505
506 fn build_summarize_prompt(&self, messages: &[Message], instruction: Option<&str>) -> String {
508 let mut prompt = String::new();
509
510 prompt.push_str("Summarize the following conversation concisely. ");
511 prompt.push_str("Capture the key points, decisions, and any ongoing tasks or context.\n\n");
512
513 if let Some(instr) = instruction {
514 prompt.push_str(&format!("Focus areas: {}\n\n", instr));
515 } else if let Some(ref custom_instr) = self.config.custom_instruction {
516 prompt.push_str(&format!("Focus areas: {}\n\n", custom_instr));
517 }
518
519 prompt.push_str("## Conversation to summarize:\n");
520
521 for (i, msg) in messages.iter().enumerate() {
522 let role = match msg {
523 Message::User(_) => "User",
524 Message::Assistant(_) => "Assistant",
525 Message::ToolResult(_) => "Tool",
526 };
527 let content = msg.text_content().unwrap_or_default();
528 let content_preview = safe_truncate(&content, 500);
529 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
530 }
531
532 prompt.push_str("\n## Summary:\n");
533 prompt
534 .push_str("Provide a concise summary that captures the essence of this conversation.");
535
536 prompt
537 }
538
539 async fn compact_with_fallback(
541 &self,
542 old_messages: &[Message],
543 recent_messages: &[Message],
544 instruction: Option<&str>,
545 ) -> std::result::Result<CompactedContext, CompactionError> {
546 match self.summarize_with_llm(old_messages, instruction).await {
548 Ok(summary) => {
549 let mut summary_msg =
551 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
552 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
553 "[Previous conversation summarized: {}]",
554 summary
555 )))];
556
557 let mut kept = vec![Message::Assistant(summary_msg)];
559 kept.extend(recent_messages.iter().cloned());
560
561 let original_tokens = self.estimate_tokens(old_messages);
562 let compacted_tokens = self.estimate_tokens(&kept);
563 let kept_len = kept.len();
564
565 Ok(CompactedContext::new(
566 summary,
567 kept,
568 old_messages.len(),
569 CompactionMetadata::new(
570 original_tokens,
571 compacted_tokens,
572 old_messages.len(),
573 kept_len,
574 self.config.target_ratio,
575 ),
576 ))
577 }
578 Err(llm_err) => {
579 self.compact_fallback(old_messages, recent_messages)
581 .await
582 .map_err(|_| CompactionError::LlmError(llm_err.to_string()))
583 }
584 }
585 }
586
587 async fn summarize_with_llm(
589 &self,
590 messages: &[Message],
591 instruction: Option<&str>,
592 ) -> std::result::Result<String, CompactionError> {
593 let prompt = self.build_summarize_prompt(messages, instruction);
594
595 let mut context = Context::new();
596 context.set_system_prompt(
597 "You are a helpful assistant that summarizes conversations concisely.",
598 );
599 context.add_message(Message::User(UserMessage::new(prompt)));
600
601 let options = StreamOptions {
602 temperature: Some(self.config.temperature as f64),
603 max_tokens: Some(self.config.summary_max_tokens),
604 ..Default::default()
605 };
606
607 let summary_message = complete(&self.model, &context, Some(options))
608 .await
609 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
610
611 Ok(summary_message.text_content())
612 }
613
614 async fn compact_fallback(
616 &self,
617 old_messages: &[Message],
618 recent_messages: &[Message],
619 ) -> std::result::Result<CompactedContext, CompactionError> {
620 let mut summary_parts = Vec::new();
622
623 if old_messages.len() > 2 {
624 if let Some(first) = old_messages.first() {
626 let content = first.text_content().unwrap_or_default();
627 let preview = safe_truncate(&content, 200);
628 summary_parts.push(format!("Started discussing: {}", preview));
629 }
630
631 if let Some(last) = old_messages.last() {
633 let content = last.text_content().unwrap_or_default();
634 let preview = safe_truncate(&content, 200);
635 summary_parts.push(format!("Ended with: {}", preview));
636 }
637
638 summary_parts.push(format!(
639 "({} messages omitted)",
640 old_messages.len().saturating_sub(2)
641 ));
642 } else if !old_messages.is_empty() {
643 if let Some(msg) = old_messages.first() {
645 let content = msg.text_content().unwrap_or_default();
646 summary_parts.push(format!("Conversation started: {}", content));
647 }
648 }
649
650 let summary = summary_parts.join(" ");
651
652 let mut summary_msg =
653 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
654 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
655 "[Previous conversation summary: {}]",
656 summary
657 )))];
658
659 let mut kept = vec![Message::Assistant(summary_msg)];
660 kept.extend(recent_messages.iter().cloned());
661
662 let original_tokens = self.estimate_tokens(old_messages);
663 let compacted_tokens = self.estimate_tokens(&kept);
664 let kept_len = kept.len();
665
666 Ok(CompactedContext::new(
667 summary,
668 kept,
669 old_messages.len(),
670 CompactionMetadata::new(
671 original_tokens,
672 compacted_tokens,
673 old_messages.len(),
674 kept_len,
675 self.config.target_ratio,
676 ),
677 ))
678 }
679}
680
681impl Compactor for LlmCompactor {
682 fn compact<'a>(
683 &'a self,
684 messages: &'a [Message],
685 instruction: Option<&'a str>,
686 ) -> Pin<
687 Box<
688 dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
689 >,
690 > {
691 Box::pin(async move {
692 if messages.is_empty() {
694 return Err(CompactionError::NoMessagesToCompact);
695 }
696
697 if messages.len() <= self.config.keep_recent {
698 let original_tokens = self.estimate_tokens(messages);
700 return Ok(CompactedContext::new(
701 String::new(),
702 messages.to_vec(),
703 0,
704 CompactionMetadata::new(
705 original_tokens,
706 original_tokens,
707 0,
708 messages.len(),
709 self.config.target_ratio,
710 ),
711 ));
712 }
713
714 let keep_count = self.config.keep_recent.min(messages.len());
722 let raw_split = messages.len() - keep_count;
723 let split = align_split_boundary(messages, raw_split);
724 let old_messages: Vec<Message> = messages[..split].to_vec();
725 let recent_messages: Vec<Message> = messages[split..].to_vec();
726
727 if old_messages.is_empty() {
728 return Err(CompactionError::NoMessagesToCompact);
729 }
730
731 self.compact_with_fallback(&old_messages, &recent_messages, instruction)
733 .await
734 })
735 }
736}
737
738pub(crate) fn align_split_boundary(messages: &[crate::Message], raw_split: usize) -> usize {
756 use crate::{ContentBlock, Message};
757
758 if raw_split == 0 || raw_split >= messages.len() {
759 return raw_split;
760 }
761
762 let is_boundary = |msg: &Message| match msg {
763 Message::User(_) => true,
764 Message::Assistant(a) => !a
765 .content
766 .iter()
767 .any(|b| matches!(b, ContentBlock::ToolCall(_))),
768 Message::ToolResult(_) => false,
769 };
770
771 let mut i = raw_split;
774 while i > 0 && !is_boundary(&messages[i - 1]) {
775 i -= 1;
776 }
777 i
778}
779
780impl LlmCompactor {
782 pub async fn summarize_branch(
787 &self,
788 messages: &[Message],
789 branch_name: &str,
790 ) -> std::result::Result<String, CompactionError> {
791 if messages.is_empty() {
792 return Ok(format!("Branch '{}' is empty", branch_name));
793 }
794
795 let mut prompt = String::new();
796 prompt.push_str(&format!(
797 "Summarize the conversation branch '{}' concisely. ",
798 branch_name
799 ));
800 prompt.push_str("Focus on: what was discussed, decisions made, and current state.\n\n");
801
802 prompt.push_str("## Branch messages:\n");
803 for (i, msg) in messages.iter().enumerate() {
804 let role = match msg {
805 Message::User(_) => "User",
806 Message::Assistant(_) => "Assistant",
807 Message::ToolResult(_) => "Tool",
808 };
809 let content = msg.text_content().unwrap_or_default();
810 let content_preview = safe_truncate(&content, 300);
811 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
812 }
813
814 prompt.push_str("\n## Summary (be concise):\n");
815
816 let mut context = Context::new();
818 context.set_system_prompt(
819 "You are a helpful assistant that summarizes conversation branches. ",
820 );
821 context.add_message(Message::User(UserMessage::new(prompt)));
822
823 let options = StreamOptions {
824 temperature: Some(0.3),
825 max_tokens: Some(512),
826 ..Default::default()
827 };
828
829 let summary_message = complete(&self.model, &context, Some(options))
830 .await
831 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
832
833 Ok(summary_message.text_content())
834 }
835}
836
837pub struct CompactionManager {
839 strategy: CompactionStrategy,
840 compactor: Option<Arc<dyn Compactor>>,
841 context_window: usize,
842 config: CompactionConfig,
843}
844
845impl CompactionManager {
846 pub fn new(strategy: CompactionStrategy, context_window: usize) -> Self {
848 Self {
849 strategy,
850 compactor: None,
851 context_window,
852 config: CompactionConfig::new(),
853 }
854 }
855
856 pub fn with_config(
858 strategy: CompactionStrategy,
859 context_window: usize,
860 config: CompactionConfig,
861 ) -> Self {
862 Self {
863 strategy,
864 compactor: None,
865 context_window,
866 config,
867 }
868 }
869
870 pub fn with_compactor<C: Compactor + 'static>(mut self, compactor: Arc<C>) -> Self {
872 self.compactor = Some(compactor);
873 self
874 }
875
876 pub fn set_compactor(&mut self, compactor: Arc<dyn Compactor>) {
878 self.compactor = Some(compactor);
879 }
880
881 pub fn should_compact(&self, context_tokens: usize, iteration: usize) -> bool {
883 self.strategy
884 .should_compact(context_tokens, self.context_window, iteration)
885 }
886
887 pub fn strategy(&self) -> &CompactionStrategy {
889 &self.strategy
890 }
891
892 pub fn config(&self) -> &CompactionConfig {
894 &self.config
895 }
896
897 pub fn set_config(&mut self, config: CompactionConfig) {
899 self.config = config;
900 }
901
902 pub async fn compact_if_needed(
904 &self,
905 messages: &[Message],
906 instruction: Option<&str>,
907 context_tokens: usize,
908 iteration: usize,
909 ) -> std::result::Result<Option<CompactedContext>, CompactionError> {
910 if !self.should_compact(context_tokens, iteration) {
911 return Ok(None);
912 }
913
914 let compactor = match &self.compactor {
915 Some(c) => c,
916 None => return Err(CompactionError::CompactionDisabled),
917 };
918
919 let result = compactor.compact(messages, instruction).await?;
920 Ok(Some(result))
921 }
922
923 pub async fn compact_now(
925 &self,
926 messages: &[Message],
927 instruction: Option<&str>,
928 ) -> std::result::Result<CompactedContext, CompactionError> {
929 let compactor = match &self.compactor {
930 Some(c) => c,
931 None => return Err(CompactionError::CompactionDisabled),
932 };
933
934 compactor.compact(messages, instruction).await
935 }
936
937 pub fn estimate_tokens(&self, messages: &[Message]) -> usize {
939 messages
940 .iter()
941 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
942 .sum()
943 }
944}
945
946impl Default for CompactionManager {
947 fn default() -> Self {
948 Self::new(CompactionStrategy::default(), 128_000)
949 }
950}
951
952#[cfg(test)]
957mod tests {
958 use super::*;
959
960 fn make_user_message(content: &str) -> Message {
962 Message::user(content)
963 }
964
965 fn make_assistant_message(content: &str) -> Message {
967 Message::Assistant({
968 let mut msg = AssistantMessage::new(Api::AnthropicMessages, "test", "test-model");
969 msg.content = vec![ContentBlock::Text(TextContent::new(content))];
970 msg
971 })
972 }
973
974 fn make_test_model() -> Model {
976 Model::new(
977 "test-model",
978 "Test Model",
979 Api::AnthropicMessages,
980 "test",
981 "https://test.example.com",
982 )
983 }
984
985 #[test]
986 fn test_compaction_config_defaults() {
987 let config = CompactionConfig::new();
988 assert_eq!(config.keep_recent, 4);
989 assert_eq!(config.max_batch, 20);
990 assert!((config.target_ratio - 0.5).abs() < 0.001);
991 assert_eq!(config.summary_max_tokens, 1024);
992 assert!((config.temperature - 0.3).abs() < 0.001);
993 }
994
995 #[test]
996 fn test_compaction_config_builder_pattern() {
997 let config = CompactionConfig::new()
998 .with_keep_recent(10)
999 .with_max_batch(30)
1000 .with_target_ratio(0.3)
1001 .with_temperature(0.5);
1002
1003 assert_eq!(config.keep_recent, 10);
1004 assert_eq!(config.max_batch, 30);
1005 assert!((config.target_ratio - 0.3).abs() < 0.001);
1006 assert!((config.temperature - 0.5).abs() < 0.001);
1007 }
1008
1009 #[test]
1010 fn test_compaction_config_ratio_clamping() {
1011 let config = CompactionConfig::new().with_target_ratio(1.5);
1013 assert!((config.target_ratio - 0.9).abs() < 0.001);
1014
1015 let config = CompactionConfig::new().with_target_ratio(-0.5);
1017 assert!((config.target_ratio - 0.1).abs() < 0.001);
1018 }
1019
1020 #[test]
1021 fn test_compaction_metadata_success() {
1022 let metadata = CompactionMetadata::new(
1023 1000, 500, 10, 5, 0.5, );
1029
1030 assert!(metadata.success);
1031 assert_eq!(metadata.original_tokens, 1000);
1032 assert_eq!(metadata.compacted_tokens, 500);
1033 assert_eq!(metadata.messages_compacted, 10);
1034 assert_eq!(metadata.messages_kept, 5);
1035 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1036 assert!((metadata.compression_factor() - 0.5).abs() < 0.001);
1037 assert_eq!(metadata.tokens_saved(), 500);
1038 assert!(metadata.error.is_none());
1039 }
1040
1041 #[test]
1042 fn test_compaction_metadata_failure() {
1043 let metadata = CompactionError::LlmError("test error".to_string());
1044
1045 assert!(metadata.to_string().contains("test error"));
1047 }
1048
1049 #[test]
1050 fn test_compaction_metadata_compression_factor() {
1051 let metadata = CompactionMetadata::new(0, 0, 0, 0, 0.5);
1053 assert!((metadata.actual_ratio - 1.0).abs() < 0.001);
1054 assert!((metadata.compression_factor() - 0.0).abs() < 0.001);
1055
1056 let metadata = CompactionMetadata::new(1000, 100, 10, 5, 0.5);
1058 assert!((metadata.compression_factor() - 0.9).abs() < 0.001);
1059 }
1060
1061 #[test]
1062 fn test_compaction_metadata_tokens_saved() {
1063 let metadata = CompactionMetadata::new(1000, 400, 10, 5, 0.5);
1065 assert_eq!(metadata.tokens_saved(), 600);
1066
1067 let metadata = CompactionMetadata::new(1000, 1000, 0, 0, 0.5);
1069 assert_eq!(metadata.tokens_saved(), 0);
1070
1071 let metadata = CompactionMetadata::new(500, 600, 5, 3, 0.5);
1073 assert_eq!(metadata.tokens_saved(), 0); }
1075
1076 #[test]
1077 fn test_compaction_strategy_disabled() {
1078 let strategy = CompactionStrategy::Disabled;
1079 assert!(!strategy.should_compact(100_000, 128_000, 5));
1080 assert!(!strategy.should_compact(120_000, 128_000, 10));
1081 assert!(!strategy.should_compact(0, 128_000, 1));
1082 }
1083
1084 #[test]
1085 fn test_compaction_strategy_threshold() {
1086 let strategy = CompactionStrategy::Threshold(0.8);
1087
1088 assert!(!strategy.should_compact(100_000, 128_000, 1));
1090
1091 assert!(strategy.should_compact(102_400, 128_000, 1));
1093
1094 assert!(strategy.should_compact(120_000, 128_000, 1));
1096
1097 assert!(!strategy.should_compact(100_000, 0, 1));
1099 }
1100
1101 #[test]
1102 fn test_compaction_strategy_every_n_turns() {
1103 let strategy = CompactionStrategy::EveryNTurns(5);
1104
1105 assert!(!strategy.should_compact(0, 128_000, 0));
1107 assert!(!strategy.should_compact(0, 128_000, 3));
1108 assert!(!strategy.should_compact(0, 128_000, 4));
1109
1110 assert!(strategy.should_compact(0, 128_000, 5));
1112 assert!(strategy.should_compact(0, 128_000, 10));
1113 assert!(strategy.should_compact(0, 128_000, 15));
1114
1115 assert!(!strategy.should_compact(0, 128_000, 6));
1117 assert!(!strategy.should_compact(0, 128_000, 9));
1118 }
1119
1120 #[test]
1121 fn test_compaction_strategy_absolute_tokens() {
1122 let strategy = CompactionStrategy::AbsoluteTokens(100_000);
1123
1124 assert!(!strategy.should_compact(50_000, 128_000, 0));
1126 assert!(!strategy.should_compact(99_999, 128_000, 0));
1127
1128 assert!(strategy.should_compact(100_000, 128_000, 0));
1130
1131 assert!(strategy.should_compact(150_000, 128_000, 0));
1133 }
1134
1135 #[test]
1136 fn test_compacted_context_basic() {
1137 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1138 let ctx = CompactedContext::new(
1139 "Test summary".to_string(),
1140 vec![make_user_message("test")],
1141 10,
1142 metadata,
1143 );
1144
1145 assert_eq!(ctx.summary(), "Test summary");
1146 assert_eq!(ctx.kept_count(), 1);
1147 assert_eq!(ctx.compacted_count(), 10);
1148 assert!(ctx.is_success());
1149 assert_eq!(ctx.metadata().tokens_saved(), 500);
1150 }
1151
1152 #[test]
1153 fn test_compacted_context_with_empty_summary() {
1154 let metadata = CompactionMetadata::new(100, 100, 0, 2, 0.5);
1155 let ctx = CompactedContext::new(
1156 String::new(), vec![make_user_message("test1"), make_user_message("test2")],
1158 0,
1159 metadata,
1160 );
1161
1162 assert_eq!(ctx.summary(), "");
1163 assert_eq!(ctx.kept_count(), 2);
1164 assert_eq!(ctx.compacted_count(), 0);
1165 }
1166
1167 #[test]
1168 fn test_llm_compactor_config_builder() {
1169 use crate::providers::OpenAiProvider;
1171 let provider = OpenAiProvider::new();
1172 let model = make_test_model();
1173 let compactor = LlmCompactor::new(model, Arc::new(provider))
1174 .with_keep_recent(6)
1175 .with_max_batch(25)
1176 .with_target_ratio(0.6);
1177
1178 assert!(compactor.config.keep_recent >= 4);
1179 assert!(compactor.config.max_batch >= 20);
1180 }
1181
1182 #[test]
1183 fn test_compaction_error_display() {
1184 let err = CompactionError::NoMessagesToCompact;
1185 assert_eq!(err.to_string(), "No messages to compact");
1186
1187 let err = CompactionError::TooFewMessages {
1188 total: 3,
1189 keep_recent: 5,
1190 };
1191 assert!(err.to_string().contains("3"));
1192 assert!(err.to_string().contains("6"));
1194
1195 let err = CompactionError::CompactionDisabled;
1196 assert_eq!(err.to_string(), "Compaction is disabled");
1197
1198 let err = CompactionError::NoContextWindow;
1199 assert_eq!(err.to_string(), "Context window not configured");
1200
1201 let err = CompactionError::LlmError("API timeout".to_string());
1202 assert!(err.to_string().contains("API timeout"));
1203 }
1204
1205 #[test]
1206 fn test_compaction_manager_default() {
1207 let manager = CompactionManager::default();
1208 assert!(matches!(
1209 manager.strategy(),
1210 CompactionStrategy::Threshold(_)
1211 ));
1212 assert_eq!(manager.config().keep_recent, 4);
1213 }
1214
1215 #[test]
1216 fn test_compaction_manager_with_custom_strategy() {
1217 let strategy = CompactionStrategy::AbsoluteTokens(50_000);
1218 let manager = CompactionManager::new(strategy, 200_000);
1219
1220 assert!(!manager.should_compact(30_000, 0));
1222
1223 assert!(manager.should_compact(60_000, 0));
1225 }
1226
1227 #[test]
1228 fn test_compaction_manager_with_config() {
1229 let config = CompactionConfig::new()
1230 .with_keep_recent(8)
1231 .with_target_ratio(0.4);
1232
1233 let manager =
1234 CompactionManager::with_config(CompactionStrategy::default(), 128_000, config);
1235
1236 assert_eq!(manager.config().keep_recent, 8);
1237 assert!((manager.config().target_ratio - 0.4).abs() < 0.001);
1238 }
1239
1240 #[test]
1241 fn test_compaction_manager_should_compact_integration() {
1242 let manager = CompactionManager::new(CompactionStrategy::Threshold(0.75), 100_000);
1243
1244 assert!(!manager.should_compact(70_000, 0));
1246
1247 assert!(manager.should_compact(75_000, 0));
1249
1250 assert!(manager.should_compact(80_000, 0));
1252 assert!(manager.should_compact(100_000, 0));
1253 }
1254
1255 #[test]
1256 fn test_compaction_manager_no_compactor_set() {
1257 let manager = CompactionManager::new(CompactionStrategy::EveryNTurns(5), 128_000);
1258
1259 assert!(manager.should_compact(0, 5)); }
1263
1264 #[test]
1265 fn test_token_estimation_helper() {
1266 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![
1272 make_user_message("Hello world, this is a test message."),
1273 make_assistant_message("This is a response with some content."),
1274 ];
1275
1276 let tokens = compactor.estimate_tokens(&messages);
1277 assert!(tokens > 0, "Should estimate tokens for messages");
1278 }
1279
1280 #[test]
1281 fn test_compaction_config_custom_instruction() {
1282 let config = CompactionConfig::new()
1283 .with_custom_instruction("Focus on code changes and technical decisions");
1284
1285 assert!(config.custom_instruction.is_some());
1286 assert!(config.custom_instruction.unwrap().contains("code changes"));
1287 }
1288
1289 #[test]
1290 fn test_compaction_metadata_timestamp_is_set() {
1291 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1292 assert!(metadata.timestamp <= Utc::now());
1293 }
1294
1295 #[test]
1296 fn test_compaction_ratio_achievement() {
1297 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1299 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1300
1301 let metadata = CompactionMetadata::new(1000, 300, 10, 5, 0.5);
1303 assert!((metadata.actual_ratio - 0.3).abs() < 0.001);
1304 assert!(metadata.compression_factor() > 0.5);
1305
1306 let metadata = CompactionMetadata::new(1000, 700, 10, 5, 0.5);
1308 assert!((metadata.actual_ratio - 0.7).abs() < 0.001);
1309 assert!(metadata.compression_factor() < 0.5);
1310 }
1311
1312 #[test]
1313 fn test_compaction_manager_config_updates() {
1314 let mut manager = CompactionManager::default();
1315
1316 let new_config = CompactionConfig::new()
1317 .with_keep_recent(12)
1318 .with_target_ratio(0.3);
1319
1320 manager.set_config(new_config);
1321
1322 assert_eq!(manager.config().keep_recent, 12);
1323 assert!((manager.config().target_ratio - 0.3).abs() < 0.001);
1324 }
1325
1326 #[test]
1327 fn test_llm_compactor_has_summarize_branch() {
1328 use crate::providers::OpenAiProvider;
1330 let provider = OpenAiProvider::new();
1331 let model = make_test_model();
1332 let compactor = LlmCompactor::new(model, Arc::new(provider));
1333
1334 let messages = vec![
1336 make_user_message("Test message 1"),
1337 make_assistant_message("Test response 1"),
1338 make_user_message("Test message 2"),
1339 ];
1340
1341 let branch_name = "test-branch";
1344 let _future = compactor.summarize_branch(&messages, branch_name);
1346 }
1347
1348 #[test]
1349 fn test_summarize_branch_returns_error_on_llm_failure() {
1350 use crate::providers::OpenAiProvider;
1352 let provider = OpenAiProvider::new();
1353 let model = make_test_model();
1354 let compactor = LlmCompactor::new(model, Arc::new(provider));
1355
1356 let messages: Vec<Message> = vec![];
1358
1359 let _future = compactor.summarize_branch(&messages, "empty-branch");
1362 }
1363
1364 use crate::{ToolCall, ToolResultMessage};
1367 fn make_user_msg(text: &str) -> Message {
1368 Message::User(UserMessage::new(text))
1369 }
1370
1371 fn make_asst_text(text: &str) -> Message {
1372 let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1373 m.content
1374 .push(ContentBlock::Text(TextContent::new(text.to_string())));
1375 Message::Assistant(m)
1376 }
1377
1378 fn make_asst_with_tool_call(id: &str) -> Message {
1379 let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1380 m.content.push(ContentBlock::ToolCall(ToolCall::new(
1381 id,
1382 "bash",
1383 serde_json::json!({}),
1384 )));
1385 Message::Assistant(m)
1386 }
1387
1388 fn make_tool_result(id: &str) -> Message {
1389 Message::ToolResult(ToolResultMessage::new(
1390 id,
1391 "bash",
1392 vec![ContentBlock::Text(TextContent::new("ok"))],
1393 ))
1394 }
1395
1396 #[test]
1397 fn test_align_boundary_already_at_user() {
1398 let msgs = vec![
1400 make_user_msg("a"),
1401 make_user_msg("b"),
1402 make_user_msg("c"),
1403 make_user_msg("d"),
1404 ];
1405 assert_eq!(align_split_boundary(&msgs, 2), 2);
1407 }
1408
1409 #[test]
1410 fn test_align_boundary_walks_back_from_tool_result() {
1411 let msgs = vec![
1414 make_user_msg("u1"),
1415 make_asst_with_tool_call("call_1"),
1416 make_tool_result("call_1"),
1417 make_user_msg("u2"),
1418 make_asst_text("done"),
1419 ];
1420 assert_eq!(align_split_boundary(&msgs, 3), 1);
1425 }
1426
1427 #[test]
1428 fn test_align_boundary_at_zero() {
1429 let msgs = vec![make_user_msg("u1")];
1431 assert_eq!(align_split_boundary(&msgs, 0), 0);
1432 }
1433
1434 #[test]
1435 fn test_align_boundary_past_end() {
1436 let msgs = vec![make_user_msg("u1")];
1438 assert_eq!(align_split_boundary(&msgs, 5), 5);
1439 }
1440
1441 #[test]
1442 fn test_align_boundary_assistant_text_is_safe() {
1443 let msgs = vec![
1445 make_user_msg("u1"),
1446 make_asst_with_tool_call("call_1"),
1447 make_tool_result("call_1"),
1448 make_asst_text("summary"),
1449 make_user_msg("u2"),
1450 ];
1451 assert_eq!(align_split_boundary(&msgs, 4), 4);
1453 }
1454}