Skip to main content

oxicode_agent/agent_loop/compaction/
shake.rs

1//! Shake compaction — mechanical (LLM-free) context compression.
2//!
3//! Walks the message log backwards to identify a recent
4//! `protect_window_tokens` "tail" that
5//! must be preserved verbatim, then elides large token-heavy regions from
6//! everything older:
7//!
8//! 1. Tool result messages whose text payload is at least
9//!    `min_elidable_tokens`.
10//! 2. Fenced code blocks (`` ```...``` ``) of at least the same size,
11//!    inside any message text.
12//!
13//! Each region is replaced with a compact placeholder. If the total
14//! recovered tokens meet `min_savings_tokens`,
15//! every region is elided in a single pass and the function reports
16//! `ShakeOutcome::Shaken`. Otherwise **no message is mutated** and the
17//! function reports `ShakeOutcome::NoChange` — callers can poll the same
18//! vector repeatedly without side effects.
19//!
20//! Ported from omp `packages/agent/src/compaction/shake.ts` (mechanical,
21//! regex-free analogue). Token counts use the `chars / 4` heuristic that
22//! the rest of the agent loop already uses for cold-start estimation.
23
24use oxicode_ai::{ContentBlock, Message, MessageContent, TextContent};
25
26/// Tunable thresholds for [`shake`].
27///
28/// Defaults mirror omp's reference implementation:
29/// `protect_window_tokens = 16384`, `min_elidable_tokens = 400`,
30/// `min_savings_tokens = 4096`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct ShakeConfig {
33    /// Number of most-recent tokens that must remain untouched. The
34    /// boundary is inclusive: messages from the tail that, together,
35    /// cover at least this many tokens are protected.
36    pub protect_window_tokens: usize,
37    /// Minimum token count for a region to be considered for elision.
38    /// Smaller regions are left alone to avoid noisy churn.
39    pub min_elidable_tokens: usize,
40    /// Minimum aggregate token savings required to actually apply
41    /// replacements. Below this threshold the call is a no-op.
42    pub min_savings_tokens: usize,
43}
44
45impl Default for ShakeConfig {
46    fn default() -> Self {
47        Self {
48            protect_window_tokens: 16_384,
49            min_elidable_tokens: 400,
50            min_savings_tokens: 4_096,
51        }
52    }
53}
54
55/// Result of a single [`shake`] call.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ShakeOutcome {
58    /// At least one region was elided.
59    Shaken {
60        /// Number of regions replaced by placeholders.
61        regions_elided: usize,
62        /// Approximate tokens recovered (using `chars / 4`).
63        tokens_saved: usize,
64    },
65    /// The call did not meet `min_savings_tokens`; `messages` is unchanged.
66    NoChange,
67}
68
69// ─────────────────────────────────────────────────────────────────────────
70// Token estimation
71// ─────────────────────────────────────────────────────────────────────────
72
73/// Approximate token count for a UTF-8 string.
74///
75/// Uses the legacy `chars / 4` heuristic. We divide on `chars().count()`
76/// (not byte length) so non-ASCII content is not under-counted.
77#[inline]
78fn estimate_tokens(text: &str) -> usize {
79    text.chars().count() / 4
80}
81
82/// Approximate token count for a [`ContentBlock`].
83#[inline]
84fn estimate_block_tokens(block: &ContentBlock) -> usize {
85    match block {
86        ContentBlock::Text(t) => estimate_tokens(&t.text),
87        ContentBlock::Thinking(t) => estimate_tokens(&t.thinking),
88        ContentBlock::Image(_) => 8,
89        ContentBlock::ToolCall(tc) => (tc.name.chars().count() / 4) + 12,
90        ContentBlock::Unknown(_) => 10,
91    }
92}
93
94/// Approximate token count for a [`MessageContent`].
95fn estimate_message_content_tokens(content: &MessageContent) -> usize {
96    match content {
97        MessageContent::Text(s) => estimate_tokens(s),
98        MessageContent::Blocks(blocks) => blocks.iter().map(estimate_block_tokens).sum(),
99    }
100}
101
102/// Approximate token count for a [`Message`].
103///
104/// Uses [`Message::text_content`] for tool results so the rendered text
105/// is the basis; falls back to a structural estimate if rendering fails
106/// (which it should not for in-process messages).
107fn estimate_message_tokens(message: &Message) -> usize {
108    match message {
109        Message::User(m) => estimate_message_content_tokens(&m.content),
110        Message::Assistant(m) => m.content.iter().map(estimate_block_tokens).sum(),
111        Message::ToolResult(m) => match m.text_content() {
112            Ok(text) => estimate_tokens(&text),
113            Err(_) => m.content.iter().map(estimate_block_tokens).sum(),
114        },
115    }
116}
117
118// ─────────────────────────────────────────────────────────────────────────
119// Protect boundary
120// ─────────────────────────────────────────────────────────────────────────
121/// Find the slice index where the protected tail begins (inclusive end).
122///
123/// Mirrors omp `collectShakeRegions`: a message at index `i` is eligible
124/// for shaking when the tokens of messages strictly after it sum to at
125/// least `protect_window_tokens` (i.e., a recent tail of that size
126/// already exists and is safe to keep). Walking backwards from the end
127/// of `messages`, we accumulate tokens AFTER the current index. As soon
128/// as that running total crosses the window, the current index is the
129/// first eligible message — everything before it is also eligible; the
130/// tail `[boundary..]` is protected verbatim.
131///
132/// Edge cases:
133/// - `protect_window_tokens == 0` → boundary = 0 (nothing protected;
134///   anything that can be saved is elided).
135/// - Empty vector → boundary = 0.
136/// - Total tokens below the window → boundary = `messages.len()`
137///   (the whole log fits in the protected tail; nothing is eligible).
138fn find_protect_boundary(messages: &[Message], protect_window_tokens: usize) -> usize {
139    if messages.is_empty() || protect_window_tokens == 0 {
140        return 0;
141    }
142    let mut accumulated_after: usize = 0;
143    for (idx, message) in messages.iter().enumerate().rev() {
144        if accumulated_after >= protect_window_tokens {
145            // The current index is the first one with a sufficiently
146            // large tail behind it — start the eligible range here.
147            return idx + 1;
148        }
149        accumulated_after = accumulated_after.saturating_add(estimate_message_tokens(message));
150    }
151    // Walked the whole log without crossing the window: every index is
152    // protected.
153    messages.len()
154}
155
156// ─────────────────────────────────────────────────────────────────────────
157// Candidate collection
158// ─────────────────────────────────────────────────────────────────────────
159
160/// A planned elision — applies to a single message.
161///
162/// We store plain data instead of closures so the candidate type is
163/// `Debug`-able and trivially `Clone`-able (the apply phase can then
164/// walk candidates in reverse index order without lifetime tangles).
165#[derive(Debug, Clone)]
166enum Candidate {
167    /// Replace the entire content of a `Message::ToolResult`.
168    ToolResult {
169        /// Index into the message vector.
170        index: usize,
171        /// Tokens saved by this replacement.
172        tokens_saved: usize,
173    },
174    /// Replace a fenced code block inside a user/assistant message's
175    /// text content. We splice the placeholder into the first `Text`
176    /// block's `text` field; non-text blocks are untouched.
177    CodeBlock {
178        /// Index into the message vector.
179        index: usize,
180        /// Byte offset of the opening fence within the block's text.
181        block_start: usize,
182        /// Byte offset just past the closing fence.
183        block_end: usize,
184        /// Pre-rendered placeholder string.
185        placeholder: String,
186        /// Tokens saved by this replacement.
187        tokens_saved: usize,
188    },
189}
190
191/// Scan messages `[0..boundary)` for elidable regions and compute the
192/// total recoverable tokens. Does **not** mutate the input.
193fn collect_candidates(
194    messages: &[Message],
195    boundary: usize,
196    min_elidable_tokens: usize,
197) -> Vec<Candidate> {
198    let mut candidates: Vec<Candidate> = Vec::new();
199    for (index, message) in messages[..boundary].iter().enumerate() {
200        match message {
201            Message::ToolResult(m) => {
202                let original_tokens = match m.text_content() {
203                    Ok(text) => estimate_tokens(&text),
204                    Err(_) => m.content.iter().map(estimate_block_tokens).sum(),
205                };
206                if original_tokens >= min_elidable_tokens {
207                    let placeholder = format!("[tool result elided (~{original_tokens} tokens)]");
208                    let placeholder_tokens = estimate_tokens(&placeholder);
209                    let tokens_saved = original_tokens.saturating_sub(placeholder_tokens);
210                    candidates.push(Candidate::ToolResult {
211                        index,
212                        tokens_saved,
213                    });
214                }
215            }
216            Message::User(m) => {
217                collect_text_candidates(&m.content, index, min_elidable_tokens, &mut candidates);
218            }
219            Message::Assistant(m) => {
220                // Assistant messages hold many block types; we only
221                // scan the text within `ContentBlock::Text` blocks.
222                // Each text block is scanned independently so non-text
223                // blocks (tool calls, images, thinking) survive intact.
224                for block in &m.content {
225                    if let ContentBlock::Text(t) = block {
226                        let content = MessageContent::Text(t.text.clone());
227                        collect_text_candidates(
228                            &content,
229                            index,
230                            min_elidable_tokens,
231                            &mut candidates,
232                        );
233                    }
234                }
235            }
236        }
237    }
238    candidates
239}
240
241/// Walk a single `MessageContent`'s text, looking for fenced code blocks
242/// large enough to elide.
243fn collect_text_candidates(
244    content: &MessageContent,
245    index: usize,
246    min_elidable_tokens: usize,
247    out: &mut Vec<Candidate>,
248) {
249    let Some(text) = content.as_str() else {
250        return;
251    };
252    for_each_elidable_code_block(
253        text,
254        min_elidable_tokens,
255        |block_start, block_end, body, lines| {
256            let body_tokens = estimate_tokens(body);
257            let placeholder = format!("\n```\n...code block elided ({lines} lines)...\n```\n");
258            let placeholder_tokens = estimate_tokens(&placeholder);
259            let tokens_saved = body_tokens.saturating_sub(placeholder_tokens);
260            if tokens_saved == 0 {
261                return;
262            }
263            out.push(Candidate::CodeBlock {
264                index,
265                block_start,
266                block_end,
267                placeholder,
268                tokens_saved,
269            });
270        },
271    );
272}
273
274/// Invoke `f` once per fenced code block whose body (excluding the
275/// fences themselves) has at least `min_elidable_tokens` tokens.
276fn for_each_elidable_code_block(
277    text: &str,
278    min_elidable_tokens: usize,
279    mut f: impl FnMut(usize, usize, &str, usize),
280) {
281    let bytes = text.as_bytes();
282    let mut search_from = 0usize;
283    while let Some(open_rel) = find_fence_open(bytes, search_from) {
284        let open_start = search_from + open_rel;
285        let Some(close_rel) = find_fence_close(bytes, open_start + 3) else {
286            // Unterminated fence — stop scanning.
287            return;
288        };
289        let close_end = open_start + 3 + close_rel + 3;
290        let body_start = open_start + 3;
291        let body = &text[body_start..close_end - 3];
292        let tokens = estimate_tokens(body);
293        if tokens >= min_elidable_tokens {
294            let lines = body.lines().count();
295            f(open_start, close_end, body, lines);
296        }
297        search_from = close_end;
298    }
299}
300
301/// Locate the next opening fence (`` ``` ``) at or after `from`.
302///
303/// Returns the **byte offset relative to `from`** of the backtick run, or
304/// `None` if no opening fence remains.
305fn find_fence_open(bytes: &[u8], from: usize) -> Option<usize> {
306    if from + 2 >= bytes.len() {
307        return None;
308    }
309    let mut idx = from;
310    while idx + 2 < bytes.len() {
311        if bytes[idx] == b'`' && bytes[idx + 1] == b'`' && bytes[idx + 2] == b'`' {
312            return Some(idx - from);
313        }
314        idx += 1;
315    }
316    None
317}
318
319/// Locate the closing fence following an opening fence at byte offset
320/// `open_start`. `search_from` is the first byte **after** the opening
321/// fence's three backticks (so we don't match the opener).
322///
323/// Returns the **byte offset relative to `search_from`** of the closing
324/// backtick run, or `None` if the fence is unterminated.
325fn find_fence_close(bytes: &[u8], search_from: usize) -> Option<usize> {
326    if search_from + 2 >= bytes.len() {
327        return None;
328    }
329    let mut idx = search_from;
330    while idx + 2 < bytes.len() {
331        if bytes[idx] == b'`' && bytes[idx + 1] == b'`' && bytes[idx + 2] == b'`' {
332            return Some(idx - search_from);
333        }
334        idx += 1;
335    }
336    None
337}
338
339/// Replace `text[block_start..block_end]` with `placeholder`, returning
340/// the resulting `String`.
341fn replace_code_block(
342    text: &str,
343    block_start: usize,
344    block_end: usize,
345    placeholder: &str,
346) -> String {
347    let mut out = String::with_capacity(text.len());
348    out.push_str(&text[..block_start]);
349    out.push_str(placeholder);
350    out.push_str(&text[block_end..]);
351    out
352}
353
354// ─────────────────────────────────────────────────────────────────────────
355// Application
356// ─────────────────────────────────────────────────────────────────────────
357
358/// Apply every planned candidate to `messages`.
359///
360/// Candidates are applied in reverse index order so earlier replacements
361/// don't invalidate later indices.
362fn apply_candidates(messages: &mut [Message], mut candidates: Vec<Candidate>) {
363    // Sort by descending index so we mutate back-to-front and indices
364    // for earlier entries stay valid as we go.
365    candidates.sort_by_key(|c| std::cmp::Reverse(c.index()));
366
367    for candidate in candidates {
368        apply_one(messages, candidate);
369    }
370}
371
372impl Candidate {
373    fn index(&self) -> usize {
374        match self {
375            Candidate::ToolResult { index, .. } => *index,
376            Candidate::CodeBlock { index, .. } => *index,
377        }
378    }
379}
380
381fn apply_one(messages: &mut [Message], candidate: Candidate) {
382    match candidate {
383        Candidate::ToolResult { index, .. } => {
384            let Some(Message::ToolResult(tr)) = messages.get_mut(index) else {
385                return;
386            };
387            let original_tokens = match tr.text_content() {
388                Ok(text) => estimate_tokens(&text),
389                Err(_) => tr.content.iter().map(estimate_block_tokens).sum(),
390            };
391            let placeholder = format!("[tool result elided (~{original_tokens} tokens)]");
392            tr.content = vec![ContentBlock::Text(TextContent::new(placeholder))];
393        }
394        Candidate::CodeBlock {
395            index,
396            block_start,
397            block_end,
398            placeholder,
399            ..
400        } => {
401            let Some(message) = messages.get_mut(index) else {
402                return;
403            };
404            match message {
405                Message::User(m) => {
406                    rewrite_message_content(&mut m.content, block_start, block_end, &placeholder)
407                }
408                Message::Assistant(m) => {
409                    // Replace the first text block whose `text` field
410                    // is at least `block_end` chars long. This handles
411                    // the simple case where the code block lives in
412                    // one text block; multi-block assistant messages
413                    // with the same code block split across blocks are
414                    // not supported (and are extremely rare in
415                    // practice).
416                    for block in &mut m.content {
417                        if let ContentBlock::Text(t) = block
418                            && t.text.len() >= block_end
419                        {
420                            t.text =
421                                replace_code_block(&t.text, block_start, block_end, &placeholder);
422                            return;
423                        }
424                    }
425                }
426                Message::ToolResult(_) => {
427                    // Tool result content is rewritten by the dedicated
428                    // candidate variant; this branch is unreachable.
429                }
430            }
431        }
432    }
433}
434
435/// Splice a placeholder into a `MessageContent`. Operates on the first
436/// text payload available — `MessageContent::Text` directly, or the
437/// first `ContentBlock::Text` inside `MessageContent::Blocks`.
438fn rewrite_message_content(
439    content: &mut MessageContent,
440    block_start: usize,
441    block_end: usize,
442    placeholder: &str,
443) {
444    match content {
445        MessageContent::Text(s) => {
446            if s.len() >= block_end {
447                *s = replace_code_block(s, block_start, block_end, placeholder);
448            }
449        }
450        MessageContent::Blocks(blocks) => {
451            for block in blocks {
452                if let ContentBlock::Text(t) = block
453                    && t.text.len() >= block_end
454                {
455                    t.text = replace_code_block(&t.text, block_start, block_end, placeholder);
456                    return;
457                }
458            }
459        }
460    }
461}
462
463// ─────────────────────────────────────────────────────────────────────────
464// Public entry point
465// ─────────────────────────────────────────────────────────────────────────
466
467/// Shake the message log: elide large tool results and code blocks from
468/// the older portion of `messages`, leaving the recent
469/// `protect_window_tokens` intact.
470///
471/// If aggregate savings are below
472/// `min_savings_tokens` the call is a
473/// no-op: `messages` is unchanged and the outcome is `ShakeOutcome::NoChange`.
474/// Otherwise every eligible region is replaced in a single pass.
475// The signature takes `&mut Vec<Message>` to match the spec; callers pass
476// a `Vec<Message>` from the agent log, and slice deref coercion is
477// available internally. The clippy lint is silenced locally rather than
478// file-wide to keep the rest of the file slice-clean.
479#[allow(clippy::ptr_arg)]
480pub fn shake(messages: &mut Vec<Message>, config: &ShakeConfig) -> ShakeOutcome {
481    let boundary = find_protect_boundary(messages, config.protect_window_tokens);
482    if boundary == 0 {
483        // Either the log is empty, the protect window covers
484        // everything, or the budget is zero — nothing is eligible.
485        return ShakeOutcome::NoChange;
486    }
487
488    let candidates = collect_candidates(messages, boundary, config.min_elidable_tokens);
489    if candidates.is_empty() {
490        return ShakeOutcome::NoChange;
491    }
492
493    let total_savings: usize = candidates.iter().map(Candidate::tokens_saved).sum();
494
495    if total_savings < config.min_savings_tokens {
496        return ShakeOutcome::NoChange;
497    }
498
499    let regions_elided = candidates.len();
500    apply_candidates(messages, candidates);
501    ShakeOutcome::Shaken {
502        regions_elided,
503        tokens_saved: total_savings,
504    }
505}
506
507impl Candidate {
508    fn tokens_saved(&self) -> usize {
509        match self {
510            Candidate::ToolResult { tokens_saved, .. } => *tokens_saved,
511            Candidate::CodeBlock { tokens_saved, .. } => *tokens_saved,
512        }
513    }
514}
515
516// ─────────────────────────────────────────────────────────────────────────
517// Tests
518// ─────────────────────────────────────────────────────────────────────────
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use oxicode_ai::{Api, AssistantMessage, ToolResultMessage, UserMessage};
524
525    /// Build a small user message.
526    fn user_msg(text: &str) -> Message {
527        Message::User(UserMessage::new(text.to_string()))
528    }
529
530    #[allow(dead_code)]
531    fn assistant_msg(text: &str) -> Message {
532        let mut msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test-model");
533        msg.content.push(ContentBlock::Text(TextContent::new(text)));
534        Message::Assistant(msg)
535    }
536
537    /// Build a tool result message whose text payload is `text`.
538    fn tool_result_msg(tool_call_id: &str, tool_name: &str, text: &str) -> Message {
539        Message::ToolResult(ToolResultMessage::new(
540            tool_call_id.to_string(),
541            tool_name.to_string(),
542            vec![ContentBlock::Text(TextContent::new(text.to_string()))],
543        ))
544    }
545
546    /// Build a string of `n` ASCII chars.
547    fn chars(n: usize) -> String {
548        "a".repeat(n)
549    }
550
551    /// Compact `ShakeConfig` used by the unit tests. `protect_window_tokens`
552    /// is small enough that a single trailing message can exceed it, and
553    /// the elision/savings thresholds are scaled to match.
554    const CFG: ShakeConfig = ShakeConfig {
555        protect_window_tokens: 100,
556        min_elidable_tokens: 50,
557        min_savings_tokens: 200,
558    };
559
560    #[test]
561    fn test_shake_elides_large_tool_result() {
562        // 8 000 chars ≈ 2 000 tokens — clears `min_elidable_tokens` and
563        // yields savings that clear `min_savings_tokens`. The trailing
564        // user msg (~125 tokens) crosses the 100-token protect window,
565        // so the boundary lands past the tool result and index 0 is
566        // eligible.
567        let mut messages = vec![
568            tool_result_msg("call-1", "search", &chars(8_000)),
569            user_msg(&chars(500)),
570        ];
571        let outcome = shake(&mut messages, &CFG);
572        match outcome {
573            ShakeOutcome::Shaken {
574                regions_elided,
575                tokens_saved,
576            } => {
577                assert_eq!(regions_elided, 1);
578                assert!(tokens_saved >= 200);
579            }
580            other => panic!("expected Shaken, got {other:?}"),
581        }
582        match &messages[0] {
583            Message::ToolResult(tr) => {
584                assert_eq!(tr.content.len(), 1);
585                let rendered = tr.text_content().expect("renderable");
586                assert!(
587                    rendered.contains("tool result elided"),
588                    "unexpected tool result text: {rendered:?}"
589                );
590            }
591            other => panic!("expected ToolResult variant, got {other:?}"),
592        }
593    }
594
595    #[test]
596    fn test_shake_preserves_protect_window() {
597        // 4 tool results, each 8 000 chars ≈ 2 000 tokens. With
598        // `protect_window_tokens = 2 500`, walking back from the end we
599        // accumulate: 0 → 2 000 → 4 000. The 4 000-token cumulative
600        // (i.e. the two most-recent messages) crosses 2 500, so the
601        // boundary lands at index 2 — the first two are eligible; the
602        // last two are inside the protected tail.
603        let mut messages = vec![
604            tool_result_msg("call-1", "search", &chars(8_000)),
605            tool_result_msg("call-2", "search", &chars(8_000)),
606            tool_result_msg("call-3", "search", &chars(8_000)),
607            tool_result_msg("call-4", "search", &chars(8_000)),
608        ];
609        let snapshot_before: Vec<String> = messages
610            .iter()
611            .map(|m| match m {
612                Message::ToolResult(tr) => tr.text_content().unwrap_or_default(),
613                _ => String::new(),
614            })
615            .collect();
616
617        let cfg = ShakeConfig {
618            protect_window_tokens: 2_500,
619            ..CFG
620        };
621        let outcome = shake(&mut messages, &cfg);
622        assert!(matches!(outcome, ShakeOutcome::Shaken { .. }));
623
624        let len = messages.len();
625        // The two most-recent tool results must be untouched.
626        for (idx, original) in snapshot_before.iter().enumerate().rev().take(2) {
627            let preserved = match &messages[idx] {
628                Message::ToolResult(tr) => tr.text_content().unwrap_or_default(),
629                _ => panic!("expected ToolResult at index {idx}"),
630            };
631            assert_eq!(
632                &preserved, original,
633                "tool result at index {idx} was mutated but should be inside the protect window"
634            );
635        }
636        // The first two must now contain the placeholder.
637        for (msg, idx) in messages.iter().take(len - 2).zip(0..) {
638            let rendered = match msg {
639                Message::ToolResult(tr) => tr.text_content().unwrap_or_default(),
640                _ => panic!("expected ToolResult at index {idx}"),
641            };
642            assert!(
643                rendered.contains("tool result elided"),
644                "tool result at index {idx} was not elided: {rendered:?}"
645            );
646        }
647    }
648
649    #[test]
650    fn test_shake_no_change_when_insufficient_savings() {
651        // 3 tool results, each 200 chars ≈ 50 tokens — they ARE
652        // eligible candidates (≥ `min_eligible_tokens`) but `min_savings_tokens`
653        // is bumped to 5 000 so aggregate savings (~3 × 45 = 135)
654        // cannot meet it; outcome must be NoChange and the messages
655        // must be untouched.
656        let mut messages = vec![
657            tool_result_msg("call-1", "echo", &chars(200)),
658            tool_result_msg("call-2", "echo", &chars(200)),
659            tool_result_msg("call-3", "echo", &chars(200)),
660        ];
661        let snapshot_before: Vec<String> = messages
662            .iter()
663            .map(|m| match m {
664                Message::ToolResult(tr) => tr.text_content().unwrap_or_default(),
665                _ => String::new(),
666            })
667            .collect();
668
669        let cfg = ShakeConfig {
670            protect_window_tokens: 50,
671            min_elidable_tokens: 40,
672            min_savings_tokens: 5_000,
673        };
674        let outcome = shake(&mut messages, &cfg);
675        assert_eq!(outcome, ShakeOutcome::NoChange);
676
677        let snapshot_after: Vec<String> = messages
678            .iter()
679            .map(|m| match m {
680                Message::ToolResult(tr) => tr.text_content().unwrap_or_default(),
681                _ => String::new(),
682            })
683            .collect();
684        assert_eq!(snapshot_before, snapshot_after);
685    }
686
687    #[test]
688    fn test_shake_elides_large_code_block() {
689        // 8 000-char fenced code body ≈ 2 000 tokens — clears
690        // `min_elidable_tokens`. The trailing user msg (~125 tokens)
691        // crosses the 100-token protect window, so the boundary sits
692        // past index 0 and the code block is found.
693        let code_body = chars(8_000);
694        let user_text = format!("x\n```rust\n{code_body}\n```\n");
695        let mut messages = vec![user_msg(&user_text), user_msg(&chars(500))];
696        let outcome = shake(&mut messages, &CFG);
697        match outcome {
698            ShakeOutcome::Shaken {
699                regions_elided,
700                tokens_saved,
701            } => {
702                assert_eq!(regions_elided, 1);
703                assert!(tokens_saved >= 200);
704            }
705            other => panic!("expected Shaken, got {other:?}"),
706        }
707        let rendered = messages[0].text_content().expect("renderable");
708        assert!(
709            rendered.contains("code block elided"),
710            "code block was not replaced; got: {rendered:?}"
711        );
712        assert!(
713            !rendered.contains(&code_body),
714            "original code body should have been replaced"
715        );
716    }
717}