1use super::config::ContextConfig;
2use super::partitions::ContextPartitions;
3use super::pressure::PressureAction;
4use super::token_engine::ContextTokenEngine;
5use crate::types::message::{Content, ContentPart, Message};
6
7#[derive(Default)]
9pub struct CompressResult {
10 pub tokens_saved: u32,
12 pub summary: Option<String>,
14 pub archived: Vec<Message>,
16 pub prefix_invalidated_at: Option<usize>,
20}
21
22pub trait Compressor: Send + Sync {
24 fn compress(
25 &self,
26 partitions: &mut ContextPartitions,
27 target_tokens: u32,
28 max_tokens: u32,
29 preserve_k: usize,
30 engine: &ContextTokenEngine,
31 ) -> CompressResult;
32}
33
34pub struct SnipCompactor {
36 pub per_msg_ratio: f64,
37}
38
39impl Compressor for SnipCompactor {
40 fn compress(
41 &self,
42 partitions: &mut ContextPartitions,
43 _target_tokens: u32,
44 max_tokens: u32,
45 preserve_k: usize,
46 engine: &ContextTokenEngine,
47 ) -> CompressResult {
48 let per_msg_limit = ((max_tokens as f64 * self.per_msg_ratio) as u32).max(50);
49 let mut saved = 0u32;
50 let partition = &mut partitions.history;
51 let prefix_keep = prefix_keep_for(partition.messages.len(), preserve_k);
57 let indices =
58 oversized_text_message_indices(&partition.messages, per_msg_limit, prefix_keep, engine);
59
60 for &i in &indices {
61 let msg = &mut partition.messages[i];
62 let original_tokens = msg.token_count.unwrap_or_else(|| engine.count_message(msg));
63 let head_limit = per_msg_limit / 2;
64 let tail_limit = per_msg_limit.saturating_sub(head_limit);
65 let snipped = if let Content::Text(ref t) = msg.content {
68 Some(excerpt_text_with_total(t, head_limit, tail_limit, engine, original_tokens))
69 } else {
70 None
71 };
72 if let Some(text) = snipped {
73 msg.content = Content::Text(text);
74 msg.token_count = Some(per_msg_limit);
75 saved += original_tokens.saturating_sub(per_msg_limit);
76 }
77 }
78
79 partition.token_count = partition.token_count.saturating_sub(saved);
80
81 CompressResult {
84 tokens_saved: saved,
85 prefix_invalidated_at: indices.iter().min().copied(),
86 ..Default::default()
87 }
88 }
89}
90
91fn prefix_keep_for(len: usize, preserve_k: usize) -> usize {
101 if len >= preserve_k.saturating_mul(3) {
102 preserve_k
103 } else {
104 0
105 }
106}
107
108fn oversized_text_message_indices(
109 messages: &[Message],
110 per_msg_limit: u32,
111 prefix_keep: usize,
112 engine: &ContextTokenEngine,
113) -> Vec<usize> {
114 messages
115 .iter()
116 .enumerate()
117 .filter(|(i, msg)| {
118 if *i < prefix_keep {
122 return false;
123 }
124 if !matches!(msg.content, Content::Text(_)) {
125 return false;
126 }
127 let toks = msg.token_count.unwrap_or_else(|| engine.count_message(msg));
128 toks > per_msg_limit && toks > 10
129 })
130 .map(|(i, _)| i)
131 .collect()
132}
133
134fn extract_json_excerpt(output: &str) -> Option<String> {
136 let val: serde_json::Value = serde_json::from_str(output).ok()?;
137 match val {
138 serde_json::Value::Object(map) => {
139 let mut summary_parts = Vec::new();
140 let mut keys = Vec::new();
141 for (k, v) in &map {
142 keys.push(k.as_str());
143 if v.is_number() || v.is_boolean() {
144 summary_parts.push(format!("{}: {}", k, v));
145 } else if let Some(s) = v.as_str() {
146 if s.len() <= 50 {
147 summary_parts.push(format!("{}: \"{}\"", k, s));
148 }
149 }
150 }
151 Some(format!(
152 "JSON Keys: [{}]\nJSON Fields: {{{}}}",
153 keys.join(", "),
154 summary_parts.join(", ")
155 ))
156 }
157 serde_json::Value::Array(arr) => {
158 if arr.is_empty() {
159 return Some("JSON Array: []".to_string());
160 }
161 let mut headers = Vec::new();
162 if let Some(serde_json::Value::Object(first_map)) = arr.first() {
163 for k in first_map.keys() {
164 headers.push(k.as_str());
165 }
166 }
167 let len = arr.len();
168 Some(format!(
169 "JSON Array: {} items. Keys: [{}]",
170 len,
171 headers.join(", ")
172 ))
173 }
174 _ => None,
175 }
176}
177
178fn excerpt_text(
180 text: &str,
181 head_tokens: u32,
182 tail_tokens: u32,
183 engine: &ContextTokenEngine,
184) -> String {
185 excerpt_text_with_total(text, head_tokens, tail_tokens, engine, engine.count(text))
186}
187
188fn excerpt_text_with_total(
191 text: &str,
192 head_tokens: u32,
193 tail_tokens: u32,
194 engine: &ContextTokenEngine,
195 total_tokens: u32,
196) -> String {
197 if total_tokens <= head_tokens + tail_tokens {
198 return text.to_string();
199 }
200 let head = engine.truncate(text, head_tokens);
201
202 let chars: Vec<char> = text.chars().collect();
203 let mut low = head.chars().count();
204 let mut high = chars.len();
205 let mut suffix_start = chars.len();
206 while low <= high {
207 let mid = (low + high) / 2;
208 if mid >= chars.len() {
209 break;
210 }
211 let candidate: String = chars[mid..].iter().collect();
212 let tokens = engine.count(&candidate);
213 if tokens <= tail_tokens {
214 suffix_start = mid;
215 if mid == 0 {
216 break;
217 }
218 high = mid - 1;
219 } else {
220 low = mid + 1;
221 }
222 }
223 let tail: String = chars[suffix_start..].iter().collect();
224 let remaining = total_tokens
225 .saturating_sub(head_tokens)
226 .saturating_sub(tail_tokens);
227 format!("{}… [… {} tokens omitted …] …{}", head, remaining, tail)
228}
229
230fn excerptable_tool_result_indices(
235 messages: &[Message],
236 preserved_refs: &[String],
237 prefix_keep: usize,
238 engine: &ContextTokenEngine,
239) -> Vec<usize> {
240 messages
241 .iter()
242 .enumerate()
243 .filter_map(|(i, msg)| {
244 if i < prefix_keep {
247 return None;
248 }
249 let toks = msg.token_count.unwrap_or_else(|| engine.count_message(msg));
250 if toks < 200 {
251 return None;
252 }
253 let Content::Parts(parts) = &msg.content else {
254 return None;
255 };
256 let call_id = parts.iter().find_map(|p| match p {
257 ContentPart::ToolResult { call_id, .. } => Some(call_id.to_string()),
258 _ => None,
259 })?;
260 (!preserved_refs.contains(&call_id)).then_some(i)
261 })
262 .collect()
263}
264
265pub struct MicroCompactor;
268
269impl Compressor for MicroCompactor {
270 fn compress(
271 &self,
272 partitions: &mut ContextPartitions,
273 _target_tokens: u32,
274 _max_tokens: u32,
275 preserve_k: usize,
276 engine: &ContextTokenEngine,
277 ) -> CompressResult {
278 let find_tool_name = |call_id: &str, msgs: &[Message]| -> Option<String> {
279 for m in msgs {
280 for tc in &m.tool_calls {
281 if tc.id == call_id {
282 return Some(tc.name.to_string());
283 }
284 }
285 }
286 None
287 };
288
289 let prefix_keep = prefix_keep_for(partitions.history.messages.len(), preserve_k);
292 let indices = excerptable_tool_result_indices(
293 &partitions.history.messages,
294 &partitions.task_state.preserved_refs,
295 prefix_keep,
296 engine,
297 );
298 let messages_clone = partitions.history.messages.clone();
299 let partition = &mut partitions.history;
300 let mut saved = 0u32;
301
302 for &i in &indices {
303 let msg = &mut partition.messages[i];
304 let original_tokens = msg.token_count.unwrap_or_else(|| engine.count_message(msg));
305 if let Content::Parts(ref mut parts) = msg.content {
306 let tool_result_index = parts
307 .iter()
308 .position(|p| matches!(p, ContentPart::ToolResult { .. }));
309 if let Some(idx) = tool_result_index {
310 if let ContentPart::ToolResult {
311 call_id,
312 output,
313 is_error: _,
314 } = &mut parts[idx]
315 {
316 let tool_name = find_tool_name(call_id, &messages_clone)
317 .unwrap_or_else(|| "unknown".to_string());
318
319 let new_output = if original_tokens > 2000 {
320 if let Some(json_excerpt) = extract_json_excerpt(output) {
321 format!(
322 "[tool result: {} | {} | {} tokens]\n{}",
323 call_id, tool_name, original_tokens, json_excerpt
324 )
325 } else {
326 let excerpt = excerpt_text(output, 30, 10, engine);
327 format!(
328 "[tool result: {} | {} | {} tokens]\n{}",
329 call_id, tool_name, original_tokens, excerpt
330 )
331 }
332 } else {
333 let excerpt = excerpt_text(output, 150, 50, engine);
334 format!(
335 "[tool result: {} | {} | {} tokens]\n{}",
336 call_id, tool_name, original_tokens, excerpt
337 )
338 };
339
340 let new_tokens = engine.count(&new_output);
341 msg.content = Content::Text(new_output);
342 msg.token_count = Some(new_tokens);
343 saved += original_tokens.saturating_sub(new_tokens);
344 }
345 }
346 }
347 }
348
349 partition.token_count = partition.token_count.saturating_sub(saved);
350
351 CompressResult {
353 tokens_saved: saved,
354 prefix_invalidated_at: indices.iter().min().copied(),
355 ..Default::default()
356 }
357 }
358}
359
360pub fn plan_drop_oldest(
366 messages: &[Message],
367 total_tokens: u32,
368 target_tokens: u32,
369 keep: usize,
370 engine: &ContextTokenEngine,
371) -> (usize, u32) {
372 let limit = messages.len().saturating_sub(keep);
373 let mut saved = 0u32;
374 let mut n = 0usize;
375 for (i, msg) in messages.iter().take(limit).enumerate() {
376 if total_tokens.saturating_sub(saved) <= target_tokens {
377 break;
378 }
379 saved += msg.token_count.unwrap_or_else(|| engine.count_message(msg));
380 n = i + 1;
381 }
382 (n, saved)
383}
384
385pub struct CollapseCompactor;
388
389impl Compressor for CollapseCompactor {
390 fn compress(
391 &self,
392 partitions: &mut ContextPartitions,
393 target_tokens: u32,
394 _max_tokens: u32,
395 preserve_k: usize,
396 engine: &ContextTokenEngine,
397 ) -> CompressResult {
398 let partition = &mut partitions.history;
399 let keep = preserve_k * 2; let (n, saved) =
401 plan_drop_oldest(&partition.messages, partition.token_count, target_tokens, keep, engine);
402
403 if n == 0 {
404 return CompressResult::default();
405 }
406
407 let archived: Vec<Message> = partition.messages.drain(..n).collect();
408 partition.token_count = partition.token_count.saturating_sub(saved);
409
410 CompressResult {
413 tokens_saved: saved,
414 archived,
415 prefix_invalidated_at: Some(0),
416 ..Default::default()
417 }
418 }
419}
420
421pub struct AutoCompactor;
423
424impl Compressor for AutoCompactor {
425 fn compress(
426 &self,
427 partitions: &mut ContextPartitions,
428 _target_tokens: u32,
429 _max_tokens: u32,
430 preserve_k: usize,
431 engine: &ContextTokenEngine,
432 ) -> CompressResult {
433 let partition = &mut partitions.history;
434 if partition.messages.is_empty() {
435 return CompressResult::default();
436 }
437
438 let original_tokens = partition.token_count;
439 let keep = preserve_k * 2;
440 let limit = partition.messages.len().saturating_sub(keep);
441 let (archived, kept): (Vec<Message>, Vec<Message>) = if limit > 0 {
442 let archived_msgs = partition.messages.drain(..limit).collect();
443 let kept_msgs = partition.messages.drain(..).collect();
444 (archived_msgs, kept_msgs)
445 } else {
446 (vec![], partition.messages.drain(..).collect())
447 };
448
449 if archived.is_empty() {
450 partition.messages = kept;
451 return CompressResult::default();
452 }
453
454 partition.messages = kept;
455
456 let kept_tokens: u32 = partition
457 .messages
458 .iter()
459 .map(|m| m.token_count.unwrap_or_else(|| engine.count_message(m)))
460 .sum();
461 partition.token_count = kept_tokens;
462
463 CompressResult {
466 tokens_saved: original_tokens.saturating_sub(kept_tokens),
467 archived,
468 prefix_invalidated_at: Some(0),
469 ..Default::default()
470 }
471 }
472}
473
474pub struct CompressionPipeline {
476 stages: Vec<(PressureAction, Box<dyn Compressor>)>,
477 preserve_recent_turns: usize,
478}
479
480impl CompressionPipeline {
481 pub fn new(config: &ContextConfig) -> Self {
482 Self {
483 preserve_recent_turns: config.preserve_recent_turns,
484 stages: vec![
485 (
486 PressureAction::SnipCompact,
487 Box::new(SnipCompactor {
488 per_msg_ratio: config.snip_per_msg_ratio,
489 }),
490 ),
491 (PressureAction::MicroCompact, Box::new(MicroCompactor)),
492 (PressureAction::ContextCollapse, Box::new(CollapseCompactor)),
493 (PressureAction::AutoCompact, Box::new(AutoCompactor)),
494 ],
495 }
496 }
497
498 pub fn compress(
499 &self,
500 partitions: &mut ContextPartitions,
501 action: PressureAction,
502 max_tokens: u32,
503 target_tokens: u32,
504 engine: &ContextTokenEngine,
505 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
506 if action == PressureAction::None {
507 return (0, None, vec![], None);
508 }
509
510 let mut total_saved = 0;
511 let mut all_archived = vec![];
512 let mut cache_at: Option<usize> = None;
515 let summarizer = super::summarizer::RuleSummarizer;
516
517 for (stage_action, compressor) in &self.stages {
518 if *stage_action <= action {
519 if partitions.total_tokens(engine) <= target_tokens {
520 break;
521 }
522 let res = compressor.compress(
523 partitions,
524 target_tokens,
525 max_tokens,
526 self.preserve_recent_turns,
527 engine,
528 );
529 total_saved += res.tokens_saved;
530 cache_at = [cache_at, res.prefix_invalidated_at].into_iter().flatten().min();
531 all_archived.extend(res.archived);
532 }
533 }
534
535 let summary = if all_archived.is_empty() {
541 None
542 } else {
543 let s = summarizer.summarize(&all_archived, action, target_tokens);
544 partitions.task_state.log_compression(action.label(), s.clone());
545 Some(s)
546 };
547
548 (total_saved, summary, all_archived, cache_at)
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::context::config::ContextConfig;
556 use crate::context::partitions::ContextPartitions;
557 use crate::context::token_engine::ContextTokenEngine;
558 use crate::types::message::Message;
559
560 fn engine() -> ContextTokenEngine {
561 ContextTokenEngine::char_approx()
562 }
563 fn config() -> ContextConfig {
564 ContextConfig::default()
565 }
566 fn summarizer() -> super::super::summarizer::RuleSummarizer {
567 super::super::summarizer::RuleSummarizer
568 }
569 const MAX: u32 = 1_000;
570
571 #[test]
572 fn snip_compactor_truncates_oversized_messages() {
573 let cfg = ContextConfig {
574 snip_per_msg_ratio: 0.10,
575 ..Default::default()
576 };
577 let compactor = SnipCompactor {
578 per_msg_ratio: cfg.snip_per_msg_ratio,
579 };
580 let mut ctx = ContextPartitions::new(&cfg);
581 ctx.history.push(Message::user("a".repeat(800)), 200);
582 let result = compactor.compress(&mut ctx, 0, MAX, 0, &engine());
584 assert!(result.tokens_saved > 0);
585 if let Content::Text(ref t) = ctx.history.messages[0].content {
586 assert!(t.contains("… [… 100 tokens omitted …] …"), "got: {t}");
587 }
588 }
589
590 #[test]
591 fn snip_compactor_leaves_small_messages_untouched() {
592 let cfg = ContextConfig {
593 snip_per_msg_ratio: 0.10,
594 ..Default::default()
595 };
596 let compactor = SnipCompactor {
597 per_msg_ratio: cfg.snip_per_msg_ratio,
598 };
599 let mut ctx = ContextPartitions::new(&cfg);
600 ctx.history.push(Message::user("short"), 5);
601 let result = compactor.compress(&mut ctx, 0, MAX, 2, &engine());
602 assert_eq!(result.tokens_saved, 0);
603 }
604
605 #[test]
606 fn micro_compactor_replaces_tool_results_with_measured_placeholder() {
607 use crate::types::message::{ContentPart, Role};
608 use compact_str::CompactString;
609
610 let compactor = MicroCompactor;
611 let mut ctx = ContextPartitions::new(&config());
612 let parts = vec![ContentPart::ToolResult {
613 call_id: CompactString::new("c1"),
614 output: "a".repeat(1200),
615 is_error: false,
616 }];
617 let msg = Message {
618 role: Role::Tool,
619 content: Content::Parts(parts),
620 tool_calls: vec![],
621 token_count: Some(300),
622 };
623 ctx.history.messages.push(msg);
624 ctx.history.token_count = 300;
625
626 let result = compactor.compress(&mut ctx, 0, MAX, 0, &engine());
628 assert!(result.tokens_saved > 0);
629 let text = ctx.history.messages[0].content.as_text().unwrap();
630 assert!(
631 text.contains("[tool result: c1 | unknown | 300 tokens]"),
632 "got: {text}"
633 );
634 }
635
636 #[test]
637 fn collapse_compactor_drops_oldest_to_reach_target() {
638 let compactor = CollapseCompactor;
639 let mut ctx = ContextPartitions::new(&config());
640 for _ in 0..8 {
641 ctx.history.push(Message::user("msg"), 50);
642 }
643 let result = compactor.compress(&mut ctx, 250, MAX, 2, &engine());
644 assert!(result.tokens_saved > 0);
645 assert!(ctx.history.messages.len() < 8);
646 assert!(!result.archived.is_empty(), "drained messages are returned to the pipeline");
649 assert!(result.summary.is_none(), "compactor no longer self-summarizes");
650 assert!(ctx.task_state.compression_log.is_empty(), "compactor no longer logs");
651 }
652
653 #[test]
654 fn rule_summarizer_formats_correctly() {
655 use crate::context::summarizer::RuleSummarizer;
656 use crate::types::message::{Content, Message, Role};
657 let summarizer = RuleSummarizer;
658 let mut messages = vec![];
659 messages.push(Message {
660 role: Role::User,
661 content: Content::Text("hello".to_string()),
662 tool_calls: vec![],
663 token_count: Some(5),
664 });
665 messages.push(Message {
666 role: Role::Assistant,
667 content: Content::Text("world".to_string()),
668 tool_calls: vec![],
669 token_count: Some(6),
670 });
671 let summary = summarizer.summarize(&messages, PressureAction::SnipCompact, 100);
672 assert!(summary.contains("[Compressed: snip_compact]"));
673 assert!(summary.contains("2 messages / 11 tokens archived"));
674 assert!(summary.contains("last assistant output: world"));
675 }
676
677 #[test]
678 fn micro_compactor_preserves_refs_in_preserved_refs() {
679 use crate::types::message::{ContentPart, Role};
680 use compact_str::CompactString;
681
682 let compactor = MicroCompactor;
683 let mut ctx = ContextPartitions::new(&config());
684 ctx.task_state.preserved_refs = vec!["keep_me".to_string()];
685
686 let parts = vec![ContentPart::ToolResult {
687 call_id: CompactString::new("keep_me"),
688 output: "a".repeat(1200),
689 is_error: false,
690 }];
691 let msg = Message {
692 role: Role::Tool,
693 content: Content::Parts(parts),
694 tool_calls: vec![],
695 token_count: Some(300),
696 };
697 ctx.history.messages.push(msg);
698 ctx.history.token_count = 300;
699
700 let result = compactor.compress(&mut ctx, 0, MAX, 2, &engine());
701 assert_eq!(result.tokens_saved, 0);
703 let text_opt = ctx.history.messages[0].content.as_text();
704 assert!(
705 text_opt.is_none(),
706 "should not be replaced to text placeholder"
707 );
708 }
709
710 #[test]
711 fn auto_compactor_merges_all_except_last_two_turns() {
712 let compactor = AutoCompactor;
713 let mut ctx = ContextPartitions::new(&config());
714 for i in 0..10 {
715 ctx.history.push(Message::user(format!("msg {i}")), 10);
716 }
717 let result = compactor.compress(&mut ctx, 0, MAX, 2, &engine());
718 assert!(result.tokens_saved > 0);
719 assert_eq!(ctx.history.messages.len(), 4); assert!(!result.archived.is_empty(), "drained messages returned to the pipeline");
723 assert!(result.summary.is_none(), "compactor no longer self-summarizes");
724 assert!(ctx.task_state.compression_log.is_empty(), "compactor no longer logs");
725 }
726
727 #[test]
728 fn plan_drop_oldest_respects_target_and_preserve_floor() {
729 let msgs: Vec<Message> = (0..8)
732 .map(|i| {
733 let mut m = Message::user(format!("m{i}"));
734 m.token_count = Some(50);
735 m
736 })
737 .collect();
738 assert_eq!(plan_drop_oldest(&msgs, 400, 250, 2, &engine()), (3, 150));
740 assert_eq!(plan_drop_oldest(&msgs, 400, 0, 2, &engine()), (6, 300));
742 assert_eq!(plan_drop_oldest(&msgs, 400, 500, 2, &engine()), (0, 0));
744 }
745
746 #[test]
747 fn prefix_keep_yields_without_drop_fallback() {
748 assert_eq!(prefix_keep_for(6, 2), 2, "len 6 >= 6 → protect oldest 2");
751 assert_eq!(prefix_keep_for(5, 2), 0, "len 5 < 6 → would leave an untouchable message");
752 assert_eq!(prefix_keep_for(3, 2), 0);
753 assert_eq!(prefix_keep_for(0, 2), 0);
754 }
755
756 #[test]
757 fn pipeline_reports_accurate_prefix_invalidation() {
758 let cfg = config();
762 let mut ctx = baseline_partitions();
763 let (_s, _u, _a, cache_at) = CompressionPipeline::new(&cfg).compress(
764 &mut ctx,
765 PressureAction::SnipCompact,
766 MAX,
767 500,
768 &engine(),
769 );
770 assert_eq!(cache_at, Some(3), "snip protected the oldest 2 → earliest touch is msg 3");
771
772 let mut ctx2 = baseline_partitions();
773 let (_s2, _u2, _a2, cache_at2) = CompressionPipeline::new(&cfg).compress(
774 &mut ctx2,
775 PressureAction::AutoCompact,
776 MAX,
777 500,
778 &engine(),
779 );
780 assert_eq!(cache_at2, Some(0), "dropping the oldest breaks the cache prefix at 0");
781 }
782
783 use crate::types::message::Role;
792 use compact_str::CompactString;
793
794 fn baseline_partitions() -> ContextPartitions {
797 let cfg = config();
798 let mut ctx = ContextPartitions::new(&cfg);
799 ctx.history.push(Message::user("u0 ".repeat(120)), 300);
801 ctx.history.push(Message::assistant("a0 ".repeat(120)), 300);
802 ctx.history.messages.push(Message {
804 role: Role::Tool,
805 content: Content::Parts(vec![ContentPart::ToolResult {
806 call_id: CompactString::new("call_1"),
807 output: serde_json::json!({"rows": 42, "ok": true, "name": "alpha"}).to_string()
808 + &"-pad".repeat(400),
809 is_error: false,
810 }]),
811 tool_calls: vec![],
812 token_count: Some(400),
813 });
814 ctx.history.token_count += 400;
815 ctx.history.push(Message::user("u1 ".repeat(120)), 300);
816 ctx.history.push(Message::assistant("a1 ".repeat(120)), 300);
817 ctx.history.messages.push(Message {
818 role: Role::Tool,
819 content: Content::Parts(vec![ContentPart::ToolResult {
820 call_id: CompactString::new("call_2"),
821 output: "y".repeat(1600),
822 is_error: false,
823 }]),
824 tool_calls: vec![],
825 token_count: Some(400),
826 });
827 ctx.history.token_count += 400;
828 ctx
829 }
830
831 fn run_baseline(action: PressureAction) -> (u32, u32, Option<String>, usize, usize, u32) {
834 let mut ctx = baseline_partitions();
835 let before = ctx.total_tokens(&engine());
836 let (saved, summary, archived, _cache_at) =
837 CompressionPipeline::new(&config()).compress(&mut ctx, action, MAX, 500, &engine());
838 let archived_len = archived.len();
839 let msgs_after = ctx.history.messages.len();
840 let total_after = ctx.total_tokens(&engine());
841 (before, saved, summary, archived_len, msgs_after, total_after)
842 }
843
844 #[test]
845 fn baseline_snip_only_caps_text_no_archival() {
846 let (before, saved, summary, archived, msgs, total) = run_baseline(PressureAction::SnipCompact);
852 assert_eq!(before, 2001);
853 assert_eq!(saved, 500, "2 non-prefix oversized turns × 250 (oldest 2 protected; was 1000)");
854 assert_eq!(archived, 0);
855 assert!(summary.is_none());
856 assert_eq!(msgs, 6, "snip mutates in place, drops no messages");
857 assert_eq!(total, 1501);
858 }
859
860 #[test]
861 fn baseline_micro_excerpts_tool_results() {
862 let (before, saved, summary, archived, msgs, total) = run_baseline(PressureAction::MicroCompact);
866 assert_eq!(before, 2001);
867 assert_eq!(saved, 862, "snip(500, prefix-protected) + excerpt(362); was 1362");
868 assert_eq!(archived, 0);
869 assert!(summary.is_none());
870 assert_eq!(msgs, 6);
871 assert_eq!(total, 1139);
872 }
873
874 #[test]
875 fn baseline_collapse_drops_oldest_and_summarizes() {
876 let (before, saved, summary, archived, msgs, total) =
879 run_baseline(PressureAction::ContextCollapse);
880 assert_eq!(before, 2001);
881 assert_eq!(saved, 1462);
882 assert_eq!(archived, 2, "drops the 2 oldest messages above the preserve floor");
883 assert_eq!(msgs, 4, "preserve_recent_turns=2 → 4 messages kept");
884 assert_eq!(total, 539);
885 let summary = summary.expect("collapse summarizes archived messages");
886 assert!(
887 summary.contains("[Compressed: context_collapse]"),
888 "summary routes the collapse action: {summary}"
889 );
890 }
891
892 #[test]
893 fn baseline_auto_attributes_summary_to_auto_compact() {
894 let (before, saved, summary, archived, msgs, total) = run_baseline(PressureAction::AutoCompact);
900 assert_eq!(before, 2001);
901 assert_eq!(saved, 1462);
902 assert_eq!(archived, 2);
903 assert_eq!(msgs, 4);
904 assert_eq!(total, 539);
905 let summary = summary.expect("auto-compact summarizes the archived messages");
906 assert!(summary.contains("[Compressed: auto_compact]"), "got: {summary}");
907 }
908
909 #[test]
910 fn baseline_saved_is_monotonic_in_action_level() {
911 let snip = run_baseline(PressureAction::SnipCompact).1;
913 let micro = run_baseline(PressureAction::MicroCompact).1;
914 let collapse = run_baseline(PressureAction::ContextCollapse).1;
915 let auto = run_baseline(PressureAction::AutoCompact).1;
916 assert!(snip <= micro, "{snip} <= {micro}");
917 assert!(micro <= collapse, "{micro} <= {collapse}");
918 assert!(collapse <= auto, "{collapse} <= {auto}");
919 }
920
921 #[test]
922 fn pipeline_stops_cascade_when_target_reached() {
923 let cfg = ContextConfig {
924 snip_per_msg_ratio: 0.25,
925 preserve_recent_turns: 0,
928 ..Default::default()
929 };
930 let pipeline = CompressionPipeline::new(&cfg);
931 let mut ctx = ContextPartitions::new(&cfg);
932 ctx.history.push(Message::user("a".repeat(3600)), 900);
933
934 let (saved, summary, archived, _cache_at) = pipeline.compress(
935 &mut ctx,
936 PressureAction::AutoCompact,
937 1_000,
938 500,
939 &engine(),
940 );
941
942 assert!(saved > 0);
943 assert!(summary.is_none(), "auto compactor should not run after snip reaches target");
944 assert!(archived.is_empty(), "heavier archival stages should not run");
945 assert_eq!(ctx.history.messages.len(), 1);
946 assert!(ctx.total_tokens(&engine()) <= 500);
947 }
948}