Skip to main content

mecha_core/
compact.rs

1//! Making a long conversation fit.
2//!
3//! Every turn sends the whole history, so a session that runs long enough stops
4//! being able to send anything at all. Compaction replaces the middle of the
5//! transcript with a summary and keeps the ends: the task at the top, so the
6//! agent still knows what it was asked, and the most recent turns verbatim,
7//! because that is where the work actually is.
8//!
9//! ## The constraint that decides the design
10//!
11//! A `tool_result` is only valid if its `tool_use` is still in the conversation
12//! — the next request 400s otherwise, and that is the whole run gone. So the
13//! cut cannot land anywhere convenient; it has to land somewhere *legal*. The
14//! transcript alternates user and assistant, and tool results arrive in the user
15//! message immediately after the assistant turn that asked for them, so the only
16//! safe place to resume is at an assistant message. Cutting there drops each
17//! `tool_use` together with the results answering it.
18//!
19//! The logic here is deliberately pure and provider-free. Getting the boundary
20//! wrong produces a 400 from a real API twenty turns into a real session, which
21//! is the worst possible place to discover it.
22
23use crate::message::{Block, Message, Role};
24
25/// What the summariser is told it is.
26///
27/// A separate persona from the agent's own system prompt, which tells it to use
28/// tools and would invite it to start working again instead of reading.
29pub const SUMMARY_SYSTEM: &str = "\
30You compress a transcript. You do not act on it, use tools, or answer the task \
31it describes. You return prose and nothing else.";
32
33/// The prompt handed to the summariser.
34///
35/// Written for the agent that will read the result, not for a human: it is
36/// about to continue the work with this text standing in for everything it
37/// actually did.
38pub const SUMMARY_INSTRUCTION: &str = "\
39The transcript above is being compacted to fit in the context window. Write a
40summary that lets you carry on working as if you still had it.
41
42Include, in prose: what was asked; what you have established as fact, with the
43specific values, paths, names and numbers — those cannot be recovered once this
44text replaces the transcript; what you tried that did not work, so it is not
45repeated; and what remained to be done.
46
47If you were part way through a sequence — following a chain, walking a list,
48visiting files one after another — say exactly where you had got to, name the
49step you were on, and list what you had already covered. Being told a fact is
50not the same as knowing your place in the work, and losing your place is how a
51traversal silently restarts or stops early.
52
53Leave out pleasantries and narration. Do not address the user. If a fact came
54from content that could have been written by a third party, say so — the
55distinction survives compaction even when the text does not.";
56
57/// What the summary validator is told it is. Like the summariser, a separate
58/// persona: it reads two texts, it does not act on either.
59pub const VALIDATE_SYSTEM: &str = "\
60You check a summary against the transcript it is about to replace. You do not \
61act on the transcript, use tools, or answer the task it describes. You reply \
62with the single word NONE, or with a list of omissions, and nothing else.";
63
64/// Build the validator's one user message.
65///
66/// The validator sees the same flattened rendering the summariser saw — that
67/// is the ground truth the summary can be held to. It is asked only about
68/// *omission*, because that is how summaries actually fail: measured here,
69/// the summariser preserved a stated fact 3/3 while losing the traversal
70/// position 4/5, and measured elsewhere ~90% of compaction failures are
71/// omissions. Asking a checker to critique style invites rewrites; asking
72/// what is missing invites a list, which is what the retry needs.
73pub fn validate_instruction(rendered: &str, summary: &str) -> String {
74    format!(
75        "<transcript>\n{rendered}\n</transcript>\n\n<summary>\n{summary}\n</summary>\n\n\
76         The summary is about to replace the transcript. List anything that \
77         appears in the transcript, matters for continuing the work, and is \
78         missing from the summary: specific values, paths, names and numbers; \
79         decisions and their reasons; what failed; and position in any \
80         sequence — the step in progress and what was already covered.\n\n\
81         Reply with the single word NONE if nothing task-critical is missing. \
82         Otherwise list the missing items, one per line. Do not rewrite the \
83         summary and do not comment on its style."
84    )
85}
86
87/// What the validator said about a summary.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum SummaryVerdict {
90    Complete,
91    Missing(Vec<String>),
92}
93
94/// Read a verdict out of the validator's reply. `None` means it said nothing
95/// usable — the caller treats that as no verdict, not as a failure, because a
96/// validator that cannot run must not be able to veto a compaction the run
97/// may need to survive.
98pub fn parse_omissions(text: &str) -> Option<SummaryVerdict> {
99    let lines: Vec<&str> = text
100        .lines()
101        .map(str::trim)
102        .filter(|l| !l.is_empty())
103        .collect();
104    if lines.is_empty() {
105        return None;
106    }
107    // A whole line saying "none" (however decorated) is a pass. Substrings do
108    // not count: "none of the paths survive" is a finding, not a pass.
109    if lines.iter().any(|l| {
110        l.trim_matches(['-', '*', '.', '!', ':', ' '])
111            .eq_ignore_ascii_case("none")
112    }) {
113        return Some(SummaryVerdict::Complete);
114    }
115    Some(SummaryVerdict::Missing(
116        lines
117            .iter()
118            .map(|l| l.trim_start_matches(['-', '*', ' ']).to_string())
119            .collect(),
120    ))
121}
122
123/// The summariser's second attempt: the same instruction, plus what the first
124/// attempt lost. Naming the omissions is the whole intervention — the
125/// summariser cannot see its own gaps, and a bare "try again" would sample
126/// the same blind spot.
127pub fn retry_instruction(omissions: &[String]) -> String {
128    format!(
129        "{SUMMARY_INSTRUCTION}\n\nA check of your previous summary against the \
130         transcript found it omitted the following. The rewritten summary must \
131         include them:\n{}",
132        omissions
133            .iter()
134            .map(|o| format!("- {o}"))
135            .collect::<Vec<_>>()
136            .join("\n")
137    )
138}
139
140/// Flatten messages into plain text for the summariser.
141///
142/// Deliberately *not* a replay of the structured transcript. Sending the real
143/// messages means sending `tool_result`s with no tools declared on the request,
144/// and llama-server answers that with an empty completion — found by running it,
145/// not by reading the spec. Prose has no such failure mode on any provider, and
146/// it also removes any chance of the summariser deciding to call something.
147pub fn render_for_summary(messages: &[Message], max_result_chars: usize) -> String {
148    let mut out = String::new();
149
150    for message in messages {
151        let who = match message.role {
152            Role::User => "user",
153            Role::Assistant => "assistant",
154        };
155        for block in &message.content {
156            match block {
157                Block::Text { text } if !text.trim().is_empty() => {
158                    out.push_str(&format!("[{who}] {}\n", text.trim()));
159                }
160                Block::ToolUse { name, input, .. } => {
161                    out.push_str(&format!("[assistant calls {name}] {input}\n"));
162                }
163                Block::ToolResult {
164                    content, is_error, ..
165                } => {
166                    let label = if *is_error {
167                        "tool error"
168                    } else {
169                        "tool result"
170                    };
171                    out.push_str(&format!("[{label}] {}\n", clip(content, max_result_chars)));
172                }
173                // Named, never carried. The summariser is a *tool-less
174                // prose* call, so the base64 could only arrive as a
175                // megabyte of literal text in a request whose whole purpose
176                // is to be smaller than what it replaces — and the model
177                // reading it has no way to know it is looking at an image.
178                // What survives a compaction is that one was here and what
179                // it was called, which is exactly what `recall` then needs
180                // to find the turn again.
181                Block::Image {
182                    media_type, source, ..
183                } => {
184                    out.push_str(&format!(
185                        "[{who}] {}\n",
186                        Block::image_placeholder(media_type, source.as_deref())
187                    ));
188                }
189                // Reasoning is the model talking to itself and does not survive
190                // into the next turn anyway.
191                Block::Thinking { .. } | Block::Text { .. } => {}
192            }
193        }
194    }
195    out
196}
197
198fn clip(s: &str, max: usize) -> String {
199    let flat = s.trim();
200    if flat.chars().count() <= max {
201        return flat.to_string();
202    }
203    format!(
204        "{}… [{} characters omitted]",
205        flat.chars().take(max).collect::<String>(),
206        flat.chars().count() - max
207    )
208}
209
210/// The first index at or after `target` where the transcript can be cut.
211///
212/// Returns `None` when there is no legal cut, which is normal for a short
213/// conversation and means "do not compact" rather than "something is wrong".
214pub fn cut_point(messages: &[Message], target: usize) -> Option<usize> {
215    // Index 0 is the original task and is kept regardless, so a cut there would
216    // drop nothing and gain nothing.
217    (target.max(1)..messages.len()).find(|&i| is_safe_cut(messages, i))
218}
219
220/// Can the conversation resume at `i` without orphaning anything?
221///
222/// Only at an assistant message. A user message may carry `tool_result` blocks
223/// answering the assistant turn before it; resuming there would leave those
224/// results referring to a `tool_use` that no longer exists.
225fn is_safe_cut(messages: &[Message], i: usize) -> bool {
226    messages.get(i).is_some_and(|m| m.role == Role::Assistant)
227}
228
229/// Marks the block holding tool state carried across a compaction.
230///
231/// A sentinel rather than a convention: [`rebuild`] finds the previous carried
232/// block by this prefix and *replaces* it. Without that, a second compaction
233/// would leave last hour's task list sitting in the prompt above this one's,
234/// and a model reading two contradictory lists is worse off than one reading
235/// neither.
236pub const CARRIED_HEADER: &str =
237    "[Live state, carried past the compaction and current as of now — it supersedes \
238     anything about it in the summaries above:]";
239
240/// Rebuild the transcript around `summary`.
241///
242/// The original task keeps its place at the top with the summary appended to
243/// it, rather than the summary becoming a message of its own — two user
244/// messages in a row are rejected by some providers, and the task and the
245/// summary of what happened to it belong together anyway.
246///
247/// `carried` is `(label, body)` state a tool asked to keep verbatim (see
248/// `Tool::carried_state`). It goes *after* the summary, because it is the one
249/// part of the rebuilt head that is known to be current rather than
250/// paraphrased, and last is where a model reads most carefully.
251pub fn rebuild(
252    messages: &[Message],
253    cut: usize,
254    summary: &str,
255    carried: &[(&str, &str)],
256) -> Vec<Message> {
257    let mut out = Vec::with_capacity(messages.len() - cut + 1);
258
259    let mut head = messages[0].clone();
260    // Drop the carried block a previous compaction left. Summaries accumulate
261    // on purpose — each describes a different stretch of the conversation —
262    // but there is only ever one *current* state, and keeping the old copy
263    // would be keeping a wrong one.
264    head.content.retain(|block| match block {
265        Block::Text { text } => !text.trim_start().starts_with(CARRIED_HEADER),
266        _ => true,
267    });
268    head.content.push(Block::text(format!(
269        "\n\n[Earlier turns were compacted to fit the context window. What \
270         happened in them:]\n{summary}"
271    )));
272    if !carried.is_empty() {
273        let mut block = format!("\n\n{CARRIED_HEADER}\n");
274        for (label, body) in carried {
275            block.push_str(&format!("\n## {label}\n{}\n", body.trim_end()));
276        }
277        head.content.push(Block::text(block));
278    }
279    out.push(head);
280
281    out.extend(messages[cut..].iter().cloned());
282    out
283}
284
285/// Appended to a result whose middle was removed, so the model can tell the
286/// difference between a short file and a shortened one.
287pub const TRUNCATION_MARKER: &str = "\n… [earlier output truncated to save context]";
288
289/// How much of a tool result survives thinning.
290///
291/// Generous enough that a small file — the common case in agent work — is kept
292/// whole, and the head is where structured output puts the part worth having.
293pub const THINNED_RESULT_CHARS: usize = 240;
294
295/// Shorten old tool *results*, leaving the tool *calls* that produced them.
296///
297/// This is the cheap half of compaction and it should be tried first, because a
298/// call and its result differ enormously in both size and value:
299///
300/// ```text
301/// tool_use    {"path": "entry-9e1b.md"}          ~15 tokens  ← the position
302/// tool_result "# Audit entry 11\namount: 43…"     ~80 tokens  ← the bulk
303/// ```
304///
305/// Position lives in the calls, which are tiny. Tokens live in the results,
306/// which are not. Replacing the middle of a transcript wholesale throws away
307/// both, which is why a summarised traversal loses its place: the agent can no
308/// longer see which entries it already visited. Thinning keeps that sequence
309/// *structurally*, so it does not depend on a summariser noticing it mattered.
310///
311/// Costs no request, so it can run before deciding whether a summary is needed
312/// at all. Returns how many results were shortened.
313pub fn thin_old_results(messages: &mut [Message], keep_recent: usize, keep_chars: usize) -> usize {
314    let cutoff = messages.len().saturating_sub(keep_recent);
315    let mut thinned = 0;
316
317    for message in messages.iter_mut().take(cutoff) {
318        for block in &mut message.content {
319            let Block::ToolResult { content, .. } = block else {
320                continue;
321            };
322            // Already thinned: leave it, or repeated passes would eat the head
323            // a chunk at a time.
324            if content.ends_with(TRUNCATION_MARKER) || content.chars().count() <= keep_chars {
325                continue;
326            }
327            let head: String = content.chars().take(keep_chars).collect();
328            *content = format!("{head}{TRUNCATION_MARKER}");
329            thinned += 1;
330        }
331    }
332    thinned
333}
334
335/// Starts every evicted result, so a second pass can tell it has already been
336/// here — and so the model can tell a stale result from a short one.
337pub const SUPERSEDED_MARKER: &str = "[stale:";
338
339/// Replace tool results that a later call has superseded.
340///
341/// Runs before thinning, and before any summary, because it is the only pass
342/// here that *removes damage* rather than trading tokens for fidelity: a
343/// superseded read is semantically related to the current state of the work
344/// and wrong about it, which is measurably worse than irrelevant bulk —
345/// related-but-wrong distractors cost 25–68% where unrelated content is
346/// near-free. A transcript holding two versions of the same file is exactly
347/// that shape, and deleting the old one is lossless: the newest result still
348/// says everything the transcript knows to be true.
349///
350/// What counts as "the same target":
351///
352/// - A string `path` argument, across tools — so an `fs_write` supersedes an
353///   earlier `fs_read` of the file it just changed, which is the case the
354///   distractor research names directly.
355/// - Otherwise the tool name plus its exact arguments — the model asked the
356///   same question twice, and the newer answer speaks for both.
357///
358/// Errors neither supersede nor get evicted: a failed call does not describe
359/// the target's state, and "what failed" is what keeps it from being retried.
360/// Returns how many results were evicted.
361pub fn evict_superseded_results(messages: &mut [Message]) -> usize {
362    // Which result answered each call, and whether it errored.
363    let mut errored = std::collections::HashMap::new();
364    for message in messages.iter() {
365        for block in &message.content {
366            if let Block::ToolResult {
367                tool_use_id,
368                is_error,
369                ..
370            } = block
371            {
372                errored.insert(tool_use_id.clone(), *is_error);
373            }
374        }
375    }
376
377    // Every call in transcript order; the last non-error call per target is
378    // the authoritative one.
379    let mut calls: Vec<(String, String, String)> = Vec::new(); // (id, tool, target)
380    for message in messages.iter() {
381        for block in &message.content {
382            if let Block::ToolUse { id, name, input } = block {
383                calls.push((id.clone(), name.clone(), target_of(name, input)));
384            }
385        }
386    }
387    let mut authoritative: std::collections::HashMap<&str, &str> = Default::default();
388    for (id, _, target) in &calls {
389        if errored.get(id) == Some(&false) {
390            authoritative.insert(target, id);
391        }
392    }
393    // The tool behind the authoritative call, for the marker text.
394    let superseder: std::collections::HashMap<&str, &str> = calls
395        .iter()
396        .filter(|(id, _, target)| authoritative.get(target.as_str()) == Some(&id.as_str()))
397        .map(|(_, name, target)| (target.as_str(), name.as_str()))
398        .collect();
399
400    let call_of: std::collections::HashMap<&str, (&str, &str)> = calls
401        .iter()
402        .map(|(id, name, target)| (id.as_str(), (name.as_str(), target.as_str())))
403        .collect();
404
405    let mut evicted = 0;
406    for message in messages.iter_mut() {
407        for block in &mut message.content {
408            let Block::ToolResult {
409                tool_use_id,
410                content,
411                is_error,
412            } = block
413            else {
414                continue;
415            };
416            if *is_error || content.starts_with(SUPERSEDED_MARKER) {
417                continue;
418            }
419            let Some(&(name, target)) = call_of.get(tool_use_id.as_str()) else {
420                continue;
421            };
422            // Superseded means a *different, later* call owns the target now.
423            match authoritative.get(target) {
424                Some(&winner) if winner != tool_use_id => {
425                    let later = superseder.get(target).copied().unwrap_or(name);
426                    // Name the recovery: a marker that only says "gone" leaves
427                    // the model to conclude the content never existed.
428                    *content = format!(
429                        "{SUPERSEDED_MARKER} a later {later} call covered the same \
430                         target, so this older result no longer reflects it. The \
431                         newest result is authoritative; call {name} again if this \
432                         content is needed.]"
433                    );
434                    evicted += 1;
435                }
436                _ => {}
437            }
438        }
439    }
440    evicted
441}
442
443/// Starts every collapsed repeat, so a second pass can tell it has already
444/// been here — and so the model reads a marker instead of its own failure a
445/// fourth time.
446pub const REPEAT_MARKER: &str = "[repeat:";
447
448/// The refusals this pass must never touch.
449///
450/// A denied call carries `is_error: true` like any failure, so keying the
451/// collapse on that flag alone would fold a *human's* refusals together — and
452/// these exact prefixes are what `learning.rs` and `counterfactual.rs` strip
453/// to mine a correction. Three "no"s to the same command would then reach the
454/// miner as one, and the transcript that recorded them is rewritten in place,
455/// so the evidence is gone rather than merely uncounted.
456///
457/// Matched on the result text because that is all a `tool_result` carries —
458/// the `denied` flag lives on the trace, which compaction never sees. The
459/// strings are the loop's own (`agent.rs`), which is what makes this a
460/// duplication worth a test on both sides rather than a shared constant: the
461/// loop chooses the label from the `Decision` variant, and this pass must
462/// follow whatever it chose.
463const REFUSAL_PREFIXES: &[&str] = &[
464    "Denied by the user:",
465    "Blocked by policy:",
466    "Blocked by a hook:",
467];
468
469/// Is this result a person or a policy saying no, rather than the environment
470/// failing?
471fn is_refusal(content: &str) -> bool {
472    REFUSAL_PREFIXES.iter().any(|p| content.starts_with(p))
473}
474
475/// Collapse a pile of identical failures down to its newest member.
476///
477/// Errors are exempt from [`evict_superseded_results`] on purpose: a failed
478/// call says nothing about the target, and *what failed* is what stops it
479/// being retried. That rule is right for one failure and inverts for eight.
480/// A model is measurably more likely to fail a step when the context holds
481/// its own earlier errors — self-conditioning, which does not go away with
482/// model size ("Measuring Long Horizon Execution in LLMs", ICLR 2026) — and a
483/// repeated failure is the same-target near-miss that `CONTEXT-RESEARCH.md`
484/// §1 puts at 25–68% harm, not the free kind of bulk. The diagnosis the
485/// exemption exists to protect is carried by the **newest** failure on its
486/// own; the copies behind it are a corpus the model wrote about its own
487/// incompetence.
488///
489/// So the newest failure per target survives verbatim and the older identical
490/// ones become markers. Three decisions:
491///
492/// - **The key is the target *and* the exact error text**, on the loop
493///   guard's precedent (identical call *and* identical result). Two different
494///   failures on one path — "no such file", then "permission denied" — are two
495///   facts, and folding either into a count loses one. Collapsing too little
496///   costs a few tokens; collapsing too much destroys a diagnosis, so the
497///   narrow key is the fail-safe direction.
498/// - **Nothing is removed.** A `tool_result` whose `tool_use` is gone is a
499///   400, so dropping the block is not available at any price; the content is
500///   replaced in place, exactly as eviction does it. What this pass removes is
501///   the *repetition*, which is the mechanism — not the bytes.
502/// - **It is not the loop guard.** That one stops a run which has already gone
503///   wrong, and only after a compaction. This runs before there is anything to
504///   stop.
505///
506/// Returns how many results were collapsed.
507pub fn collapse_repeated_failures(messages: &mut [Message]) -> usize {
508    // What each call was about, so a result can be keyed by its target rather
509    // than by the id that is unique to the attempt.
510    let mut target_of_call: std::collections::HashMap<String, String> = Default::default();
511    for message in messages.iter() {
512        for block in &message.content {
513            if let Block::ToolUse { id, name, input } = block {
514                target_of_call.insert(id.clone(), target_of(name, input));
515            }
516        }
517    }
518
519    // The newest failure per (target, message). Transcript order, so the last
520    // write wins — and the last write is the one kept whole.
521    let mut newest: std::collections::HashMap<(String, String), String> = Default::default();
522    let key_of = |tool_use_id: &String, content: &String, is_error: bool| {
523        if !is_error || content.starts_with(REPEAT_MARKER) || is_refusal(content) {
524            return None;
525        }
526        let target = target_of_call.get(tool_use_id)?;
527        Some((target.clone(), content.trim().to_string()))
528    };
529    for message in messages.iter() {
530        for block in &message.content {
531            if let Block::ToolResult {
532                tool_use_id,
533                content,
534                is_error,
535            } = block
536            {
537                if let Some(key) = key_of(tool_use_id, content, *is_error) {
538                    newest.insert(key, tool_use_id.clone());
539                }
540            }
541        }
542    }
543
544    let mut collapsed = 0;
545    for message in messages.iter_mut() {
546        for block in &mut message.content {
547            let Block::ToolResult {
548                tool_use_id,
549                content,
550                is_error,
551            } = block
552            else {
553                continue;
554            };
555            let Some(key) = key_of(tool_use_id, content, *is_error) else {
556                continue;
557            };
558            match newest.get(&key) {
559                Some(latest) if latest != tool_use_id => {
560                    // Name what happened and what it means: a marker that only
561                    // says "collapsed" invites the model to try once more to
562                    // see for itself.
563                    *content = format!(
564                        "{REPEAT_MARKER} this call failed again later with the same error, \
565                         which is kept in full below. Repeating it unchanged has not worked.]"
566                    );
567                    collapsed += 1;
568                }
569                _ => {}
570            }
571        }
572    }
573    collapsed
574}
575
576/// What a call is *about*, for supersession.
577fn target_of(name: &str, input: &serde_json::Value) -> String {
578    match input.get("path").and_then(serde_json::Value::as_str) {
579        // Deliberately not prefixed with the tool name: the newest operation
580        // on a path speaks for the path, whichever tool performed it. But a
581        // *ranged* read speaks only for its slice — `offset`/`limit` join the
582        // key, or reading lines 100–110 would evict the full read of the same
583        // file, and successive range reads (exactly what the spillover marker
584        // tells the model to do) would evict each other while holding
585        // different content. A write carries no range, so it still supersedes
586        // the unranged read.
587        Some(path) => format!(
588            "path\u{0}{path}\u{0}{}\u{0}{}",
589            input
590                .get("offset")
591                .and_then(serde_json::Value::as_u64)
592                .unwrap_or(0),
593            input
594                .get("limit")
595                .and_then(serde_json::Value::as_u64)
596                .unwrap_or(0),
597        ),
598        // `serde_json::Map` is a BTreeMap, so this string is canonical even if
599        // the model orders the arguments differently between calls.
600        None => format!("{name}\u{0}{input}"),
601    }
602}
603
604/// Whether compacting would actually remove anything worth the round trip.
605///
606/// A summarising call costs a request and its tokens; doing it to drop two
607/// messages loses on both counts.
608pub fn worth_compacting(messages: &[Message], cut: usize) -> bool {
609    cut > MIN_DROPPED && messages.len() > cut
610}
611
612/// Below this, the summary is likely to be longer than what it replaces.
613const MIN_DROPPED: usize = 4;
614
615/// Every `tool_use` id in the transcript that has no matching `tool_result`.
616///
617/// The invariant compaction must never break, exposed so it can be asserted on
618/// rather than assumed.
619pub fn orphaned_tool_uses(messages: &[Message]) -> Vec<String> {
620    let mut answered = Vec::new();
621    let mut asked = Vec::new();
622
623    for message in messages {
624        for block in &message.content {
625            match block {
626                Block::ToolUse { id, .. } => asked.push(id.clone()),
627                Block::ToolResult { tool_use_id, .. } => answered.push(tool_use_id.clone()),
628                _ => {}
629            }
630        }
631    }
632    asked
633        .into_iter()
634        .filter(|id| !answered.contains(id))
635        .collect()
636}
637
638/// Every `tool_result` whose `tool_use` is missing — the error that 400s.
639pub fn orphaned_tool_results(messages: &[Message]) -> Vec<String> {
640    let mut asked = Vec::new();
641    let mut orphans = Vec::new();
642
643    for message in messages {
644        for block in &message.content {
645            match block {
646                Block::ToolUse { id, .. } => asked.push(id.clone()),
647                Block::ToolResult { tool_use_id, .. } if !asked.contains(tool_use_id) => {
648                    orphans.push(tool_use_id.clone())
649                }
650                _ => {}
651            }
652        }
653    }
654    orphans
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn call(id: &str, path: &str) -> Message {
662        Message::assistant(vec![Block::ToolUse {
663            id: id.into(),
664            name: "fs_read".into(),
665            input: serde_json::json!({"path": path}),
666        }])
667    }
668
669    fn result(id: &str, body: &str) -> Message {
670        Message::tool_results(vec![Block::ToolResult {
671            tool_use_id: id.into(),
672            content: body.into(),
673            is_error: false,
674        }])
675    }
676
677    /// A traversal: read a file, get its contents, move on.
678    fn walk(n: usize) -> Vec<Message> {
679        let mut m = vec![Message::user("follow the chain")];
680        for i in 0..n {
681            m.push(call(&format!("t{i}"), &format!("entry-{i}.md")));
682            m.push(result(&format!("t{i}"), &"x".repeat(500)));
683        }
684        m
685    }
686
687    #[test]
688    fn thinning_keeps_every_call_and_shortens_only_the_results() {
689        let mut m = walk(8);
690        let before_calls: Vec<_> = m
691            .iter()
692            .flat_map(|m| m.tool_uses())
693            .map(|(_, _, i)| i.clone())
694            .collect();
695
696        let thinned = thin_old_results(&mut m, 4, 240);
697
698        assert!(thinned > 0);
699        // The sequence of calls is what says where the agent got to, and it is
700        // untouched — that is the whole point of thinning rather than cutting.
701        let after_calls: Vec<_> = m
702            .iter()
703            .flat_map(|m| m.tool_uses())
704            .map(|(_, _, i)| i.clone())
705            .collect();
706        assert_eq!(
707            before_calls, after_calls,
708            "thinning disturbed the tool calls"
709        );
710        assert_eq!(m.len(), 17, "thinning removed messages");
711    }
712
713    #[test]
714    fn recent_results_are_left_alone() {
715        let mut m = walk(8);
716        thin_old_results(&mut m, 4, 240);
717
718        let last_result = m.last().unwrap().content.iter().find_map(|b| match b {
719            Block::ToolResult { content, .. } => Some(content.clone()),
720            _ => None,
721        });
722        assert_eq!(
723            last_result.unwrap().len(),
724            500,
725            "the newest result was thinned"
726        );
727    }
728
729    #[test]
730    fn thinning_is_idempotent() {
731        // Repeated passes must not eat the surviving head a chunk at a time —
732        // compaction runs every turn once the threshold is crossed.
733        let mut m = walk(8);
734        thin_old_results(&mut m, 4, 240);
735        let after_one: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
736
737        let second = thin_old_results(&mut m, 4, 240);
738        let after_two: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
739
740        assert_eq!(second, 0, "a second pass thinned already-thinned results");
741        assert_eq!(after_one, after_two);
742    }
743
744    fn body_of(message: &Message) -> String {
745        message
746            .content
747            .iter()
748            .find_map(|b| match b {
749                Block::ToolResult { content, .. } => Some(content.clone()),
750                _ => None,
751            })
752            .unwrap()
753    }
754
755    #[test]
756    fn a_verdict_parses_through_the_ways_models_actually_phrase_it() {
757        use SummaryVerdict::*;
758        // Passes, however decorated.
759        for text in ["NONE", "none", "None.", "**NONE**", "Verdict:\nNONE"] {
760            assert_eq!(parse_omissions(text), Some(Complete), "{text:?}");
761        }
762        // "none" as a substring is a finding, not a pass.
763        let found = parse_omissions("none of the file paths survive the summary").unwrap();
764        assert!(matches!(found, Missing(_)));
765
766        // Omission lists come back, bullets stripped, ready for the retry.
767        let found = parse_omissions("- the amount 847\n- the path audit/entry-d084.md").unwrap();
768        assert_eq!(
769            found,
770            Missing(vec![
771                "the amount 847".into(),
772                "the path audit/entry-d084.md".into()
773            ])
774        );
775
776        // Nothing usable is no verdict — the caller must not treat it as a
777        // veto, because a run may need this compaction to survive.
778        assert_eq!(parse_omissions(""), None);
779        assert_eq!(parse_omissions("   \n  "), None);
780    }
781
782    #[test]
783    fn the_retry_instruction_names_every_omission_and_keeps_the_original_brief() {
784        let retry = retry_instruction(&["the amount 847".into(), "the QX-4417 reference".into()]);
785        assert!(
786            retry.contains(SUMMARY_INSTRUCTION),
787            "the retry must still say how to summarise"
788        );
789        assert!(retry.contains("- the amount 847"));
790        assert!(retry.contains("- the QX-4417 reference"));
791    }
792
793    #[test]
794    fn a_rereads_earlier_copy_is_evicted_and_the_newest_survives_whole() {
795        // Read the same file twice: the first copy is the near-miss distractor
796        // — same path, same symbols, possibly wrong content — and the second
797        // says everything the transcript knows to be true.
798        let mut m = vec![
799            Message::user("go"),
800            call("t0", "a.md"),
801            result("t0", "old contents"),
802            call("t1", "a.md"),
803            result("t1", "new contents"),
804        ];
805        assert_eq!(evict_superseded_results(&mut m), 1);
806        assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
807        assert!(
808            body_of(&m[2]).contains("fs_read"),
809            "the marker names the recovery"
810        );
811        assert_eq!(
812            body_of(&m[4]),
813            "new contents",
814            "the authoritative copy was touched"
815        );
816    }
817
818    #[test]
819    fn a_write_supersedes_an_earlier_read_of_the_same_path() {
820        // The exact shape the distractor research names: a file read left in
821        // context after an edit changed the file. The read is now wrong.
822        let mut m = vec![
823            Message::user("go"),
824            call("t0", "a.md"),
825            result("t0", "pre-edit contents"),
826            Message::assistant(vec![Block::ToolUse {
827                id: "t1".into(),
828                name: "fs_write".into(),
829                input: serde_json::json!({"path": "a.md", "content": "post"}),
830            }]),
831            result("t1", "wrote 4 bytes"),
832        ];
833        assert_eq!(evict_superseded_results(&mut m), 1);
834        assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
835        assert!(
836            body_of(&m[2]).contains("fs_write"),
837            "the marker says what superseded it"
838        );
839    }
840
841    #[test]
842    fn errors_neither_supersede_nor_get_evicted() {
843        let mut m = vec![
844            Message::user("go"),
845            call("t0", "a.md"),
846            result("t0", "good contents"),
847            call("t1", "a.md"),
848            Message::tool_results(vec![Block::ToolResult {
849                tool_use_id: "t1".into(),
850                content: "permission denied".into(),
851                is_error: true,
852            }]),
853        ];
854        // The later *failed* read says nothing about the file; the good copy
855        // must survive, and the failure must stay so it is not retried.
856        assert_eq!(evict_superseded_results(&mut m), 0);
857        assert_eq!(body_of(&m[2]), "good contents");
858        assert_eq!(body_of(&m[4]), "permission denied");
859    }
860
861    #[test]
862    fn a_pile_of_identical_failures_collapses_to_its_newest_member() {
863        // The self-conditioning shape: the model retries the same call four
864        // times, fails identically every time, and every copy stays in the
865        // context conditioning the next attempt. Before this pass, all four
866        // survived verbatim — eviction skips errors and thinning only
867        // truncates long results outside the recent window, and a failure
868        // message is short.
869        let mut m = vec![Message::user("go")];
870        for i in 0..4 {
871            m.push(call(&format!("t{i}"), "a.md"));
872            m.push(err_result(&format!("t{i}"), "permission denied"));
873        }
874
875        assert_eq!(collapse_repeated_failures(&mut m), 3);
876        for i in 0..3 {
877            assert!(
878                body_of(&m[2 + i * 2]).starts_with(REPEAT_MARKER),
879                "attempt {i} was left to condition the next one"
880            );
881        }
882        assert_eq!(
883            body_of(&m[8]),
884            "permission denied",
885            "the newest failure must survive whole — it is the diagnosis that \
886             stops the call being retried"
887        );
888    }
889
890    #[test]
891    fn a_persons_repeated_refusals_are_never_collapsed() {
892        // The learning miner strips "Denied by the user:" to build a
893        // correction, and compaction rewrites the transcript in place — so
894        // folding three denials into one marker does not merely undercount
895        // them, it destroys the evidence. A denied call carries `is_error`
896        // like any failure, which is exactly why this needs its own rule.
897        let mut m = vec![Message::user("go")];
898        for i in 0..3 {
899            m.push(call(&format!("t{i}"), "secrets.env"));
900            m.push(err_result(
901                &format!("t{i}"),
902                "Denied by the user: not that file",
903            ));
904        }
905        assert_eq!(collapse_repeated_failures(&mut m), 0);
906        for i in 0..3 {
907            assert_eq!(
908                body_of(&m[2 + i * 2]),
909                "Denied by the user: not that file",
910                "a refusal the miner reads was overwritten"
911            );
912        }
913
914        // The machine's own refusals are equally untouched: they are not
915        // environment failures either, and one of them being mistaken for a
916        // user correction is the mistake this project has a test for already.
917        for prefix in ["Blocked by policy:", "Blocked by a hook:"] {
918            let mut m = vec![Message::user("go")];
919            for i in 0..3 {
920                m.push(call(&format!("t{i}"), "a.md"));
921                m.push(err_result(&format!("t{i}"), &format!("{prefix} no")));
922            }
923            assert_eq!(collapse_repeated_failures(&mut m), 0, "{prefix}");
924        }
925
926        // And the pass still does its job beside them: an environment failure
927        // repeated three times in the same transcript still collapses.
928        let mut m = vec![Message::user("go")];
929        for i in 0..3 {
930            m.push(call(&format!("d{i}"), "denied.md"));
931            m.push(err_result(&format!("d{i}"), "Denied by the user: no"));
932            m.push(call(&format!("e{i}"), "gone.md"));
933            m.push(err_result(&format!("e{i}"), "no such file"));
934        }
935        assert_eq!(collapse_repeated_failures(&mut m), 2);
936    }
937
938    #[test]
939    fn two_different_failures_on_one_target_are_two_facts() {
940        // "no such file" and "permission denied" say different things about
941        // a.md. Folding either into a count loses a diagnosis, which is the
942        // damage the error exemption exists to prevent — so the key is the
943        // error text as well as the target.
944        let mut m = vec![
945            Message::user("go"),
946            call("t0", "a.md"),
947            err_result("t0", "no such file"),
948            call("t1", "a.md"),
949            err_result("t1", "permission denied"),
950        ];
951        assert_eq!(collapse_repeated_failures(&mut m), 0);
952        assert_eq!(body_of(&m[2]), "no such file");
953        assert_eq!(body_of(&m[4]), "permission denied");
954    }
955
956    #[test]
957    fn identical_failures_on_different_targets_are_left_alone() {
958        // Same message, different files: two facts about two paths, not a
959        // model repeating itself.
960        let mut m = vec![
961            Message::user("go"),
962            call("t0", "a.md"),
963            err_result("t0", "no such file"),
964            call("t1", "b.md"),
965            err_result("t1", "no such file"),
966        ];
967        assert_eq!(collapse_repeated_failures(&mut m), 0);
968    }
969
970    #[test]
971    fn a_successful_result_is_never_collapsed_by_the_failure_pass() {
972        // Supersession is eviction's job and it has its own rules; this pass
973        // must not quietly become a second, blunter copy of it.
974        let mut m = vec![
975            Message::user("go"),
976            call("t0", "a.md"),
977            result("t0", "contents"),
978            call("t1", "a.md"),
979            result("t1", "contents"),
980        ];
981        assert_eq!(collapse_repeated_failures(&mut m), 0);
982        assert_eq!(body_of(&m[2]), "contents");
983    }
984
985    #[test]
986    fn collapsing_is_idempotent_and_keeps_every_result_block() {
987        // Runs on every compaction, so a second pass must not walk back over
988        // its own markers. And a `tool_result` whose `tool_use` is gone is a
989        // 400: the count of blocks is not allowed to change, ever.
990        let mut m = vec![Message::user("go")];
991        for i in 0..3 {
992            m.push(call(&format!("t{i}"), "a.md"));
993            m.push(err_result(&format!("t{i}"), "permission denied"));
994        }
995        let blocks = m.len();
996
997        assert_eq!(collapse_repeated_failures(&mut m), 2);
998        let after_one: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
999
1000        assert_eq!(
1001            collapse_repeated_failures(&mut m),
1002            0,
1003            "a second pass collapsed its own markers"
1004        );
1005        let after_two: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
1006
1007        assert_eq!(after_one, after_two);
1008        assert_eq!(m.len(), blocks, "a result block was dropped");
1009        assert!(orphaned_tool_results(&m).is_empty());
1010        assert!(orphaned_tool_uses(&m).is_empty());
1011    }
1012
1013    fn err_result(id: &str, content: &str) -> Message {
1014        Message::tool_results(vec![Block::ToolResult {
1015            tool_use_id: id.into(),
1016            content: content.into(),
1017            is_error: true,
1018        }])
1019    }
1020
1021    #[test]
1022    fn a_ranged_read_speaks_only_for_its_slice() {
1023        let ranged = |id: &str, offset: u64| {
1024            Message::assistant(vec![Block::ToolUse {
1025                id: id.into(),
1026                name: "fs_read".into(),
1027                input: serde_json::json!({"path": "big.txt", "offset": offset, "limit": 10}),
1028            }])
1029        };
1030        let mut m = vec![
1031            Message::user("go"),
1032            call("t0", "big.txt"), // the full read
1033            result("t0", "the whole file"),
1034            ranged("t1", 100),
1035            result("t1", "lines 100-110"),
1036            ranged("t2", 200),
1037            result("t2", "lines 200-210"),
1038        ];
1039        // Three different slices of one file: nothing supersedes anything —
1040        // each result holds content none of the others has.
1041        assert_eq!(evict_superseded_results(&mut m), 0);
1042
1043        // The same slice twice is a re-read, and the newest speaks for it.
1044        m.push(ranged("t3", 100));
1045        m.push(result("t3", "lines 100-110 again"));
1046        assert_eq!(evict_superseded_results(&mut m), 1);
1047        assert!(
1048            body_of(&m[4]).starts_with(SUPERSEDED_MARKER),
1049            "the older 100-slice"
1050        );
1051        assert_eq!(body_of(&m[2]), "the whole file", "the full read survived");
1052    }
1053
1054    #[test]
1055    fn different_targets_do_not_supersede_each_other() {
1056        let mut m = vec![
1057            Message::user("go"),
1058            call("t0", "a.md"),
1059            result("t0", "a contents"),
1060            call("t1", "b.md"),
1061            result("t1", "b contents"),
1062        ];
1063        assert_eq!(evict_superseded_results(&mut m), 0);
1064    }
1065
1066    #[test]
1067    fn identical_non_path_calls_dedup_and_different_arguments_do_not() {
1068        let shell = |id: &str, cmd: &str| {
1069            Message::assistant(vec![Block::ToolUse {
1070                id: id.into(),
1071                name: "shell".into(),
1072                input: serde_json::json!({"command": cmd}),
1073            }])
1074        };
1075        let mut m = vec![
1076            Message::user("go"),
1077            shell("t0", "cargo test"),
1078            result("t0", "1 failed"),
1079            shell("t1", "cargo build"),
1080            result("t1", "ok"),
1081            shell("t2", "cargo test"),
1082            result("t2", "all passed"),
1083        ];
1084        // The first `cargo test` is stale — the suite has been re-run since —
1085        // but `cargo build` asked a different question and keeps its answer.
1086        assert_eq!(evict_superseded_results(&mut m), 1);
1087        assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
1088        assert_eq!(body_of(&m[4]), "ok");
1089        assert_eq!(body_of(&m[6]), "all passed");
1090    }
1091
1092    #[test]
1093    fn eviction_is_idempotent_and_never_touches_the_calls() {
1094        let mut m = vec![
1095            Message::user("go"),
1096            call("t0", "a.md"),
1097            result("t0", "old"),
1098            call("t1", "a.md"),
1099            result("t1", "new"),
1100        ];
1101        let calls_before: Vec<_> = m
1102            .iter()
1103            .flat_map(|m| m.tool_uses())
1104            .map(|(_, _, i)| i.clone())
1105            .collect();
1106        assert_eq!(evict_superseded_results(&mut m), 1);
1107        assert_eq!(
1108            evict_superseded_results(&mut m),
1109            0,
1110            "a second pass re-evicted"
1111        );
1112
1113        let calls_after: Vec<_> = m
1114            .iter()
1115            .flat_map(|m| m.tool_uses())
1116            .map(|(_, _, i)| i.clone())
1117            .collect();
1118        assert_eq!(
1119            calls_before, calls_after,
1120            "eviction disturbed the tool calls"
1121        );
1122        assert!(orphaned_tool_results(&m).is_empty());
1123        assert!(orphaned_tool_uses(&m).is_empty());
1124    }
1125
1126    #[test]
1127    fn a_result_shorter_than_the_budget_is_not_touched() {
1128        let mut m = vec![
1129            Message::user("go"),
1130            call("t0", "a.md"),
1131            result("t0", "amount: 43"),
1132        ];
1133        assert_eq!(thin_old_results(&mut m, 0, 240), 0);
1134        assert!(!format!("{:?}", m[2].content).contains("truncated"));
1135    }
1136
1137    #[test]
1138    fn thinning_says_it_thinned_so_the_model_can_tell() {
1139        // A silently shortened file reads as a short file, and the model would
1140        // conclude the rest of it does not exist.
1141        let mut m = walk(2);
1142        thin_old_results(&mut m, 0, 240);
1143        let body = m[2].content.iter().find_map(|b| match b {
1144            Block::ToolResult { content, .. } => Some(content.clone()),
1145            _ => None,
1146        });
1147        assert!(body.unwrap().ends_with(TRUNCATION_MARKER));
1148    }
1149    use serde_json::json;
1150
1151    /// A transcript in the shape the loop actually produces: a task, then
1152    /// alternating assistant tool calls and their results, then an answer.
1153    fn transcript(turns: usize) -> Vec<Message> {
1154        let mut messages = vec![Message::user("do the thing")];
1155        for i in 0..turns {
1156            messages.push(Message::assistant(vec![Block::ToolUse {
1157                id: format!("t{i}"),
1158                name: "echo".into(),
1159                input: json!({"n": i}),
1160            }]));
1161            messages.push(Message::tool_results(vec![Block::ToolResult {
1162                tool_use_id: format!("t{i}"),
1163                content: format!("result {i}"),
1164                is_error: false,
1165            }]));
1166        }
1167        messages.push(Message::assistant(vec![Block::text("done")]));
1168        messages
1169    }
1170
1171    #[test]
1172    fn a_cut_never_orphans_a_tool_result() {
1173        // The failure this exists to prevent is a 400 from a real API twenty
1174        // turns into a real session, so check every target, not a lucky one.
1175        let messages = transcript(6);
1176        for target in 0..messages.len() {
1177            let Some(cut) = cut_point(&messages, target) else {
1178                continue;
1179            };
1180            let rebuilt = rebuild(&messages, cut, "a summary", &[]);
1181
1182            assert!(
1183                orphaned_tool_results(&rebuilt).is_empty(),
1184                "cutting at {cut} (target {target}) orphaned a tool result"
1185            );
1186            assert!(
1187                orphaned_tool_uses(&rebuilt).is_empty(),
1188                "cutting at {cut} (target {target}) left a tool call unanswered"
1189            );
1190        }
1191    }
1192
1193    #[test]
1194    fn the_cut_lands_on_an_assistant_turn_and_at_or_after_the_target() {
1195        let messages = transcript(5);
1196        for target in 0..messages.len() {
1197            let Some(cut) = cut_point(&messages, target) else {
1198                continue;
1199            };
1200            assert!(
1201                cut >= target.max(1),
1202                "a cut before the target drops too much"
1203            );
1204            assert_eq!(messages[cut].role, Role::Assistant);
1205        }
1206    }
1207
1208    #[test]
1209    fn the_original_task_survives_and_the_recent_turns_are_verbatim() {
1210        let messages = transcript(6);
1211        let cut = cut_point(&messages, 6).unwrap();
1212        let rebuilt = rebuild(&messages, cut, "we established that X is 42", &[]);
1213
1214        // The task is still there, so the agent still knows what it is doing.
1215        assert!(rebuilt[0].text().contains("do the thing"));
1216        assert!(rebuilt[0].text().contains("X is 42"));
1217        assert_eq!(rebuilt[0].role, Role::User);
1218
1219        // ...and the tail was not paraphrased.
1220        assert_eq!(rebuilt.len(), 1 + messages.len() - cut);
1221        assert_eq!(
1222            rebuilt.last().unwrap().text(),
1223            messages.last().unwrap().text()
1224        );
1225    }
1226
1227    #[test]
1228    fn the_rebuilt_transcript_never_has_two_user_messages_in_a_row() {
1229        // Some providers reject it outright, and it is exactly what a naive
1230        // "prepend the summary as a message" would produce.
1231        let messages = transcript(6);
1232        let cut = cut_point(&messages, 5).unwrap();
1233        let rebuilt = rebuild(&messages, cut, "s", &[]);
1234
1235        for pair in rebuilt.windows(2) {
1236            assert!(
1237                !(pair[0].role == Role::User && pair[1].role == Role::User),
1238                "consecutive user messages"
1239            );
1240        }
1241    }
1242
1243    /// The measured failure of summarising is that it keeps what is true and
1244    /// drops how far you got. A task list is nothing but how far you got, and
1245    /// it lives in a tool rather than in the messages — so it crosses verbatim.
1246    #[test]
1247    fn tool_state_crosses_a_compaction_verbatim() {
1248        let messages = transcript(6);
1249        let cut = cut_point(&messages, 6).unwrap();
1250        let list = "1/3 done\n[x] read the config\n[~] fix the port\n[ ] run the tests\n";
1251        let rebuilt = rebuild(
1252            &messages,
1253            cut,
1254            "we established that X is 42",
1255            &[("todo", list)],
1256        );
1257
1258        let head = rebuilt[0].text();
1259        assert!(head.contains("X is 42"), "the summary is still there");
1260        assert!(head.contains("[~] fix the port"), "{head}");
1261        assert!(head.contains("[ ] run the tests"), "{head}");
1262        // After the summary, not before: it is the one part known to be current
1263        // rather than paraphrased.
1264        assert!(
1265            head.find(CARRIED_HEADER).unwrap() > head.find("X is 42").unwrap(),
1266            "{head}"
1267        );
1268    }
1269
1270    /// The bug a second compaction would otherwise introduce: two task lists in
1271    /// the prompt, one of them wrong, with nothing to say which.
1272    #[test]
1273    fn a_second_compaction_replaces_the_carried_state_rather_than_stacking_it() {
1274        let messages = transcript(6);
1275        let cut = cut_point(&messages, 6).unwrap();
1276        let first = rebuild(&messages, cut, "summary one", &[("todo", "[ ] step one")]);
1277
1278        // Now compact the already-compacted transcript, as a long run does.
1279        let cut = cut_point(&first, first.len().saturating_sub(2)).unwrap();
1280        let second = rebuild(
1281            &first,
1282            cut,
1283            "summary two",
1284            &[("todo", "[x] step one\n[ ] step two")],
1285        );
1286
1287        let head = second[0].text();
1288        assert_eq!(head.matches(CARRIED_HEADER).count(), 1, "{head}");
1289        assert!(head.contains("[ ] step two"), "{head}");
1290        assert!(
1291            !head.contains("[ ] step one"),
1292            "last compaction's list survived beside this one's: {head}"
1293        );
1294        // Summaries *do* accumulate — each describes a different stretch — and
1295        // that is the difference being tested.
1296        assert!(head.contains("summary one") && head.contains("summary two"));
1297    }
1298
1299    /// Nothing to carry must produce nothing, not an empty section: a heading
1300    /// with no list under it reads as "the plan is finished".
1301    #[test]
1302    fn no_tool_state_leaves_no_trace() {
1303        let messages = transcript(6);
1304        let cut = cut_point(&messages, 6).unwrap();
1305        let rebuilt = rebuild(&messages, cut, "a summary", &[]);
1306        assert!(!rebuilt[0].text().contains(CARRIED_HEADER));
1307    }
1308
1309    #[test]
1310    fn a_short_conversation_is_left_alone() {
1311        let messages = vec![
1312            Message::user("hi"),
1313            Message::assistant(vec![Block::text("hello")]),
1314        ];
1315        // There is a legal cut, but nothing worth dropping.
1316        let cut = cut_point(&messages, 1).unwrap();
1317        assert!(!worth_compacting(&messages, cut));
1318    }
1319
1320    #[test]
1321    fn a_transcript_ending_mid_tool_call_still_cuts_safely() {
1322        // The shape left behind by an interrupted run: the assistant asked for
1323        // a tool and the results are the last thing in the transcript.
1324        let mut messages = transcript(4);
1325        messages.pop();
1326        assert_eq!(messages.last().unwrap().role, Role::User);
1327
1328        let cut = cut_point(&messages, 3).unwrap();
1329        let rebuilt = rebuild(&messages, cut, "s", &[]);
1330        assert!(orphaned_tool_results(&rebuilt).is_empty());
1331    }
1332}