Skip to main content

deepstrike_core/context/
compression.rs

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/// Compression result returned by every compactor.
8#[derive(Default)]
9pub struct CompressResult {
10    /// Tokens freed from the partition.
11    pub tokens_saved: u32,
12    /// Generated summary text if any.
13    pub summary: Option<String>,
14    /// Messages drained/archived from the context.
15    pub archived: Vec<Message>,
16    /// Cache-aware (W1-1 step 2 / DoD #4): the earliest history-message index this op rewrote or
17    /// removed — i.e. where it invalidates the prompt-cache prefix. `None` = prefix-safe (touched
18    /// nothing). The pipeline folds the minimum across stages and surfaces it on the observation.
19    pub prefix_invalidated_at: Option<usize>,
20}
21
22/// Compression strategy interface.
23pub 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
34/// rho > snip_threshold: cap each oversized message at `per_msg_tokens`.
35pub 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        // Cache-prefix protection yields when there is no drop-fallback. An untouchable message —
52        // protected-from-snip (idx < preserve_k) AND inside the drop floor (idx ≥ len − preserve_k*2)
53        // — exists only when `len < preserve_k*3`. Below that threshold, disable protection so a
54        // forced/413 compaction can always cap the oldest messages and free space; above it, the
55        // prefix is droppable as a fallback, so we protect it (cache-aware).
56        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            // Same head/tail elision as excerpt_text; the omitted count comes from the recorded
66            // token metadata (not a recount) so the elision marker matches the saved accounting.
67            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        // Pure executor: snip caps oversized messages in place; it never archives or summarizes.
82        // Summary + compression-log attribution is the pipeline's job (under the *requested* action).
83        CompressResult {
84            tokens_saved: saved,
85            prefix_invalidated_at: indices.iter().min().copied(),
86            ..Default::default()
87        }
88    }
89}
90
91/// Pure selection (W1-1 collapse): indices of oversized **text** history messages a snip caps
92/// (tokens > `per_msg_limit`; non-text and tiny ≤10-token messages skipped). The cache-aware planner
93/// reuses this to choose which — and how far back — to snip; the executor only applies the head/tail
94/// truncation to the chosen indices.
95/// How many of the oldest messages to protect from in-place rewrites (snip/excerpt) as the stable
96/// prompt-cache prefix. The protection **yields when there is no drop-fallback**: an untouchable
97/// message (protected-from-snip `idx < preserve_k` AND inside the drop floor `idx ≥ len − preserve_k*2`)
98/// exists only when `len < preserve_k*3`. Below that, return 0 so a forced/413 compaction can always
99/// cap the oldest messages; at or above it, the prefix is droppable as a fallback, so protect it.
100fn 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            // Cache-aware (W1-1 step 2): never snip the oldest `prefix_keep` messages — they are the
119            // stable prompt-cache prefix, and rewriting one invalidates the whole cache. Their tokens
120            // are reclaimed by a batched DropOldest instead (which breaks the prefix exactly once).
121            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
134/// Helper to extract key fields and info from JSON strings.
135fn 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
178/// Helper to keep a specific amount of head and tail tokens.
179fn 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
188/// [`excerpt_text`] with the total token count supplied by the caller (e.g. from recorded
189/// message metadata) instead of recounted — the count only feeds the elision marker.
190fn 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
230/// Pure selection (W1-1 collapse): indices of history messages whose large (≥200-token) tool result
231/// a micro-compact would excerpt — the first tool-result part whose `call_id` is not in
232/// `preserved_refs`. The executor applies the excerpt. The cache-aware planner reuses this: tool
233/// results are interleaved mid/late history, so excerpting them is prefix-safe.
234fn 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            // Cache-aware (W1-1 step 2): protect the oldest `prefix_keep` messages from in-place
245            // excerpting (they are the stable prompt-cache prefix).
246            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
265/// rho > micro_threshold: replace tool results with a compact excerpt. Selection via
266/// [`excerptable_tool_result_indices`]; this executor only applies the excerpt.
267pub 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        // Selection lifted to a pure helper (excludes `preserved_refs` + the cache-prefix when it has
290        // a drop-fallback); the executor only applies the excerpt to the chosen tool-result messages.
291        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        // Pure executor: excerpts tool results in place; no archive, summary, or self-log.
352        CompressResult {
353            tokens_saved: saved,
354            prefix_invalidated_at: indices.iter().min().copied(),
355            ..Default::default()
356        }
357    }
358}
359
360/// Pure **selection** (W1-1 collapse): how many of the oldest history messages to drop to bring the
361/// partition under `target_tokens`, never crossing the preserve-recent floor (`keep` messages).
362/// Returns `(count, tokens_saved)`; the executor just drains `count` from the front. This is the
363/// decision the cache-aware planner reuses to "batch one big drop to target" rather than re-deriving
364/// the count inside the compactor.
365pub 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
385/// rho > collapse_threshold: drop oldest messages until within target. Selection via
386/// [`plan_drop_oldest`]; this executor only drains the chosen count.
387pub 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; // turns → messages (user + assistant per turn)
400        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        // Pure executor: return the drained messages; the pipeline summarizes + logs once under the
411        // requested action. Dropping the oldest `n` breaks the cache prefix at index 0.
412        CompressResult {
413            tokens_saved: saved,
414            archived,
415            prefix_invalidated_at: Some(0),
416            ..Default::default()
417        }
418    }
419}
420
421/// rho > auto_threshold: collapse history entirely except last K turns, updating compression log.
422pub 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        // Pure executor: return the drained messages; the pipeline summarizes + logs once under the
464        // requested action. Auto-compact drops all but the last K turns → prefix break at index 0.
465        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
474/// Compression pipeline — operates on history partition but can reference full partitions.
475pub 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        // Cache cost of the whole compaction = the earliest prefix-break across the stages that ran
513        // (an earlier break dominates). `None` = entirely prefix-safe.
514        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        // Single decision point for summary + log attribution: whatever the cascade drained is
536        // summarized ONCE under the **requested** action and logged once. The compactors are pure
537        // executors that no longer self-attribute — so a `compress(AutoCompact)` whose draining
538        // happened in the Collapse stage is still labeled `auto_compact` (the C fix), and a
539        // `compress(ContextCollapse)` stays `context_collapse` (unchanged).
540        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        // preserve_k=0: exercise the truncation transform directly (no cache-prefix protection).
583        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        // preserve_k=0: exercise the excerpt transform directly (no cache-prefix protection).
627        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        // Pure executor: returns the drained messages; summary + log attribution is the pipeline's
647        // job (under the requested action), so the compactor itself no longer summarizes or logs.
648        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        // Since call_id "keep_me" is in preserved_refs, it should not be replaced!
702        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); // kept last 2 turns = 4 messages
720        // Pure executor: returns the drained messages; the pipeline summarizes + logs under the
721        // requested action (see `baseline_auto_*` / `pipeline_attributes_summary_to_requested_action`).
722        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        // Pure selection helper (W1-1 collapse): drop the fewest oldest messages to reach target,
730        // never below the preserve floor. This is the decision the cache-aware planner reuses.
731        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        // total=400, target=250, keep=2 → drop 3 oldest (150 saved) lands exactly at 250.
739        assert_eq!(plan_drop_oldest(&msgs, 400, 250, 2, &engine()), (3, 150));
740        // target=0 with keep=2 → drains down to the floor (len-keep = 6), never below it.
741        assert_eq!(plan_drop_oldest(&msgs, 400, 0, 2, &engine()), (6, 300));
742        // already under target → no drop.
743        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        // Protect the oldest `preserve_k` only when the history is large enough that they remain
749        // droppable (len >= preserve_k*3); otherwise 0, so a forced/413 compaction can cap them.
750        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        // (a) DoD #4: the pipeline surfaces the earliest message any stage actually touched. On the
759        // len=6 baseline (prefix_keep=2), a SnipCompact protects the oldest 2 and caps msgs 3,4 — so
760        // the cache break is at index 3, NOT the coarse 0. An AutoCompact drops the oldest → break 0.
761        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    // ─── W1-1 characterization baseline ────────────────────────────────────────
784    // Locks the CURRENT compaction behavior (tokens_saved / archived count / summary)
785    // across all four pressure levels + the cascade, so the upcoming compactor→executor
786    // refactor (EvictionOp vocab + cache-aware planner) is provably behavior-preserving.
787    // These are golden-master pins: the values describe what the pipeline does TODAY, not
788    // an independent derivation. If a future change moves a number here, that is a behavior
789    // change and must be justified, not blindly re-pinned.
790
791    use crate::types::message::Role;
792    use compact_str::CompactString;
793
794    /// Deterministic fixture: 4 oversized text turns + 2 tool-result messages, explicit token
795    /// counts so the cascade math is reproducible under `char_approx`.
796    fn baseline_partitions() -> ContextPartitions {
797        let cfg = config();
798        let mut ctx = ContextPartitions::new(&cfg);
799        // Oversized text turns (trigger Snip / Collapse / Auto).
800        ctx.history.push(Message::user("u0 ".repeat(120)), 300);
801        ctx.history.push(Message::assistant("a0 ".repeat(120)), 300);
802        // Tool-result message (trigger Micro).
803        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    /// Run the pipeline on a fresh baseline fixture at one action level.
832    /// Returns `(before, saved, summary, archived_len, msgs_after, total_after)`.
833    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        // SnipCompact runs only the Snip stage: caps oversized text messages in place — EXCEPT the
847        // oldest `preserve_recent_turns` (=2) messages, which are the stable cache prefix and are
848        // protected from in-place rewrites (W1-1 step 2 cache-aware). So it caps the 2 non-prefix
849        // oversized turns (idx 3,4), not all 4: 500 saved, was 1000 before prefix-protection. Never
850        // archives or summarizes.
851        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        // MicroCompact runs Snip then Micro: snip caps the non-prefix oversized text (500); micro
863        // excerpts the non-prefix tool results (362). The cache prefix (oldest 2) is protected from
864        // both in-place ops. Still no archival/summary; messages stay in place.
865        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        // ContextCollapse runs Snip→Micro→Collapse: oldest messages drained to `archived` with a
877        // summary, down to the preserve-recent floor (4 msgs kept).
878        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        // AutoCompact runs all 4 stages; on this fixture Snip→Micro→Collapse already hit the preserve
895        // floor, so the Auto *stage* archives nothing extra. The token math is identical to Collapse,
896        // but the summary is attributed to the **requested** action (auto_compact) — NOT silently
897        // downgraded to context_collapse by whichever stage did the draining. This is the C fix:
898        // op-label == summary/log label (node K04/K09 + the manager-level regression gate).
899        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        // The cross-level contract the refactor must preserve: heavier pressure never frees less.
912        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: no cache-prefix protection, so snip can cap the lone message —
926            // this test isolates the cascade early-break (snip reaches target → heavier stages skip).
927            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}