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