Skip to main content

newt_core/
prune.rs

1//! Structural prune — zero-LLM context compression over the wire-shape
2//! message list (Step 18.3, issue #247).
3//!
4//! Pure functions over the `serde_json::Value` message shape the TUI loop
5//! sends to Ollama / OpenAI-compatible backends: objects with `role` /
6//! `content` / `tool_calls`, and `role: "tool"` results (with a
7//! `tool_call_id` on the OpenAI path, positional on the Ollama path).
8//!
9//! Three passes, in the hermes-proven order (see
10//! `docs/design/context-memory-hermes-learnings.md`, gap-matrix row
11//! "Structural pruning before LLM summary"):
12//!
13//! 1. [`collapse_duplicate_tool_results`] — identical oversized tool results
14//!    are collapsed: later occurrences become a one-liner referencing the
15//!    first.
16//! 2. [`summarize_aged_tool_results`] — tool results outside the protected
17//!    tail are replaced with informative per-tool one-liners
18//!    (`[run_command] ran 'npm test' -> ok, 47 lines output`).
19//! 3. [`shrink_tool_call_args`] — oversized `function.arguments` are parsed,
20//!    truncated *inside* the JSON structure (long string values), and
21//!    reserialized, so the output is always valid JSON. Hermes learned the
22//!    hard way that naive byte-slicing causes provider 400-loops
23//!    (hermes #11762).
24//!
25//! Invariants (enforced by construction, verified by the property tests):
26//! no message is ever added or removed, roles and `tool_call_id`s are never
27//! touched, the last [`PruneConfig::keep_last`] messages are byte-identical,
28//! a replacement is only applied when it is strictly shorter (serialized),
29//! and `prune(prune(x)) == prune(x)`.
30//!
31//! Nothing calls this module yet — Step 18.4 wires it into the compression
32//! pipeline after Step 9.7 lands the `agentic` module. Hashing uses `blake3`,
33//! which is already in newt-core's build graph via `agent-mesh-protocol`.
34
35use serde_json::Value;
36use std::collections::HashMap;
37
38/// Floor for [`PruneConfig::arg_string_cap`]. The truncation marker
39/// (`… [+N chars]`) needs at most 31 chars even for a `usize::MAX` count, so
40/// any cap >= 32 guarantees truncated strings land at or under the cap —
41/// which is what makes [`shrink_tool_call_args`] idempotent.
42const MIN_ARG_STRING_CAP: usize = 32;
43
44/// Thresholds for the three prune passes. All sizes are in characters.
45#[derive(Debug, Clone)]
46pub struct PruneConfig {
47    /// Protected tail: the last `keep_last` messages are never modified
48    /// (byte-identical in the output). "Aged" means anything before them.
49    pub keep_last: usize,
50    /// Pass 1 only collapses tool results strictly longer than this.
51    pub dedupe_min_chars: usize,
52    /// Pass 2 only summarizes tool results strictly longer than this.
53    pub summarize_min_chars: usize,
54    /// Pass 3 only rewrites `function.arguments` whose serialized form is
55    /// strictly longer than this.
56    pub args_max_chars: usize,
57    /// Per-string cap applied *inside* parsed argument structures by pass 3.
58    /// Values below [`MIN_ARG_STRING_CAP`] are raised to it.
59    pub arg_string_cap: usize,
60}
61
62impl Default for PruneConfig {
63    fn default() -> Self {
64        Self {
65            keep_last: 10,
66            dedupe_min_chars: 200,
67            summarize_min_chars: 200,
68            args_max_chars: 500,
69            arg_string_cap: 200,
70        }
71    }
72}
73
74impl PruneConfig {
75    fn effective_arg_string_cap(&self) -> usize {
76        self.arg_string_cap.max(MIN_ARG_STRING_CAP)
77    }
78
79    /// Index of the first protected-tail message; `[0, aged)` may be edited.
80    fn aged_len(&self, total: usize) -> usize {
81        total.saturating_sub(self.keep_last)
82    }
83}
84
85/// Result of [`prune`]: the rewritten message list plus exact accounting of
86/// how many serialized characters the three passes reclaimed.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct PruneOutcome {
89    /// The pruned message list (same length and order as the input).
90    pub messages: Vec<Value>,
91    /// Exactly `serialized_len(input) - serialized_len(output)`, where
92    /// `serialized_len` is the summed `serde_json::to_string` length of each
93    /// message. Never negative: passes only apply strictly-shorter rewrites.
94    pub chars_reclaimed: usize,
95}
96
97/// Run all three structural-prune passes in order.
98///
99/// ```
100/// use newt_core::prune::{prune, PruneConfig};
101/// use serde_json::json;
102///
103/// let mut messages = vec![
104///     json!({"role": "user", "content": "fix the bug"}),
105///     json!({"role": "assistant", "content": "",
106///            "tool_calls": [{"function": {"name": "read_file",
107///                                         "arguments": {"path": "src/lib.rs"}}}]}),
108///     json!({"role": "tool", "content": "x".repeat(500)}),
109/// ];
110/// // Pad so the tool result falls outside the protected tail.
111/// for i in 0..10 {
112///     messages.push(json!({"role": "user", "content": format!("turn {i}")}));
113/// }
114///
115/// let outcome = prune(&messages, &PruneConfig::default());
116/// assert_eq!(
117///     outcome.messages[2]["content"].as_str().unwrap(),
118///     "[read_file] read 'src/lib.rs' -> ok, 1 lines (500 chars)",
119/// );
120/// assert!(outcome.chars_reclaimed > 400);
121/// ```
122pub fn prune(messages: &[Value], cfg: &PruneConfig) -> PruneOutcome {
123    let before = serialized_len(messages);
124    let out = collapse_duplicate_tool_results(messages, cfg);
125    let out = summarize_aged_tool_results(&out, cfg);
126    let out = shrink_tool_call_args(&out, cfg);
127    let after = serialized_len(&out);
128    PruneOutcome {
129        messages: out,
130        chars_reclaimed: before.saturating_sub(after),
131    }
132}
133
134/// Summed `serde_json::to_string` length of each message — the currency of
135/// [`PruneOutcome::chars_reclaimed`].
136pub fn serialized_len(messages: &[Value]) -> usize {
137    messages.iter().map(json_len).sum()
138}
139
140/// Pass 1: collapse duplicate tool results.
141///
142/// Identical `role:"tool"` contents longer than
143/// [`PruneConfig::dedupe_min_chars`] are hashed (blake3); aged later
144/// occurrences are replaced with a one-liner referencing the first
145/// occurrence's message index. The first occurrence and anything in the
146/// protected tail are left untouched.
147pub fn collapse_duplicate_tool_results(messages: &[Value], cfg: &PruneConfig) -> Vec<Value> {
148    let mut out = messages.to_vec();
149    let aged = cfg.aged_len(out.len());
150    let paired = pair_tool_results(&out);
151    let mut first_seen: HashMap<[u8; 32], usize> = HashMap::new();
152    let mut replacements: Vec<(usize, String)> = Vec::new();
153
154    for (i, msg) in out.iter().enumerate() {
155        if msg["role"].as_str() != Some("tool") {
156            continue;
157        }
158        let Some(content) = msg["content"].as_str() else {
159            continue;
160        };
161        if content.chars().count() <= cfg.dedupe_min_chars
162            || already_pruned(content, paired_name(&paired, i))
163        {
164            continue;
165        }
166        let key = *blake3::hash(content.as_bytes()).as_bytes();
167        match first_seen.get(&key) {
168            None => {
169                first_seen.insert(key, i);
170            }
171            // Hash hit: confirm true equality before collapsing (paranoia
172            // against collisions — the first occurrence is never rewritten,
173            // so comparing against `out[first]` is comparing originals).
174            Some(&first) if i < aged && out[first]["content"].as_str() == Some(content) => {
175                let n = content.chars().count();
176                let line = format!(
177                    "[duplicate of message {first}: identical {n}-char tool result elided]"
178                );
179                if json_str_len(&line) < json_str_len(content) {
180                    replacements.push((i, line));
181                }
182            }
183            Some(_) => {}
184        }
185    }
186    for (i, line) in replacements {
187        out[i]["content"] = Value::String(line);
188    }
189    out
190}
191
192/// Pass 2: replace aged oversized tool results with per-tool one-liners.
193///
194/// Each `role:"tool"` message outside the protected tail whose content is
195/// longer than [`PruneConfig::summarize_min_chars`] is paired with the
196/// `tool_calls` entry that produced it (by `tool_call_id` when present,
197/// positionally otherwise — matching both wire dialects the TUI loop uses)
198/// and rewritten as e.g. `[run_command] ran 'npm test' -> ok, 47 lines
199/// output`. Unpairable (orphaned) results fall back to a generic `[tool]`
200/// one-liner.
201pub fn summarize_aged_tool_results(messages: &[Value], cfg: &PruneConfig) -> Vec<Value> {
202    let mut out = messages.to_vec();
203    let aged = cfg.aged_len(out.len());
204    let paired = pair_tool_results(&out);
205
206    for (i, msg) in out.iter_mut().enumerate().take(aged) {
207        if msg["role"].as_str() != Some("tool") {
208            continue;
209        }
210        let line = {
211            let Some(content) = msg["content"].as_str() else {
212                continue;
213            };
214            if content.chars().count() <= cfg.summarize_min_chars
215                || already_pruned(content, paired_name(&paired, i))
216            {
217                continue;
218            }
219            let (name, args) = match &paired[i] {
220                Some(p) => (p.name.as_str(), Some(&p.args)),
221                None => ("tool", None),
222            };
223            let line = one_line_summary(name, args, content);
224            if json_str_len(&line) >= json_str_len(content) {
225                continue;
226            }
227            line
228        };
229        msg["content"] = Value::String(line);
230    }
231    out
232}
233
234/// Pass 3: JSON-aware shrinking of oversized tool-call arguments.
235///
236/// For aged assistant messages, any `function.arguments` whose serialized
237/// form exceeds [`PruneConfig::args_max_chars`] is parsed, long string
238/// values are truncated *inside* the structure (recursively, to
239/// [`PruneConfig::arg_string_cap`] chars), and the result is reserialized —
240/// so the output always parses, and string-typed arguments stay strings
241/// while object-typed arguments stay objects. An oversized arguments string
242/// that does not parse is replaced with a small valid-JSON placeholder.
243pub fn shrink_tool_call_args(messages: &[Value], cfg: &PruneConfig) -> Vec<Value> {
244    let mut out = messages.to_vec();
245    let aged = cfg.aged_len(out.len());
246    let cap = cfg.effective_arg_string_cap();
247
248    for msg in out.iter_mut().take(aged) {
249        if msg["role"].as_str() != Some("assistant") {
250            continue;
251        }
252        let Some(tcs) = msg.get_mut("tool_calls").and_then(Value::as_array_mut) else {
253            continue;
254        };
255        for tc in tcs {
256            let Some(arguments) = tc.get_mut("function").and_then(|f| f.get_mut("arguments"))
257            else {
258                continue;
259            };
260            shrink_arguments(arguments, cfg.args_max_chars, cap);
261        }
262    }
263    out
264}
265
266// ---------------------------------------------------------------------------
267// internals
268// ---------------------------------------------------------------------------
269
270/// A tool call paired to its result message: the tool name plus its parsed
271/// arguments (string-encoded arguments are parsed; unparseable ones → Null).
272struct PairedCall {
273    name: String,
274    args: Value,
275}
276
277/// For each message index, the tool call that produced it (None for
278/// non-results and orphans). Results match their assistant's `tool_calls`
279/// by `tool_call_id` when present (OpenAI dialect) or positionally (Ollama
280/// dialect, which emits `role:"tool"` results without ids).
281fn pair_tool_results(messages: &[Value]) -> Vec<Option<PairedCall>> {
282    let mut paired = Vec::with_capacity(messages.len());
283    let mut pending: Vec<(String, PairedCall)> = Vec::new();
284
285    for msg in messages {
286        let role = msg["role"].as_str().unwrap_or("");
287        if role == "tool" {
288            let id = msg["tool_call_id"].as_str().unwrap_or("");
289            let pos = if id.is_empty() {
290                (!pending.is_empty()).then_some(0)
291            } else {
292                pending.iter().position(|(pid, _)| pid == id)
293            };
294            paired.push(pos.map(|p| pending.remove(p).1));
295            continue;
296        }
297        pending.clear();
298        if role == "assistant" {
299            if let Some(tcs) = msg["tool_calls"].as_array() {
300                for tc in tcs {
301                    let id = tc["id"].as_str().unwrap_or("").to_string();
302                    let name = tc["function"]["name"]
303                        .as_str()
304                        .unwrap_or("tool")
305                        .to_string();
306                    let args = match &tc["function"]["arguments"] {
307                        Value::String(s) => serde_json::from_str(s).unwrap_or(Value::Null),
308                        v => v.clone(),
309                    };
310                    pending.push((id, PairedCall { name, args }));
311                }
312            }
313        }
314        paired.push(None);
315    }
316    paired
317}
318
319fn paired_name(paired: &[Option<PairedCall>], i: usize) -> &str {
320    paired[i].as_ref().map_or("tool", |p| p.name.as_str())
321}
322
323/// True when `content` is already a pass-1 or pass-2 marker — keeps both
324/// passes idempotent even under tiny thresholds.
325fn already_pruned(content: &str, paired_name: &str) -> bool {
326    content.starts_with("[duplicate of message ")
327        || content.starts_with(&format!("[{paired_name}] "))
328}
329
330/// Per-tool one-line summary of a tool result (the pass-2 rewrite).
331fn one_line_summary(name: &str, args: Option<&Value>, content: &str) -> String {
332    let lines = content.lines().count();
333    let chars = content.chars().count();
334    let status = if looks_like_error(content) {
335        "error"
336    } else {
337        "ok"
338    };
339    let arg = |key: &str| -> String {
340        args.and_then(|a| a.get(key))
341            .and_then(Value::as_str)
342            .map_or_else(|| "?".to_string(), |s| excerpt(s, 80))
343    };
344    match name {
345        // Note: newt's `(exit N)` empty-output results are always shorter
346        // than any one-liner, so the strictly-shorter rule keeps them as-is —
347        // no exit-code special case is reachable here.
348        "run_command" => {
349            format!(
350                "[run_command] ran '{}' -> {status}, {lines} lines output",
351                arg("command")
352            )
353        }
354        "read_file" => {
355            format!(
356                "[read_file] read '{}' -> {status}, {lines} lines ({chars} chars)",
357                arg("path")
358            )
359        }
360        "write_file" => {
361            format!(
362                "[write_file] wrote '{}' -> {status}, {lines} lines result",
363                arg("path")
364            )
365        }
366        "edit_file" => {
367            format!(
368                "[edit_file] edited '{}' -> {status}, {lines} lines result",
369                arg("path")
370            )
371        }
372        "list_dir" => format!(
373            "[list_dir] listed '{}' -> {status}, {lines} entries",
374            arg("path")
375        ),
376        "search" => {
377            let q = args
378                .and_then(|a| a.get("query").or_else(|| a.get("pattern")))
379                .and_then(Value::as_str)
380                .map_or_else(|| "?".to_string(), |s| excerpt(s, 80));
381            format!("[search] searched '{q}' -> {status}, {lines} matching lines")
382        }
383        "web_fetch" => {
384            format!(
385                "[web_fetch] fetched '{}' -> {status}, {lines} lines ({chars} chars)",
386                arg("url")
387            )
388        }
389        _ => format!("[{name}] result elided -> {status}, {lines} lines ({chars} chars)"),
390    }
391}
392
393/// First `max_chars` chars with newlines flattened, `…`-terminated if cut.
394fn excerpt(s: &str, max_chars: usize) -> String {
395    let cleaned: String = s
396        .chars()
397        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
398        .collect();
399    if cleaned.chars().count() <= max_chars {
400        cleaned
401    } else {
402        let head: String = cleaned.chars().take(max_chars).collect();
403        format!("{head}…")
404    }
405}
406
407/// Matches the error shapes `execute_tool` produces (`error…`,
408/// `capability denied…`).
409fn looks_like_error(content: &str) -> bool {
410    let t = content.trim_start();
411    t.starts_with("error") || t.starts_with("Error") || t.starts_with("capability denied")
412}
413
414/// Shrink one `function.arguments` value in place (pass-3 core). String-typed
415/// arguments are parsed/shrunk/reserialized and stay strings; structured
416/// arguments are shrunk directly. A rewrite is only applied when strictly
417/// shorter serialized.
418fn shrink_arguments(arguments: &mut Value, max_chars: usize, cap: usize) {
419    match arguments {
420        Value::String(s) => {
421            if s.chars().count() <= max_chars {
422                return;
423            }
424            let shrunk = match serde_json::from_str::<Value>(s) {
425                Ok(mut v) => {
426                    shrink_value(&mut v, cap);
427                    v
428                }
429                Err(_) => {
430                    // Not JSON to begin with — substitute a small valid-JSON
431                    // placeholder rather than slicing bytes (hermes #11762).
432                    let n = s.chars().count();
433                    let mut ph = serde_json::json!({
434                        "truncated": format!("original arguments were not valid JSON ({n} chars elided)"),
435                    });
436                    shrink_value(&mut ph, cap);
437                    ph
438                }
439            };
440            let new = serde_json::to_string(&shrunk).unwrap_or_else(|_| "{}".to_string());
441            if json_str_len(&new) < json_str_len(s) {
442                *arguments = Value::String(new);
443            }
444        }
445        other => {
446            if json_len(other) <= max_chars {
447                return;
448            }
449            let mut v = other.clone();
450            if shrink_value(&mut v, cap) && json_len(&v) < json_len(other) {
451                *other = v;
452            }
453        }
454    }
455}
456
457/// Recursively truncate long string values inside a parsed JSON structure.
458/// Returns true when anything changed.
459fn shrink_value(v: &mut Value, cap: usize) -> bool {
460    match v {
461        Value::String(s) => match truncate_chars(s, cap) {
462            Some(t) => {
463                *s = t;
464                true
465            }
466            None => false,
467        },
468        // Explicit loops, not `.any()`: every nested value must be visited
469        // (shrink_value mutates), and `.any()` would short-circuit after the
470        // first hit, leaving later strings unshrunk.
471        Value::Array(items) => {
472            let mut changed = false;
473            for it in items.iter_mut() {
474                changed |= shrink_value(it, cap);
475            }
476            changed
477        }
478        Value::Object(map) => {
479            let mut changed = false;
480            for it in map.values_mut() {
481                changed |= shrink_value(it, cap);
482            }
483            changed
484        }
485        _ => false,
486    }
487}
488
489/// Char-boundary-safe truncation to at most `cap` chars including the
490/// `… [+N chars]` marker (None when already within `cap`). Reserves marker
491/// space using the *total* count, whose digit count bounds the omitted
492/// count's — so the result never exceeds `cap`, which keeps repeated
493/// applications stable.
494fn truncate_chars(s: &str, cap: usize) -> Option<String> {
495    let total = s.chars().count();
496    if total <= cap {
497        return None;
498    }
499    let marker_reserve = format!("… [+{total} chars]").chars().count();
500    let keep = cap.saturating_sub(marker_reserve);
501    let head: String = s.chars().take(keep).collect();
502    let omitted = total - keep;
503    Some(format!("{head}… [+{omitted} chars]"))
504}
505
506/// Serialized length of one JSON value.
507fn json_len(v: &Value) -> usize {
508    serde_json::to_string(v).map_or(0, |s| s.len())
509}
510
511/// Serialized length of a string as a JSON string (quotes + escapes) — the
512/// exact cost of a `content` value inside its message.
513fn json_str_len(s: &str) -> usize {
514    json_len(&Value::String(s.to_string()))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use serde_json::json;
521
522    // -- builders ----------------------------------------------------------
523
524    fn user(text: &str) -> Value {
525        json!({"role": "user", "content": text})
526    }
527
528    fn assistant_text(text: &str) -> Value {
529        json!({"role": "assistant", "content": text})
530    }
531
532    /// Ollama dialect: no ids, `arguments` is a JSON object.
533    fn assistant_calls(calls: &[(&str, Value)]) -> Value {
534        let tcs: Vec<Value> = calls
535            .iter()
536            .map(|(name, args)| json!({"function": {"name": name, "arguments": args}}))
537            .collect();
538        json!({"role": "assistant", "content": "", "tool_calls": tcs})
539    }
540
541    /// OpenAI dialect: ids, `arguments` is a serialized JSON string.
542    fn assistant_calls_openai(calls: &[(&str, &str, Value)]) -> Value {
543        let tcs: Vec<Value> = calls
544            .iter()
545            .map(|(id, name, args)| {
546                json!({"id": id, "type": "function",
547                       "function": {"name": name, "arguments": args.to_string()}})
548            })
549            .collect();
550        json!({"role": "assistant", "content": "", "tool_calls": tcs})
551    }
552
553    fn tool_result(content: &str) -> Value {
554        json!({"role": "tool", "content": content})
555    }
556
557    fn tool_result_id(id: &str, content: &str) -> Value {
558        json!({"role": "tool", "tool_call_id": id, "content": content})
559    }
560
561    /// `n` distinct lines, ~13 chars each.
562    fn text_lines(n: usize) -> String {
563        (0..n)
564            .map(|i| format!("test line {i:03}"))
565            .collect::<Vec<_>>()
566            .join("\n")
567    }
568
569    fn pad_tail(msgs: &mut Vec<Value>, n: usize) {
570        for i in 0..n {
571            msgs.push(user(&format!("tail filler {i}")));
572        }
573    }
574
575    fn content_of(msg: &Value) -> &str {
576        msg["content"].as_str().unwrap()
577    }
578
579    // -- pass 1: duplicate collapse -----------------------------------------
580
581    #[test]
582    fn dedupe_collapses_later_identical_aged_result() {
583        let big = text_lines(40); // ~560 chars
584        let mut msgs = vec![
585            user("task"),
586            assistant_calls(&[("run_command", json!({"command": "cargo test"}))]),
587            tool_result(&big),
588            assistant_calls(&[("run_command", json!({"command": "cargo test"}))]),
589            tool_result(&big),
590        ];
591        pad_tail(&mut msgs, 10);
592        let out = collapse_duplicate_tool_results(&msgs, &PruneConfig::default());
593        // First occurrence untouched; later one collapsed with a reference.
594        assert_eq!(content_of(&out[2]), big);
595        assert_eq!(
596            content_of(&out[4]),
597            format!(
598                "[duplicate of message 2: identical {}-char tool result elided]",
599                big.chars().count()
600            ),
601        );
602    }
603
604    #[test]
605    fn dedupe_ignores_short_results_and_non_tool_roles() {
606        let small = "short duplicate";
607        let big = text_lines(40);
608        let mut msgs = vec![
609            user(&big), // non-tool role with big duplicate content
610            tool_result(small),
611            tool_result(small),
612            user(&big),
613        ];
614        pad_tail(&mut msgs, 4);
615        let cfg = PruneConfig {
616            keep_last: 4,
617            ..PruneConfig::default()
618        };
619        let out = collapse_duplicate_tool_results(&msgs, &cfg);
620        assert_eq!(out, msgs);
621    }
622
623    #[test]
624    fn dedupe_never_touches_protected_tail() {
625        let big = text_lines(40);
626        let mut msgs = vec![user("task"), tool_result(&big)];
627        pad_tail(&mut msgs, 3);
628        msgs.push(tool_result(&big)); // duplicate, but inside the tail
629        let cfg = PruneConfig {
630            keep_last: 2,
631            ..PruneConfig::default()
632        };
633        let out = collapse_duplicate_tool_results(&msgs, &cfg);
634        assert_eq!(content_of(out.last().unwrap()), big);
635    }
636
637    #[test]
638    fn dedupe_leaves_distinct_results_alone() {
639        let mut msgs = vec![
640            user("task"),
641            tool_result(&text_lines(40)),
642            tool_result(&text_lines(41)),
643        ];
644        pad_tail(&mut msgs, 10);
645        let out = collapse_duplicate_tool_results(&msgs, &PruneConfig::default());
646        assert_eq!(out, msgs);
647    }
648
649    // -- pass 2: per-tool one-liners ----------------------------------------
650
651    /// Build `[user, assistant_call, tool_result, …tail]`, run pass 2, and
652    /// return the rewritten result content.
653    fn summarize_one(name: &str, args: Value, content: &str) -> String {
654        let mut msgs = vec![
655            user("task"),
656            assistant_calls(&[(name, args)]),
657            tool_result(content),
658        ];
659        pad_tail(&mut msgs, 10);
660        let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
661        content_of(&out[2]).to_string()
662    }
663
664    #[test]
665    fn one_liner_run_command() {
666        let line = summarize_one(
667            "run_command",
668            json!({"command": "npm test"}),
669            &text_lines(47),
670        );
671        assert_eq!(line, "[run_command] ran 'npm test' -> ok, 47 lines output");
672    }
673
674    #[test]
675    fn one_liner_run_command_error() {
676        let content = format!("error: build failed\n{}", text_lines(30));
677        let line = summarize_one("run_command", json!({"command": "cargo build"}), &content);
678        assert_eq!(
679            line,
680            "[run_command] ran 'cargo build' -> error, 31 lines output"
681        );
682    }
683
684    #[test]
685    fn one_liner_never_replaces_with_something_longer() {
686        // `(exit 0)` (run_command's empty-output result) is shorter than any
687        // one-liner — the strictly-shorter rule must leave it alone even when
688        // the threshold would otherwise fire.
689        let cfg = PruneConfig {
690            summarize_min_chars: 4,
691            ..PruneConfig::default()
692        };
693        let mut msgs = vec![
694            user("task"),
695            assistant_calls(&[("run_command", json!({"command": "true"}))]),
696            tool_result("(exit 0)"),
697        ];
698        pad_tail(&mut msgs, 10);
699        let out = summarize_aged_tool_results(&msgs, &cfg);
700        assert_eq!(content_of(&out[2]), "(exit 0)");
701    }
702
703    #[test]
704    fn one_liner_read_file() {
705        let content = text_lines(120);
706        let chars = content.chars().count();
707        let line = summarize_one("read_file", json!({"path": "src/main.rs"}), &content);
708        assert_eq!(
709            line,
710            format!("[read_file] read 'src/main.rs' -> ok, 120 lines ({chars} chars)")
711        );
712    }
713
714    #[test]
715    fn one_liner_write_edit_list_search_fetch_and_generic() {
716        let content = text_lines(20);
717        let chars = content.chars().count();
718        assert_eq!(
719            summarize_one(
720                "write_file",
721                json!({"path": "a.rs", "content": "xx"}),
722                &content
723            ),
724            "[write_file] wrote 'a.rs' -> ok, 20 lines result",
725        );
726        assert_eq!(
727            summarize_one("edit_file", json!({"path": "b.rs"}), &content),
728            "[edit_file] edited 'b.rs' -> ok, 20 lines result",
729        );
730        assert_eq!(
731            summarize_one("list_dir", json!({"path": "src"}), &content),
732            "[list_dir] listed 'src' -> ok, 20 entries",
733        );
734        assert_eq!(
735            summarize_one("search", json!({"query": "fn main"}), &content),
736            "[search] searched 'fn main' -> ok, 20 matching lines",
737        );
738        assert_eq!(
739            summarize_one("search", json!({"pattern": "TODO"}), &content),
740            "[search] searched 'TODO' -> ok, 20 matching lines",
741        );
742        assert_eq!(
743            summarize_one(
744                "web_fetch",
745                json!({"url": "https://example.com/doc"}),
746                &content
747            ),
748            format!(
749                "[web_fetch] fetched 'https://example.com/doc' -> ok, 20 lines ({chars} chars)"
750            ),
751        );
752        assert_eq!(
753            summarize_one("gitea__issue_view", json!({"index": 1}), &content),
754            format!("[gitea__issue_view] result elided -> ok, 20 lines ({chars} chars)"),
755        );
756    }
757
758    #[test]
759    fn one_liner_missing_args_uses_placeholder() {
760        let line = summarize_one("read_file", json!(null), &text_lines(20));
761        assert!(
762            line.starts_with("[read_file] read '?' -> ok, 20 lines"),
763            "{line}"
764        );
765    }
766
767    #[test]
768    fn one_liner_long_command_excerpted_and_newlines_flattened() {
769        let cmd = format!("echo {}\nsecond", "x".repeat(100));
770        let line = summarize_one("run_command", json!({"command": cmd}), &text_lines(20));
771        assert!(!line.contains('\n'), "{line}");
772        assert!(line.contains('…'), "{line}");
773        assert!(
774            line.chars().count() < 200,
775            "one-liners stay under the default threshold"
776        );
777    }
778
779    #[test]
780    fn openai_results_pair_by_id_even_out_of_order() {
781        let read = text_lines(50);
782        let listing = text_lines(30);
783        let mut msgs = vec![
784            user("task"),
785            assistant_calls_openai(&[
786                ("call_a", "read_file", json!({"path": "x.rs"})),
787                ("call_b", "list_dir", json!({"path": "src"})),
788            ]),
789            // results arrive swapped
790            tool_result_id("call_b", &listing),
791            tool_result_id("call_a", &read),
792        ];
793        pad_tail(&mut msgs, 10);
794        let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
795        assert!(
796            content_of(&out[2]).starts_with("[list_dir] listed 'src'"),
797            "{}",
798            content_of(&out[2])
799        );
800        assert!(
801            content_of(&out[3]).starts_with("[read_file] read 'x.rs'"),
802            "{}",
803            content_of(&out[3])
804        );
805    }
806
807    #[test]
808    fn ollama_results_pair_positionally() {
809        let mut msgs = vec![
810            user("task"),
811            assistant_calls(&[
812                ("read_file", json!({"path": "x.rs"})),
813                ("list_dir", json!({"path": "src"})),
814            ]),
815            tool_result(&text_lines(50)),
816            tool_result(&text_lines(30)),
817        ];
818        pad_tail(&mut msgs, 10);
819        let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
820        assert!(content_of(&out[2]).starts_with("[read_file] read 'x.rs'"));
821        assert!(content_of(&out[3]).starts_with("[list_dir] listed 'src'"));
822    }
823
824    #[test]
825    fn orphan_tool_result_gets_generic_one_liner() {
826        let content = text_lines(25);
827        let chars = content.chars().count();
828        let mut msgs = vec![user("task"), tool_result(&content)]; // no assistant before it
829        pad_tail(&mut msgs, 10);
830        let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
831        assert_eq!(
832            content_of(&out[1]),
833            format!("[tool] result elided -> ok, 25 lines ({chars} chars)"),
834        );
835    }
836
837    #[test]
838    fn summarize_protects_tail_and_skips_existing_markers() {
839        let big = text_lines(40);
840        let marker = "[read_file] read 'x' -> ok, 3 lines (5 chars)";
841        let mut msgs = vec![
842            user("task"),
843            assistant_calls(&[("read_file", json!({"path": "x"}))]),
844            json!({"role": "tool", "content": marker}),
845        ];
846        pad_tail(&mut msgs, 2);
847        msgs.push(tool_result(&big)); // inside tail
848        let cfg = PruneConfig {
849            keep_last: 1,
850            summarize_min_chars: 10,
851            ..PruneConfig::default()
852        };
853        let out = summarize_aged_tool_results(&msgs, &cfg);
854        assert_eq!(content_of(&out[2]), marker, "existing marker not rewritten");
855        assert_eq!(content_of(out.last().unwrap()), big, "tail untouched");
856    }
857
858    // -- pass 3: JSON-aware arg shrinking ------------------------------------
859
860    #[test]
861    fn shrink_truncates_inside_object_args() {
862        let body = "fn main() {}\n".repeat(200); // 2600 chars
863        let mut msgs = vec![
864            user("task"),
865            assistant_calls(&[(
866                "write_file",
867                json!({"path": "src/main.rs", "content": body}),
868            )]),
869            tool_result("ok"),
870        ];
871        pad_tail(&mut msgs, 10);
872        let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
873        let args = &out[1]["tool_calls"][0]["function"]["arguments"];
874        assert!(args.is_object(), "object args stay objects");
875        assert_eq!(args["path"], "src/main.rs", "short values untouched");
876        let content = args["content"].as_str().unwrap();
877        assert!(content.chars().count() <= 200, "truncated to the cap");
878        assert!(content.contains("… [+"), "{content}");
879        assert!(content.ends_with("chars]"), "{content}");
880    }
881
882    #[test]
883    fn shrink_keeps_string_args_as_valid_json_strings() {
884        let args =
885            json!({"path": "a.rs", "old_string": "x".repeat(900), "new_string": "y".repeat(900)});
886        let mut msgs = vec![
887            user("task"),
888            assistant_calls_openai(&[("call_1", "edit_file", args)]),
889            tool_result_id("call_1", "ok"),
890        ];
891        pad_tail(&mut msgs, 10);
892        let before_len = msgs[1]["tool_calls"][0]["function"]["arguments"]
893            .as_str()
894            .unwrap()
895            .len();
896        let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
897        let s = out[1]["tool_calls"][0]["function"]["arguments"]
898            .as_str()
899            .expect("still a string");
900        let parsed: Value = serde_json::from_str(s).expect("still valid JSON");
901        assert_eq!(parsed["path"], "a.rs");
902        assert!(parsed["old_string"].as_str().unwrap().chars().count() <= 200);
903        assert!(s.len() < before_len);
904    }
905
906    #[test]
907    fn shrink_recurses_into_arrays_and_nested_objects() {
908        let args = json!({"items": ["z".repeat(700), {"inner": "w".repeat(700)}], "n": 7});
909        let mut msgs = vec![
910            user("task"),
911            assistant_calls(&[("custom", args)]),
912            tool_result("ok"),
913        ];
914        pad_tail(&mut msgs, 10);
915        let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
916        let a = &out[1]["tool_calls"][0]["function"]["arguments"];
917        assert!(a["items"][0].as_str().unwrap().chars().count() <= 200);
918        assert!(a["items"][1]["inner"].as_str().unwrap().chars().count() <= 200);
919        assert_eq!(a["n"], 7);
920    }
921
922    #[test]
923    fn shrink_replaces_unparseable_oversized_string_args() {
924        let bad = format!("{{not json {}", "x".repeat(800));
925        let mut msgs = vec![
926            user("task"),
927            json!({"role": "assistant", "content": "",
928                   "tool_calls": [{"id": "c1", "type": "function",
929                                   "function": {"name": "custom", "arguments": bad}}]}),
930            tool_result_id("c1", "ok"),
931        ];
932        pad_tail(&mut msgs, 10);
933        let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
934        let s = out[1]["tool_calls"][0]["function"]["arguments"]
935            .as_str()
936            .unwrap();
937        let parsed: Value = serde_json::from_str(s).expect("placeholder is valid JSON");
938        assert!(parsed["truncated"]
939            .as_str()
940            .unwrap()
941            .contains("not valid JSON"));
942    }
943
944    #[test]
945    fn shrink_leaves_small_args_and_tail_untouched() {
946        let small = json!({"path": "a.rs"});
947        let big = json!({"content": "q".repeat(2000)});
948        let mut msgs = vec![user("task"), assistant_calls(&[("read_file", small)])];
949        pad_tail(&mut msgs, 3);
950        msgs.push(assistant_calls(&[("write_file", big)])); // inside tail
951        let cfg = PruneConfig {
952            keep_last: 2,
953            ..PruneConfig::default()
954        };
955        let out = shrink_tool_call_args(&msgs, &cfg);
956        assert_eq!(out, msgs);
957    }
958
959    #[test]
960    fn shrink_cap_floor_prevents_marker_oscillation() {
961        let cfg = PruneConfig {
962            arg_string_cap: 0,
963            ..PruneConfig::default()
964        };
965        let mut msgs = vec![
966            user("task"),
967            assistant_calls(&[("write_file", json!({"content": "r".repeat(3000)}))]),
968            tool_result("ok"),
969        ];
970        pad_tail(&mut msgs, 10);
971        let once = shrink_tool_call_args(&msgs, &cfg);
972        let s = once[1]["tool_calls"][0]["function"]["arguments"]["content"]
973            .as_str()
974            .unwrap();
975        assert!(
976            s.chars().count() <= MIN_ARG_STRING_CAP,
977            "floored cap respected: {s}"
978        );
979        let twice = shrink_tool_call_args(&once, &cfg);
980        assert_eq!(twice, once);
981    }
982
983    #[test]
984    fn truncate_chars_is_exact_and_stable() {
985        assert_eq!(truncate_chars("short", 32), None);
986        let t = truncate_chars(&"é".repeat(100), 32).unwrap(); // multibyte-safe
987        assert!(t.chars().count() <= 32);
988        assert_eq!(
989            truncate_chars(&t, 32),
990            None,
991            "second application is a no-op"
992        );
993    }
994
995    // -- top-level prune ------------------------------------------------------
996
997    #[test]
998    fn prune_empty_and_all_tail_are_noops() {
999        let cfg = PruneConfig::default();
1000        let out = prune(&[], &cfg);
1001        assert!(out.messages.is_empty());
1002        assert_eq!(out.chars_reclaimed, 0);
1003
1004        let msgs = vec![user("task"), tool_result(&text_lines(100))];
1005        let out = prune(&msgs, &cfg); // len <= keep_last → fully protected
1006        assert_eq!(out.messages, msgs);
1007        assert_eq!(out.chars_reclaimed, 0);
1008    }
1009
1010    /// The realistic synthetic transcript: a coding session with a repeated
1011    /// failing `cargo test`, large file reads, and bulky write/edit args.
1012    /// Asserts every pass contributes, and prints the per-pass numbers.
1013    #[test]
1014    fn realistic_transcript_pass_by_pass_breakdown() {
1015        let cargo_fail = format!("error: test failed\n{}", text_lines(300));
1016        let lib_rs = text_lines(600);
1017        let cargo_pass = text_lines(60);
1018        let new_body = "fn lossy_op() { /* generated */ }\n".repeat(120);
1019        let msgs = vec![
1020            json!({"role": "system", "content": "You are newt, a coding agent."}),
1021            user("fix the failing test in newt-core"),
1022            assistant_calls(&[("read_file", json!({"path": "newt-core/src/lib.rs"}))]),
1023            tool_result(&lib_rs),
1024            assistant_calls(&[("run_command", json!({"command": "cargo test -p newt-core"}))]),
1025            tool_result(&cargo_fail),
1026            assistant_calls(&[(
1027                "edit_file",
1028                json!({
1029                    "path": "newt-core/src/lib.rs",
1030                    "old_string": text_lines(60),
1031                    "new_string": text_lines(62),
1032                }),
1033            )]),
1034            tool_result("edited newt-core/src/lib.rs (+2 lines)"),
1035            assistant_calls(&[("run_command", json!({"command": "cargo test -p newt-core"}))]),
1036            tool_result(&cargo_fail), // identical failure → dedupe fodder
1037            assistant_calls(&[(
1038                "write_file",
1039                json!({"path": "newt-core/src/fix.rs", "content": new_body}),
1040            )]),
1041            tool_result("wrote newt-core/src/fix.rs"),
1042            assistant_calls(&[("run_command", json!({"command": "cargo test -p newt-core"}))]),
1043            tool_result(&cargo_pass),
1044            assistant_text("All tests pass now. The bug was an off-by-one."),
1045            user("great — open the PR"),
1046        ];
1047
1048        let cfg = PruneConfig {
1049            keep_last: 4,
1050            ..PruneConfig::default()
1051        };
1052        let s0 = serialized_len(&msgs);
1053        let p1 = collapse_duplicate_tool_results(&msgs, &cfg);
1054        let s1 = serialized_len(&p1);
1055        let p2 = summarize_aged_tool_results(&p1, &cfg);
1056        let s2 = serialized_len(&p2);
1057        let p3 = shrink_tool_call_args(&p2, &cfg);
1058        let s3 = serialized_len(&p3);
1059        println!("baseline: {s0} chars");
1060        println!("pass 1 (dedupe):      -{} chars -> {s1}", s0 - s1);
1061        println!("pass 2 (one-liners):  -{} chars -> {s2}", s1 - s2);
1062        println!("pass 3 (arg shrink):  -{} chars -> {s3}", s2 - s3);
1063        assert!(s1 < s0, "dedupe reclaims");
1064        assert!(s2 < s1, "one-liners reclaim");
1065        assert!(s3 < s2, "arg shrink reclaims");
1066
1067        let outcome = prune(&msgs, &cfg);
1068        assert_eq!(outcome.messages, p3, "prune == the three passes in order");
1069        assert_eq!(
1070            outcome.chars_reclaimed,
1071            s0 - s3,
1072            "accounting matches the pass chain"
1073        );
1074        println!(
1075            "total: {} of {s0} chars reclaimed ({:.0}%)",
1076            outcome.chars_reclaimed,
1077            100.0 * outcome.chars_reclaimed as f64 / s0 as f64
1078        );
1079        // The last user message + final answer are inside keep_last=4.
1080        assert_eq!(outcome.messages[15], msgs[15]);
1081        assert_eq!(outcome.messages[14], msgs[14]);
1082    }
1083
1084    // -- property tests (deterministic seeded loops) --------------------------
1085
1086    /// xorshift64* — deterministic, no dev-dep.
1087    struct Rng(u64);
1088
1089    impl Rng {
1090        fn next(&mut self) -> u64 {
1091            let mut x = self.0;
1092            x ^= x >> 12;
1093            x ^= x << 25;
1094            x ^= x >> 27;
1095            self.0 = x;
1096            x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1097        }
1098
1099        fn below(&mut self, n: usize) -> usize {
1100            (self.next() % n as u64) as usize
1101        }
1102    }
1103
1104    /// Deterministic synthetic transcript: mixed dialects, duplicate-prone
1105    /// result payloads, oversized and small args, interleaved chatter.
1106    fn synth_transcript(seed: u64) -> Vec<Value> {
1107        let mut rng = Rng(seed.max(1));
1108        let tools = [
1109            "run_command",
1110            "read_file",
1111            "write_file",
1112            "edit_file",
1113            "list_dir",
1114            "search",
1115            "web_fetch",
1116            "use_skill",
1117        ];
1118        // Small payload pool → duplicates across rounds are likely.
1119        let pool: Vec<String> = (0..5).map(|k| text_lines(10 + k * 37)).collect();
1120        let mut msgs = vec![
1121            json!({"role": "system", "content": "You are newt."}),
1122            user("do the task"),
1123        ];
1124        for round in 0..(4 + rng.below(5)) {
1125            if rng.below(4) == 0 {
1126                msgs.push(assistant_text("thinking out loud"));
1127                msgs.push(user(&format!("continue ({round})")));
1128            }
1129            let openai = rng.below(2) == 0;
1130            let ncalls = 1 + rng.below(3);
1131            let calls: Vec<(String, String, Value)> = (0..ncalls)
1132                .map(|j| {
1133                    let name = tools[rng.below(tools.len())];
1134                    let key = match name {
1135                        "run_command" => "command",
1136                        "search" => "query",
1137                        "web_fetch" => "url",
1138                        _ => "path",
1139                    };
1140                    let mut args = json!({key: format!("target-{}-{}", round, j)});
1141                    if rng.below(3) == 0 {
1142                        args["content"] = Value::String("b".repeat(50 + rng.below(2000)));
1143                    }
1144                    (format!("call_{round}_{j}"), name.to_string(), args)
1145                })
1146                .collect();
1147            if openai {
1148                let refs: Vec<(&str, &str, Value)> = calls
1149                    .iter()
1150                    .map(|(id, n, a)| (id.as_str(), n.as_str(), a.clone()))
1151                    .collect();
1152                msgs.push(assistant_calls_openai(&refs));
1153                // Sometimes deliver results out of order (ids still pair).
1154                let mut order: Vec<usize> = (0..ncalls).collect();
1155                if ncalls > 1 && rng.below(2) == 0 {
1156                    order.swap(0, 1);
1157                }
1158                for &j in &order {
1159                    let content = if rng.below(2) == 0 {
1160                        pool[rng.below(pool.len())].clone()
1161                    } else {
1162                        text_lines(1 + rng.below(80))
1163                    };
1164                    msgs.push(tool_result_id(&calls[j].0, &content));
1165                }
1166            } else {
1167                let refs: Vec<(&str, Value)> = calls
1168                    .iter()
1169                    .map(|(_, n, a)| (n.as_str(), a.clone()))
1170                    .collect();
1171                msgs.push(assistant_calls(&refs));
1172                for _ in 0..ncalls {
1173                    let content = if rng.below(2) == 0 {
1174                        pool[rng.below(pool.len())].clone()
1175                    } else {
1176                        text_lines(1 + rng.below(80))
1177                    };
1178                    msgs.push(tool_result(&content));
1179                }
1180            }
1181        }
1182        msgs.push(assistant_text("done"));
1183        msgs.push(user("thanks"));
1184        msgs
1185    }
1186
1187    fn property_configs() -> Vec<PruneConfig> {
1188        vec![
1189            PruneConfig::default(),
1190            PruneConfig {
1191                keep_last: 4,
1192                ..PruneConfig::default()
1193            },
1194            PruneConfig {
1195                keep_last: 0,
1196                dedupe_min_chars: 50,
1197                summarize_min_chars: 50,
1198                args_max_chars: 100,
1199                arg_string_cap: 0,
1200            },
1201        ]
1202    }
1203
1204    #[test]
1205    fn property_output_tool_args_always_parse_and_keep_their_shape() {
1206        for seed in 1..=40 {
1207            let msgs = synth_transcript(seed);
1208            for cfg in property_configs() {
1209                let out = prune(&msgs, &cfg).messages;
1210                for (i, msg) in out.iter().enumerate() {
1211                    let Some(tcs) = msg["tool_calls"].as_array() else {
1212                        continue;
1213                    };
1214                    for (j, tc) in tcs.iter().enumerate() {
1215                        let before = &msgs[i]["tool_calls"][j]["function"]["arguments"];
1216                        let after = &tc["function"]["arguments"];
1217                        match after {
1218                            Value::String(s) => {
1219                                assert!(
1220                                    before.is_string(),
1221                                    "seed {seed}: string args stay strings"
1222                                );
1223                                serde_json::from_str::<Value>(s).unwrap_or_else(|e| {
1224                                    panic!("seed {seed} msg {i} call {j}: invalid JSON ({e}): {s}")
1225                                });
1226                            }
1227                            v => assert!(
1228                                !before.is_string() && (v.is_object() || v.is_null()),
1229                                "seed {seed}: structured args stay structured"
1230                            ),
1231                        }
1232                    }
1233                }
1234            }
1235        }
1236    }
1237
1238    #[test]
1239    fn property_tool_pairing_structure_is_preserved() {
1240        for seed in 1..=40 {
1241            let msgs = synth_transcript(seed);
1242            for cfg in property_configs() {
1243                let out = prune(&msgs, &cfg).messages;
1244                assert_eq!(
1245                    out.len(),
1246                    msgs.len(),
1247                    "seed {seed}: no messages added or removed"
1248                );
1249                for (a, b) in msgs.iter().zip(&out) {
1250                    assert_eq!(a["role"], b["role"], "seed {seed}: roles preserved");
1251                    assert_eq!(
1252                        a["tool_call_id"], b["tool_call_id"],
1253                        "seed {seed}: result ids preserved"
1254                    );
1255                    let (atc, btc) = (a["tool_calls"].as_array(), b["tool_calls"].as_array());
1256                    assert_eq!(
1257                        atc.map(Vec::len),
1258                        btc.map(Vec::len),
1259                        "seed {seed}: tool_call counts preserved"
1260                    );
1261                    if let (Some(atc), Some(btc)) = (atc, btc) {
1262                        for (x, y) in atc.iter().zip(btc) {
1263                            assert_eq!(x["id"], y["id"], "seed {seed}: call ids preserved");
1264                            assert_eq!(
1265                                x["function"]["name"], y["function"]["name"],
1266                                "seed {seed}: call names preserved"
1267                            );
1268                        }
1269                    }
1270                }
1271            }
1272        }
1273    }
1274
1275    #[test]
1276    fn property_prune_is_idempotent() {
1277        for seed in 1..=40 {
1278            let msgs = synth_transcript(seed);
1279            for cfg in property_configs() {
1280                let once = prune(&msgs, &cfg);
1281                let twice = prune(&once.messages, &cfg);
1282                assert_eq!(
1283                    twice.messages, once.messages,
1284                    "seed {seed}: prune(prune(x)) == prune(x)"
1285                );
1286                assert_eq!(
1287                    twice.chars_reclaimed, 0,
1288                    "seed {seed}: second pass reclaims nothing"
1289                );
1290            }
1291        }
1292    }
1293
1294    #[test]
1295    fn property_protected_tail_is_byte_identical() {
1296        for seed in 1..=40 {
1297            let msgs = synth_transcript(seed);
1298            for cfg in property_configs() {
1299                let out = prune(&msgs, &cfg).messages;
1300                let tail_start = msgs.len().saturating_sub(cfg.keep_last);
1301                for i in tail_start..msgs.len() {
1302                    assert_eq!(
1303                        serde_json::to_string(&msgs[i]).unwrap(),
1304                        serde_json::to_string(&out[i]).unwrap(),
1305                        "seed {seed}: tail message {i} must be byte-identical"
1306                    );
1307                }
1308            }
1309        }
1310    }
1311
1312    #[test]
1313    fn property_chars_reclaimed_accounting_is_exact() {
1314        for seed in 1..=40 {
1315            let msgs = synth_transcript(seed);
1316            for cfg in property_configs() {
1317                let outcome = prune(&msgs, &cfg);
1318                let before = serialized_len(&msgs);
1319                let after = serialized_len(&outcome.messages);
1320                assert!(
1321                    after <= before,
1322                    "seed {seed}: prune never grows the transcript"
1323                );
1324                assert_eq!(
1325                    outcome.chars_reclaimed,
1326                    before - after,
1327                    "seed {seed}: exact accounting"
1328                );
1329            }
1330        }
1331    }
1332}