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, Default, 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, Default)]
271pub struct CompactedContext {
272 pub summary: String,
274 pub kept_messages: Vec<Message>,
276 pub compacted_count: usize,
278 pub metadata: CompactionMetadata,
280 pub frames: Option<FrameBag>,
285}
286
287pub type FrameBag = std::sync::Arc<Vec<(u32, Vec<u8>)>>;
289
290impl CompactedContext {
291 pub fn new(
293 summary: String,
294 kept_messages: Vec<Message>,
295 compacted_count: usize,
296 metadata: CompactionMetadata,
297 ) -> Self {
298 Self {
299 summary,
300 kept_messages,
301 compacted_count,
302 metadata,
303 frames: None,
304 }
305 }
306
307 pub fn summary(&self) -> &str {
309 &self.summary
310 }
311
312 pub fn kept_count(&self) -> usize {
314 self.kept_messages.len()
315 }
316
317 pub fn compacted_count(&self) -> usize {
319 self.compacted_count
320 }
321
322 pub fn metadata(&self) -> &CompactionMetadata {
324 &self.metadata
325 }
326
327 pub fn is_success(&self) -> bool {
329 self.metadata.success
330 }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
335pub enum CompactionStrategy {
336 Disabled,
338 Threshold(f32),
340 EveryNTurns(usize),
342 AbsoluteTokens(usize),
344 Snapcompact,
348}
349
350impl CompactionStrategy {
351 pub fn should_compact(
361 &self,
362 context_tokens: usize,
363 context_window: usize,
364 iteration: usize,
365 ) -> bool {
366 match self {
367 CompactionStrategy::Disabled => false,
368 CompactionStrategy::Threshold(threshold) => {
369 if context_window == 0 {
370 return false;
371 }
372 let usage = context_tokens as f32 / context_window as f32;
373 usage >= *threshold
374 }
375 CompactionStrategy::EveryNTurns(n) => iteration > 0 && iteration.is_multiple_of(*n),
376 CompactionStrategy::AbsoluteTokens(max_tokens) => context_tokens >= *max_tokens,
377 CompactionStrategy::Snapcompact => true,
378 }
379 }
380}
381
382impl Default for CompactionStrategy {
383 fn default() -> Self {
384 CompactionStrategy::Threshold(0.8)
385 }
386}
387
388#[derive(Debug, Clone)]
390pub enum CompactionError {
391 LlmError(String),
393 NoMessagesToCompact,
395 TooFewMessages {
397 total: usize,
399 keep_recent: usize,
401 },
402 CompactionDisabled,
404 NoContextWindow,
406}
407
408impl std::fmt::Display for CompactionError {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 match self {
411 CompactionError::LlmError(msg) => write!(f, "LLM compaction failed: {}", msg),
412 CompactionError::NoMessagesToCompact => write!(f, "No messages to compact"),
413 CompactionError::TooFewMessages { total, keep_recent } => {
414 write!(
415 f,
416 "Not enough messages ({}) to compact (need at least {} for keep_recent)",
417 total,
418 keep_recent + 1
419 )
420 }
421 CompactionError::CompactionDisabled => write!(f, "Compaction is disabled"),
422 CompactionError::NoContextWindow => write!(f, "Context window not configured"),
423 }
424 }
425}
426
427impl std::error::Error for CompactionError {}
428
429pub trait Compactor: Send + Sync {
431 fn compact<'a>(
433 &'a self,
434 messages: &'a [Message],
435 instruction: Option<&'a str>,
436 ) -> Pin<
437 Box<
438 dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
439 >,
440 >;
441
442 fn estimate_tokens(&self, messages: &[Message]) -> usize {
444 messages
445 .iter()
446 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
447 .sum()
448 }
449}
450
451pub trait ContextTransformer: Send + Sync {
456 fn transform<'a>(
458 &'a self,
459 context: &'a Context,
460 model: &'a Model,
461 ) -> Pin<Box<dyn Future<Output = Context> + Send + 'a>>;
462}
463
464pub struct NoopContextTransformer;
466
467impl ContextTransformer for NoopContextTransformer {
468 fn transform<'a>(
469 &'a self,
470 context: &'a Context,
471 _model: &'a Model,
472 ) -> Pin<Box<dyn Future<Output = Context> + Send + 'a>> {
473 Box::pin(async move { context.clone() })
474 }
475}
476
477pub struct LlmCompactor {
479 model: Model,
480 _provider: Arc<dyn Provider>,
481 config: CompactionConfig,
482}
483
484impl LlmCompactor {
485 pub fn new(model: Model, provider: Arc<dyn Provider>) -> Self {
487 Self {
488 model,
489 _provider: provider,
490 config: CompactionConfig::new(),
491 }
492 }
493
494 pub fn with_config(
496 model: Model,
497 provider: Arc<dyn Provider>,
498 config: CompactionConfig,
499 ) -> Self {
500 Self {
501 model,
502 _provider: provider,
503 config,
504 }
505 }
506
507 pub fn with_keep_recent(mut self, count: usize) -> Self {
509 self.config.keep_recent = count;
510 self
511 }
512
513 pub fn with_max_batch(mut self, count: usize) -> Self {
515 self.config.max_batch = count;
516 self
517 }
518
519 pub fn with_target_ratio(mut self, ratio: f32) -> Self {
521 self.config.target_ratio = ratio.clamp(0.1, 0.9);
522 self
523 }
524
525 fn build_summarize_prompt(&self, messages: &[Message], instruction: Option<&str>) -> String {
527 let mut prompt = String::new();
528
529 prompt.push_str("Summarize the following conversation concisely. ");
530 prompt.push_str("Capture the key points, decisions, and any ongoing tasks or context.\n\n");
531
532 if let Some(instr) = instruction {
533 prompt.push_str(&format!("Focus areas: {}\n\n", instr));
534 } else if let Some(ref custom_instr) = self.config.custom_instruction {
535 prompt.push_str(&format!("Focus areas: {}\n\n", custom_instr));
536 }
537
538 prompt.push_str("## Conversation to summarize:\n");
539
540 for (i, msg) in messages.iter().enumerate() {
541 let role = match msg {
542 Message::User(_) => "User",
543 Message::Assistant(_) => "Assistant",
544 Message::ToolResult(_) => "Tool",
545 };
546 let content = msg.text_content().unwrap_or_default();
547 let content_preview = safe_truncate(&content, 500);
548 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
549 }
550
551 prompt.push_str("\n## Summary:\n");
552 prompt
553 .push_str("Provide a concise summary that captures the essence of this conversation.");
554
555 prompt
556 }
557
558 async fn compact_with_fallback(
560 &self,
561 old_messages: &[Message],
562 recent_messages: &[Message],
563 instruction: Option<&str>,
564 ) -> std::result::Result<CompactedContext, CompactionError> {
565 match self.summarize_with_llm(old_messages, instruction).await {
567 Ok(summary) => {
568 let mut summary_msg =
570 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
571 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
572 "[Previous conversation summarized: {}]",
573 summary
574 )))];
575
576 let mut kept = vec![Message::Assistant(summary_msg)];
578 kept.extend(recent_messages.iter().cloned());
579
580 let original_tokens = self.estimate_tokens(old_messages);
581 let compacted_tokens = self.estimate_tokens(&kept);
582 let kept_len = kept.len();
583
584 Ok(CompactedContext::new(
585 summary,
586 kept,
587 old_messages.len(),
588 CompactionMetadata::new(
589 original_tokens,
590 compacted_tokens,
591 old_messages.len(),
592 kept_len,
593 self.config.target_ratio,
594 ),
595 ))
596 }
597 Err(llm_err) => {
598 self.compact_fallback(old_messages, recent_messages)
600 .await
601 .map_err(|_| CompactionError::LlmError(llm_err.to_string()))
602 }
603 }
604 }
605
606 async fn summarize_with_llm(
608 &self,
609 messages: &[Message],
610 instruction: Option<&str>,
611 ) -> std::result::Result<String, CompactionError> {
612 let prompt = self.build_summarize_prompt(messages, instruction);
613
614 let mut context = Context::new();
615 context.set_system_prompt(
616 "You are a helpful assistant that summarizes conversations concisely.",
617 );
618 context.add_message(Message::User(UserMessage::new(prompt)));
619
620 let options = StreamOptions {
621 temperature: Some(self.config.temperature as f64),
622 max_tokens: Some(self.config.summary_max_tokens),
623 ..Default::default()
624 };
625
626 let summary_message = complete(&self.model, &context, Some(options))
627 .await
628 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
629
630 Ok(summary_message.text_content())
631 }
632
633 async fn compact_fallback(
635 &self,
636 old_messages: &[Message],
637 recent_messages: &[Message],
638 ) -> std::result::Result<CompactedContext, CompactionError> {
639 let mut summary_parts = Vec::new();
641
642 if old_messages.len() > 2 {
643 if let Some(first) = old_messages.first() {
645 let content = first.text_content().unwrap_or_default();
646 let preview = safe_truncate(&content, 200);
647 summary_parts.push(format!("Started discussing: {}", preview));
648 }
649
650 if let Some(last) = old_messages.last() {
652 let content = last.text_content().unwrap_or_default();
653 let preview = safe_truncate(&content, 200);
654 summary_parts.push(format!("Ended with: {}", preview));
655 }
656
657 summary_parts.push(format!(
658 "({} messages omitted)",
659 old_messages.len().saturating_sub(2)
660 ));
661 } else if !old_messages.is_empty() {
662 if let Some(msg) = old_messages.first() {
664 let content = msg.text_content().unwrap_or_default();
665 summary_parts.push(format!("Conversation started: {}", content));
666 }
667 }
668
669 let summary = summary_parts.join(" ");
670
671 let mut summary_msg =
672 AssistantMessage::new(Api::AnthropicMessages, "compactor", &self.model.id);
673 summary_msg.content = vec![ContentBlock::Text(TextContent::new(format!(
674 "[Previous conversation summary: {}]",
675 summary
676 )))];
677
678 let mut kept = vec![Message::Assistant(summary_msg)];
679 kept.extend(recent_messages.iter().cloned());
680
681 let original_tokens = self.estimate_tokens(old_messages);
682 let compacted_tokens = self.estimate_tokens(&kept);
683 let kept_len = kept.len();
684
685 Ok(CompactedContext::new(
686 summary,
687 kept,
688 old_messages.len(),
689 CompactionMetadata::new(
690 original_tokens,
691 compacted_tokens,
692 old_messages.len(),
693 kept_len,
694 self.config.target_ratio,
695 ),
696 ))
697 }
698}
699
700impl Compactor for LlmCompactor {
701 fn compact<'a>(
702 &'a self,
703 messages: &'a [Message],
704 instruction: Option<&'a str>,
705 ) -> Pin<
706 Box<
707 dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
708 >,
709 > {
710 Box::pin(async move {
711 if messages.is_empty() {
713 return Err(CompactionError::NoMessagesToCompact);
714 }
715
716 if messages.len() <= self.config.keep_recent {
717 let original_tokens = self.estimate_tokens(messages);
719 return Ok(CompactedContext::new(
720 String::new(),
721 messages.to_vec(),
722 0,
723 CompactionMetadata::new(
724 original_tokens,
725 original_tokens,
726 0,
727 messages.len(),
728 self.config.target_ratio,
729 ),
730 ));
731 }
732
733 let keep_count = self.config.keep_recent.min(messages.len());
741 let raw_split = messages.len() - keep_count;
742 let split = align_split_boundary(messages, raw_split);
743 let old_messages: Vec<Message> = messages[..split].to_vec();
744 let recent_messages: Vec<Message> = messages[split..].to_vec();
745
746 if old_messages.is_empty() {
747 return Err(CompactionError::NoMessagesToCompact);
748 }
749
750 self.compact_with_fallback(&old_messages, &recent_messages, instruction)
752 .await
753 })
754 }
755}
756
757pub(crate) fn align_split_boundary(messages: &[crate::Message], raw_split: usize) -> usize {
775 use crate::{ContentBlock, Message};
776
777 if raw_split == 0 || raw_split >= messages.len() {
778 return raw_split;
779 }
780
781 let is_boundary = |msg: &Message| match msg {
782 Message::User(_) => true,
783 Message::Assistant(a) => !a
784 .content
785 .iter()
786 .any(|b| matches!(b, ContentBlock::ToolCall(_))),
787 Message::ToolResult(_) => false,
788 };
789
790 let mut i = raw_split;
793 while i > 0 && !is_boundary(&messages[i - 1]) {
794 i -= 1;
795 }
796 i
797}
798
799impl LlmCompactor {
801 pub async fn summarize_branch(
806 &self,
807 messages: &[Message],
808 branch_name: &str,
809 ) -> std::result::Result<String, CompactionError> {
810 if messages.is_empty() {
811 return Ok(format!("Branch '{}' is empty", branch_name));
812 }
813
814 let mut prompt = String::new();
815 prompt.push_str(&format!(
816 "Summarize the conversation branch '{}' concisely. ",
817 branch_name
818 ));
819 prompt.push_str("Focus on: what was discussed, decisions made, and current state.\n\n");
820
821 prompt.push_str("## Branch messages:\n");
822 for (i, msg) in messages.iter().enumerate() {
823 let role = match msg {
824 Message::User(_) => "User",
825 Message::Assistant(_) => "Assistant",
826 Message::ToolResult(_) => "Tool",
827 };
828 let content = msg.text_content().unwrap_or_default();
829 let content_preview = safe_truncate(&content, 300);
830 prompt.push_str(&format!("[{} {}]: {}\n", role, i + 1, content_preview));
831 }
832
833 prompt.push_str("\n## Summary (be concise):\n");
834
835 let mut context = Context::new();
837 context.set_system_prompt(
838 "You are a helpful assistant that summarizes conversation branches. ",
839 );
840 context.add_message(Message::User(UserMessage::new(prompt)));
841
842 let options = StreamOptions {
843 temperature: Some(0.3),
844 max_tokens: Some(512),
845 ..Default::default()
846 };
847
848 let summary_message = complete(&self.model, &context, Some(options))
849 .await
850 .map_err(|e| CompactionError::LlmError(e.to_string()))?;
851
852 Ok(summary_message.text_content())
853 }
854}
855
856pub struct CompactionManager {
858 strategy: CompactionStrategy,
859 compactor: Option<Arc<dyn Compactor>>,
860 context_window: usize,
861 config: CompactionConfig,
862}
863
864impl CompactionManager {
865 pub fn new(strategy: CompactionStrategy, context_window: usize) -> Self {
867 Self {
868 strategy,
869 compactor: None,
870 context_window,
871 config: CompactionConfig::new(),
872 }
873 }
874
875 pub fn with_config(
877 strategy: CompactionStrategy,
878 context_window: usize,
879 config: CompactionConfig,
880 ) -> Self {
881 Self {
882 strategy,
883 compactor: None,
884 context_window,
885 config,
886 }
887 }
888
889 pub fn with_compactor<C: Compactor + 'static>(mut self, compactor: Arc<C>) -> Self {
891 self.compactor = Some(compactor);
892 self
893 }
894
895 pub fn set_compactor(&mut self, compactor: Arc<dyn Compactor>) {
897 self.compactor = Some(compactor);
898 }
899
900 pub fn should_compact(&self, context_tokens: usize, iteration: usize) -> bool {
902 self.strategy
903 .should_compact(context_tokens, self.context_window, iteration)
904 }
905
906 pub fn strategy(&self) -> &CompactionStrategy {
908 &self.strategy
909 }
910
911 pub fn config(&self) -> &CompactionConfig {
913 &self.config
914 }
915
916 pub fn set_config(&mut self, config: CompactionConfig) {
918 self.config = config;
919 }
920
921 pub async fn compact_if_needed(
923 &self,
924 messages: &[Message],
925 instruction: Option<&str>,
926 context_tokens: usize,
927 iteration: usize,
928 ) -> std::result::Result<Option<CompactedContext>, CompactionError> {
929 if !self.should_compact(context_tokens, iteration) {
930 return Ok(None);
931 }
932
933 let compactor = match &self.compactor {
934 Some(c) => c,
935 None => return Err(CompactionError::CompactionDisabled),
936 };
937
938 let result = compactor.compact(messages, instruction).await?;
939 Ok(Some(result))
940 }
941
942 pub async fn compact_now(
944 &self,
945 messages: &[Message],
946 instruction: Option<&str>,
947 ) -> std::result::Result<CompactedContext, CompactionError> {
948 let compactor = match &self.compactor {
949 Some(c) => c,
950 None => return Err(CompactionError::CompactionDisabled),
951 };
952
953 compactor.compact(messages, instruction).await
954 }
955
956 pub fn estimate_tokens(&self, messages: &[Message]) -> usize {
958 messages
959 .iter()
960 .map(|msg| estimate_tokens(&msg.text_content().unwrap_or_default()))
961 .sum()
962 }
963}
964
965impl Default for CompactionManager {
966 fn default() -> Self {
967 Self::new(CompactionStrategy::default(), 128_000)
968 }
969}
970
971#[cfg(test)]
976mod tests {
977 use super::*;
978
979 fn make_user_message(content: &str) -> Message {
981 Message::user(content)
982 }
983
984 fn make_assistant_message(content: &str) -> Message {
986 Message::Assistant({
987 let mut msg = AssistantMessage::new(Api::AnthropicMessages, "test", "test-model");
988 msg.content = vec![ContentBlock::Text(TextContent::new(content))];
989 msg
990 })
991 }
992
993 fn make_test_model() -> Model {
995 Model::new(
996 "test-model",
997 "Test Model",
998 Api::AnthropicMessages,
999 "test",
1000 "https://test.example.com",
1001 )
1002 }
1003
1004 #[test]
1005 fn test_compaction_config_defaults() {
1006 let config = CompactionConfig::new();
1007 assert_eq!(config.keep_recent, 4);
1008 assert_eq!(config.max_batch, 20);
1009 assert!((config.target_ratio - 0.5).abs() < 0.001);
1010 assert_eq!(config.summary_max_tokens, 1024);
1011 assert!((config.temperature - 0.3).abs() < 0.001);
1012 }
1013
1014 #[test]
1015 fn test_compaction_config_builder_pattern() {
1016 let config = CompactionConfig::new()
1017 .with_keep_recent(10)
1018 .with_max_batch(30)
1019 .with_target_ratio(0.3)
1020 .with_temperature(0.5);
1021
1022 assert_eq!(config.keep_recent, 10);
1023 assert_eq!(config.max_batch, 30);
1024 assert!((config.target_ratio - 0.3).abs() < 0.001);
1025 assert!((config.temperature - 0.5).abs() < 0.001);
1026 }
1027
1028 #[test]
1029 fn test_compaction_config_ratio_clamping() {
1030 let config = CompactionConfig::new().with_target_ratio(1.5);
1032 assert!((config.target_ratio - 0.9).abs() < 0.001);
1033
1034 let config = CompactionConfig::new().with_target_ratio(-0.5);
1036 assert!((config.target_ratio - 0.1).abs() < 0.001);
1037 }
1038
1039 #[test]
1040 fn test_compaction_metadata_success() {
1041 let metadata = CompactionMetadata::new(
1042 1000, 500, 10, 5, 0.5, );
1048
1049 assert!(metadata.success);
1050 assert_eq!(metadata.original_tokens, 1000);
1051 assert_eq!(metadata.compacted_tokens, 500);
1052 assert_eq!(metadata.messages_compacted, 10);
1053 assert_eq!(metadata.messages_kept, 5);
1054 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1055 assert!((metadata.compression_factor() - 0.5).abs() < 0.001);
1056 assert_eq!(metadata.tokens_saved(), 500);
1057 assert!(metadata.error.is_none());
1058 }
1059
1060 #[test]
1061 fn test_compaction_metadata_failure() {
1062 let metadata = CompactionError::LlmError("test error".to_string());
1063
1064 assert!(metadata.to_string().contains("test error"));
1066 }
1067
1068 #[test]
1069 fn test_compaction_metadata_compression_factor() {
1070 let metadata = CompactionMetadata::new(0, 0, 0, 0, 0.5);
1072 assert!((metadata.actual_ratio - 1.0).abs() < 0.001);
1073 assert!((metadata.compression_factor() - 0.0).abs() < 0.001);
1074
1075 let metadata = CompactionMetadata::new(1000, 100, 10, 5, 0.5);
1077 assert!((metadata.compression_factor() - 0.9).abs() < 0.001);
1078 }
1079
1080 #[test]
1081 fn test_compaction_metadata_tokens_saved() {
1082 let metadata = CompactionMetadata::new(1000, 400, 10, 5, 0.5);
1084 assert_eq!(metadata.tokens_saved(), 600);
1085
1086 let metadata = CompactionMetadata::new(1000, 1000, 0, 0, 0.5);
1088 assert_eq!(metadata.tokens_saved(), 0);
1089
1090 let metadata = CompactionMetadata::new(500, 600, 5, 3, 0.5);
1092 assert_eq!(metadata.tokens_saved(), 0); }
1094
1095 #[test]
1096 fn test_compaction_strategy_disabled() {
1097 let strategy = CompactionStrategy::Disabled;
1098 assert!(!strategy.should_compact(100_000, 128_000, 5));
1099 assert!(!strategy.should_compact(120_000, 128_000, 10));
1100 assert!(!strategy.should_compact(0, 128_000, 1));
1101 }
1102
1103 #[test]
1104 fn test_compaction_strategy_threshold() {
1105 let strategy = CompactionStrategy::Threshold(0.8);
1106
1107 assert!(!strategy.should_compact(100_000, 128_000, 1));
1109
1110 assert!(strategy.should_compact(102_400, 128_000, 1));
1112
1113 assert!(strategy.should_compact(120_000, 128_000, 1));
1115
1116 assert!(!strategy.should_compact(100_000, 0, 1));
1118 }
1119
1120 #[test]
1121 fn test_compaction_strategy_every_n_turns() {
1122 let strategy = CompactionStrategy::EveryNTurns(5);
1123
1124 assert!(!strategy.should_compact(0, 128_000, 0));
1126 assert!(!strategy.should_compact(0, 128_000, 3));
1127 assert!(!strategy.should_compact(0, 128_000, 4));
1128
1129 assert!(strategy.should_compact(0, 128_000, 5));
1131 assert!(strategy.should_compact(0, 128_000, 10));
1132 assert!(strategy.should_compact(0, 128_000, 15));
1133
1134 assert!(!strategy.should_compact(0, 128_000, 6));
1136 assert!(!strategy.should_compact(0, 128_000, 9));
1137 }
1138
1139 #[test]
1140 fn test_compaction_strategy_absolute_tokens() {
1141 let strategy = CompactionStrategy::AbsoluteTokens(100_000);
1142
1143 assert!(!strategy.should_compact(50_000, 128_000, 0));
1145 assert!(!strategy.should_compact(99_999, 128_000, 0));
1146
1147 assert!(strategy.should_compact(100_000, 128_000, 0));
1149
1150 assert!(strategy.should_compact(150_000, 128_000, 0));
1152 }
1153
1154 #[test]
1155 fn test_compacted_context_basic() {
1156 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1157 let ctx = CompactedContext::new(
1158 "Test summary".to_string(),
1159 vec![make_user_message("test")],
1160 10,
1161 metadata,
1162 );
1163
1164 assert_eq!(ctx.summary(), "Test summary");
1165 assert_eq!(ctx.kept_count(), 1);
1166 assert_eq!(ctx.compacted_count(), 10);
1167 assert!(ctx.is_success());
1168 assert_eq!(ctx.metadata().tokens_saved(), 500);
1169 }
1170
1171 #[test]
1172 fn test_compacted_context_with_empty_summary() {
1173 let metadata = CompactionMetadata::new(100, 100, 0, 2, 0.5);
1174 let ctx = CompactedContext::new(
1175 String::new(), vec![make_user_message("test1"), make_user_message("test2")],
1177 0,
1178 metadata,
1179 );
1180
1181 assert_eq!(ctx.summary(), "");
1182 assert_eq!(ctx.kept_count(), 2);
1183 assert_eq!(ctx.compacted_count(), 0);
1184 }
1185
1186 #[test]
1187 fn test_llm_compactor_config_builder() {
1188 use crate::providers::OpenAiProvider;
1190 let provider = OpenAiProvider::new();
1191 let model = make_test_model();
1192 let compactor = LlmCompactor::new(model, Arc::new(provider))
1193 .with_keep_recent(6)
1194 .with_max_batch(25)
1195 .with_target_ratio(0.6);
1196
1197 assert!(compactor.config.keep_recent >= 4);
1198 assert!(compactor.config.max_batch >= 20);
1199 }
1200
1201 #[test]
1202 fn test_compaction_error_display() {
1203 let err = CompactionError::NoMessagesToCompact;
1204 assert_eq!(err.to_string(), "No messages to compact");
1205
1206 let err = CompactionError::TooFewMessages {
1207 total: 3,
1208 keep_recent: 5,
1209 };
1210 assert!(err.to_string().contains("3"));
1211 assert!(err.to_string().contains("6"));
1213
1214 let err = CompactionError::CompactionDisabled;
1215 assert_eq!(err.to_string(), "Compaction is disabled");
1216
1217 let err = CompactionError::NoContextWindow;
1218 assert_eq!(err.to_string(), "Context window not configured");
1219
1220 let err = CompactionError::LlmError("API timeout".to_string());
1221 assert!(err.to_string().contains("API timeout"));
1222 }
1223
1224 #[test]
1225 fn test_compaction_manager_default() {
1226 let manager = CompactionManager::default();
1227 assert!(matches!(
1228 manager.strategy(),
1229 CompactionStrategy::Threshold(_)
1230 ));
1231 assert_eq!(manager.config().keep_recent, 4);
1232 }
1233
1234 #[test]
1235 fn test_compaction_manager_with_custom_strategy() {
1236 let strategy = CompactionStrategy::AbsoluteTokens(50_000);
1237 let manager = CompactionManager::new(strategy, 200_000);
1238
1239 assert!(!manager.should_compact(30_000, 0));
1241
1242 assert!(manager.should_compact(60_000, 0));
1244 }
1245
1246 #[test]
1247 fn test_compaction_manager_with_config() {
1248 let config = CompactionConfig::new()
1249 .with_keep_recent(8)
1250 .with_target_ratio(0.4);
1251
1252 let manager =
1253 CompactionManager::with_config(CompactionStrategy::default(), 128_000, config);
1254
1255 assert_eq!(manager.config().keep_recent, 8);
1256 assert!((manager.config().target_ratio - 0.4).abs() < 0.001);
1257 }
1258
1259 #[test]
1260 fn test_compaction_manager_should_compact_integration() {
1261 let manager = CompactionManager::new(CompactionStrategy::Threshold(0.75), 100_000);
1262
1263 assert!(!manager.should_compact(70_000, 0));
1265
1266 assert!(manager.should_compact(75_000, 0));
1268
1269 assert!(manager.should_compact(80_000, 0));
1271 assert!(manager.should_compact(100_000, 0));
1272 }
1273
1274 #[test]
1275 fn test_compaction_manager_no_compactor_set() {
1276 let manager = CompactionManager::new(CompactionStrategy::EveryNTurns(5), 128_000);
1277
1278 assert!(manager.should_compact(0, 5)); }
1282
1283 #[test]
1284 fn test_token_estimation_helper() {
1285 use crate::providers::OpenAiProvider;
1286 let provider = OpenAiProvider::new();
1287 let model = make_test_model();
1288 let compactor = LlmCompactor::new(model, Arc::new(provider));
1289
1290 let messages = vec![
1291 make_user_message("Hello world, this is a test message."),
1292 make_assistant_message("This is a response with some content."),
1293 ];
1294
1295 let tokens = compactor.estimate_tokens(&messages);
1296 assert!(tokens > 0, "Should estimate tokens for messages");
1297 }
1298
1299 #[test]
1300 fn test_compaction_config_custom_instruction() {
1301 let config = CompactionConfig::new()
1302 .with_custom_instruction("Focus on code changes and technical decisions");
1303
1304 assert!(config.custom_instruction.is_some());
1305 assert!(config.custom_instruction.unwrap().contains("code changes"));
1306 }
1307
1308 #[test]
1309 fn test_compaction_metadata_timestamp_is_set() {
1310 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1311 assert!(metadata.timestamp <= Utc::now());
1312 }
1313
1314 #[test]
1315 fn test_compaction_ratio_achievement() {
1316 let metadata = CompactionMetadata::new(1000, 500, 10, 5, 0.5);
1318 assert!((metadata.actual_ratio - 0.5).abs() < 0.001);
1319
1320 let metadata = CompactionMetadata::new(1000, 300, 10, 5, 0.5);
1322 assert!((metadata.actual_ratio - 0.3).abs() < 0.001);
1323 assert!(metadata.compression_factor() > 0.5);
1324
1325 let metadata = CompactionMetadata::new(1000, 700, 10, 5, 0.5);
1327 assert!((metadata.actual_ratio - 0.7).abs() < 0.001);
1328 assert!(metadata.compression_factor() < 0.5);
1329 }
1330
1331 #[test]
1332 fn test_compaction_manager_config_updates() {
1333 let mut manager = CompactionManager::default();
1334
1335 let new_config = CompactionConfig::new()
1336 .with_keep_recent(12)
1337 .with_target_ratio(0.3);
1338
1339 manager.set_config(new_config);
1340
1341 assert_eq!(manager.config().keep_recent, 12);
1342 assert!((manager.config().target_ratio - 0.3).abs() < 0.001);
1343 }
1344
1345 #[test]
1346 fn test_llm_compactor_has_summarize_branch() {
1347 use crate::providers::OpenAiProvider;
1349 let provider = OpenAiProvider::new();
1350 let model = make_test_model();
1351 let compactor = LlmCompactor::new(model, Arc::new(provider));
1352
1353 let messages = vec![
1355 make_user_message("Test message 1"),
1356 make_assistant_message("Test response 1"),
1357 make_user_message("Test message 2"),
1358 ];
1359
1360 let branch_name = "test-branch";
1363 let _future = compactor.summarize_branch(&messages, branch_name);
1365 }
1366
1367 #[test]
1368 fn test_summarize_branch_returns_error_on_llm_failure() {
1369 use crate::providers::OpenAiProvider;
1371 let provider = OpenAiProvider::new();
1372 let model = make_test_model();
1373 let compactor = LlmCompactor::new(model, Arc::new(provider));
1374
1375 let messages: Vec<Message> = vec![];
1377
1378 let _future = compactor.summarize_branch(&messages, "empty-branch");
1381 }
1382
1383 use crate::{ToolCall, ToolResultMessage};
1386 fn make_user_msg(text: &str) -> Message {
1387 Message::User(UserMessage::new(text))
1388 }
1389
1390 fn make_asst_text(text: &str) -> Message {
1391 let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1392 m.content
1393 .push(ContentBlock::Text(TextContent::new(text.to_string())));
1394 Message::Assistant(m)
1395 }
1396
1397 fn make_asst_with_tool_call(id: &str) -> Message {
1398 let mut m = AssistantMessage::new(Api::AnthropicMessages, "agent", "m");
1399 m.content.push(ContentBlock::ToolCall(ToolCall::new(
1400 id,
1401 "bash",
1402 serde_json::json!({}),
1403 )));
1404 Message::Assistant(m)
1405 }
1406
1407 fn make_tool_result(id: &str) -> Message {
1408 Message::ToolResult(ToolResultMessage::new(
1409 id,
1410 "bash",
1411 vec![ContentBlock::Text(TextContent::new("ok"))],
1412 ))
1413 }
1414
1415 #[test]
1416 fn test_align_boundary_already_at_user() {
1417 let msgs = vec![
1419 make_user_msg("a"),
1420 make_user_msg("b"),
1421 make_user_msg("c"),
1422 make_user_msg("d"),
1423 ];
1424 assert_eq!(align_split_boundary(&msgs, 2), 2);
1426 }
1427
1428 #[test]
1429 fn test_align_boundary_walks_back_from_tool_result() {
1430 let msgs = vec![
1433 make_user_msg("u1"),
1434 make_asst_with_tool_call("call_1"),
1435 make_tool_result("call_1"),
1436 make_user_msg("u2"),
1437 make_asst_text("done"),
1438 ];
1439 assert_eq!(align_split_boundary(&msgs, 3), 1);
1444 }
1445
1446 #[test]
1447 fn test_align_boundary_at_zero() {
1448 let msgs = vec![make_user_msg("u1")];
1450 assert_eq!(align_split_boundary(&msgs, 0), 0);
1451 }
1452
1453 #[test]
1454 fn test_align_boundary_past_end() {
1455 let msgs = vec![make_user_msg("u1")];
1457 assert_eq!(align_split_boundary(&msgs, 5), 5);
1458 }
1459
1460 #[test]
1461 fn test_align_boundary_assistant_text_is_safe() {
1462 let msgs = vec![
1464 make_user_msg("u1"),
1465 make_asst_with_tool_call("call_1"),
1466 make_tool_result("call_1"),
1467 make_asst_text("summary"),
1468 make_user_msg("u2"),
1469 ];
1470 assert_eq!(align_split_boundary(&msgs, 4), 4);
1472 }
1473}