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}