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/// What a call is *about*, for supersession.
428fn target_of(name: &str, input: &serde_json::Value) -> String {
429    match input.get("path").and_then(serde_json::Value::as_str) {
430        // Deliberately not prefixed with the tool name: the newest operation
431        // on a path speaks for the path, whichever tool performed it. But a
432        // *ranged* read speaks only for its slice — `offset`/`limit` join the
433        // key, or reading lines 100–110 would evict the full read of the same
434        // file, and successive range reads (exactly what the spillover marker
435        // tells the model to do) would evict each other while holding
436        // different content. A write carries no range, so it still supersedes
437        // the unranged read.
438        Some(path) => format!(
439            "path\u{0}{path}\u{0}{}\u{0}{}",
440            input
441                .get("offset")
442                .and_then(serde_json::Value::as_u64)
443                .unwrap_or(0),
444            input
445                .get("limit")
446                .and_then(serde_json::Value::as_u64)
447                .unwrap_or(0),
448        ),
449        // `serde_json::Map` is a BTreeMap, so this string is canonical even if
450        // the model orders the arguments differently between calls.
451        None => format!("{name}\u{0}{input}"),
452    }
453}
454
455/// Whether compacting would actually remove anything worth the round trip.
456///
457/// A summarising call costs a request and its tokens; doing it to drop two
458/// messages loses on both counts.
459pub fn worth_compacting(messages: &[Message], cut: usize) -> bool {
460    cut > MIN_DROPPED && messages.len() > cut
461}
462
463/// Below this, the summary is likely to be longer than what it replaces.
464const MIN_DROPPED: usize = 4;
465
466/// Every `tool_use` id in the transcript that has no matching `tool_result`.
467///
468/// The invariant compaction must never break, exposed so it can be asserted on
469/// rather than assumed.
470pub fn orphaned_tool_uses(messages: &[Message]) -> Vec<String> {
471    let mut answered = Vec::new();
472    let mut asked = Vec::new();
473
474    for message in messages {
475        for block in &message.content {
476            match block {
477                Block::ToolUse { id, .. } => asked.push(id.clone()),
478                Block::ToolResult { tool_use_id, .. } => answered.push(tool_use_id.clone()),
479                _ => {}
480            }
481        }
482    }
483    asked
484        .into_iter()
485        .filter(|id| !answered.contains(id))
486        .collect()
487}
488
489/// Every `tool_result` whose `tool_use` is missing — the error that 400s.
490pub fn orphaned_tool_results(messages: &[Message]) -> Vec<String> {
491    let mut asked = Vec::new();
492    let mut orphans = Vec::new();
493
494    for message in messages {
495        for block in &message.content {
496            match block {
497                Block::ToolUse { id, .. } => asked.push(id.clone()),
498                Block::ToolResult { tool_use_id, .. } if !asked.contains(tool_use_id) => {
499                    orphans.push(tool_use_id.clone())
500                }
501                _ => {}
502            }
503        }
504    }
505    orphans
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn call(id: &str, path: &str) -> Message {
513        Message::assistant(vec![Block::ToolUse {
514            id: id.into(),
515            name: "fs_read".into(),
516            input: serde_json::json!({"path": path}),
517        }])
518    }
519
520    fn result(id: &str, body: &str) -> Message {
521        Message::tool_results(vec![Block::ToolResult {
522            tool_use_id: id.into(),
523            content: body.into(),
524            is_error: false,
525        }])
526    }
527
528    /// A traversal: read a file, get its contents, move on.
529    fn walk(n: usize) -> Vec<Message> {
530        let mut m = vec![Message::user("follow the chain")];
531        for i in 0..n {
532            m.push(call(&format!("t{i}"), &format!("entry-{i}.md")));
533            m.push(result(&format!("t{i}"), &"x".repeat(500)));
534        }
535        m
536    }
537
538    #[test]
539    fn thinning_keeps_every_call_and_shortens_only_the_results() {
540        let mut m = walk(8);
541        let before_calls: Vec<_> = m
542            .iter()
543            .flat_map(|m| m.tool_uses())
544            .map(|(_, _, i)| i.clone())
545            .collect();
546
547        let thinned = thin_old_results(&mut m, 4, 240);
548
549        assert!(thinned > 0);
550        // The sequence of calls is what says where the agent got to, and it is
551        // untouched — that is the whole point of thinning rather than cutting.
552        let after_calls: Vec<_> = m
553            .iter()
554            .flat_map(|m| m.tool_uses())
555            .map(|(_, _, i)| i.clone())
556            .collect();
557        assert_eq!(
558            before_calls, after_calls,
559            "thinning disturbed the tool calls"
560        );
561        assert_eq!(m.len(), 17, "thinning removed messages");
562    }
563
564    #[test]
565    fn recent_results_are_left_alone() {
566        let mut m = walk(8);
567        thin_old_results(&mut m, 4, 240);
568
569        let last_result = m.last().unwrap().content.iter().find_map(|b| match b {
570            Block::ToolResult { content, .. } => Some(content.clone()),
571            _ => None,
572        });
573        assert_eq!(
574            last_result.unwrap().len(),
575            500,
576            "the newest result was thinned"
577        );
578    }
579
580    #[test]
581    fn thinning_is_idempotent() {
582        // Repeated passes must not eat the surviving head a chunk at a time —
583        // compaction runs every turn once the threshold is crossed.
584        let mut m = walk(8);
585        thin_old_results(&mut m, 4, 240);
586        let after_one: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
587
588        let second = thin_old_results(&mut m, 4, 240);
589        let after_two: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
590
591        assert_eq!(second, 0, "a second pass thinned already-thinned results");
592        assert_eq!(after_one, after_two);
593    }
594
595    fn body_of(message: &Message) -> String {
596        message
597            .content
598            .iter()
599            .find_map(|b| match b {
600                Block::ToolResult { content, .. } => Some(content.clone()),
601                _ => None,
602            })
603            .unwrap()
604    }
605
606    #[test]
607    fn a_verdict_parses_through_the_ways_models_actually_phrase_it() {
608        use SummaryVerdict::*;
609        // Passes, however decorated.
610        for text in ["NONE", "none", "None.", "**NONE**", "Verdict:\nNONE"] {
611            assert_eq!(parse_omissions(text), Some(Complete), "{text:?}");
612        }
613        // "none" as a substring is a finding, not a pass.
614        let found = parse_omissions("none of the file paths survive the summary").unwrap();
615        assert!(matches!(found, Missing(_)));
616
617        // Omission lists come back, bullets stripped, ready for the retry.
618        let found = parse_omissions("- the amount 847\n- the path audit/entry-d084.md").unwrap();
619        assert_eq!(
620            found,
621            Missing(vec![
622                "the amount 847".into(),
623                "the path audit/entry-d084.md".into()
624            ])
625        );
626
627        // Nothing usable is no verdict — the caller must not treat it as a
628        // veto, because a run may need this compaction to survive.
629        assert_eq!(parse_omissions(""), None);
630        assert_eq!(parse_omissions("   \n  "), None);
631    }
632
633    #[test]
634    fn the_retry_instruction_names_every_omission_and_keeps_the_original_brief() {
635        let retry = retry_instruction(&["the amount 847".into(), "the QX-4417 reference".into()]);
636        assert!(
637            retry.contains(SUMMARY_INSTRUCTION),
638            "the retry must still say how to summarise"
639        );
640        assert!(retry.contains("- the amount 847"));
641        assert!(retry.contains("- the QX-4417 reference"));
642    }
643
644    #[test]
645    fn a_rereads_earlier_copy_is_evicted_and_the_newest_survives_whole() {
646        // Read the same file twice: the first copy is the near-miss distractor
647        // — same path, same symbols, possibly wrong content — and the second
648        // says everything the transcript knows to be true.
649        let mut m = vec![
650            Message::user("go"),
651            call("t0", "a.md"),
652            result("t0", "old contents"),
653            call("t1", "a.md"),
654            result("t1", "new contents"),
655        ];
656        assert_eq!(evict_superseded_results(&mut m), 1);
657        assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
658        assert!(
659            body_of(&m[2]).contains("fs_read"),
660            "the marker names the recovery"
661        );
662        assert_eq!(
663            body_of(&m[4]),
664            "new contents",
665            "the authoritative copy was touched"
666        );
667    }
668
669    #[test]
670    fn a_write_supersedes_an_earlier_read_of_the_same_path() {
671        // The exact shape the distractor research names: a file read left in
672        // context after an edit changed the file. The read is now wrong.
673        let mut m = vec![
674            Message::user("go"),
675            call("t0", "a.md"),
676            result("t0", "pre-edit contents"),
677            Message::assistant(vec![Block::ToolUse {
678                id: "t1".into(),
679                name: "fs_write".into(),
680                input: serde_json::json!({"path": "a.md", "content": "post"}),
681            }]),
682            result("t1", "wrote 4 bytes"),
683        ];
684        assert_eq!(evict_superseded_results(&mut m), 1);
685        assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
686        assert!(
687            body_of(&m[2]).contains("fs_write"),
688            "the marker says what superseded it"
689        );
690    }
691
692    #[test]
693    fn errors_neither_supersede_nor_get_evicted() {
694        let mut m = vec![
695            Message::user("go"),
696            call("t0", "a.md"),
697            result("t0", "good contents"),
698            call("t1", "a.md"),
699            Message::tool_results(vec![Block::ToolResult {
700                tool_use_id: "t1".into(),
701                content: "permission denied".into(),
702                is_error: true,
703            }]),
704        ];
705        // The later *failed* read says nothing about the file; the good copy
706        // must survive, and the failure must stay so it is not retried.
707        assert_eq!(evict_superseded_results(&mut m), 0);
708        assert_eq!(body_of(&m[2]), "good contents");
709        assert_eq!(body_of(&m[4]), "permission denied");
710    }
711
712    #[test]
713    fn a_ranged_read_speaks_only_for_its_slice() {
714        let ranged = |id: &str, offset: u64| {
715            Message::assistant(vec![Block::ToolUse {
716                id: id.into(),
717                name: "fs_read".into(),
718                input: serde_json::json!({"path": "big.txt", "offset": offset, "limit": 10}),
719            }])
720        };
721        let mut m = vec![
722            Message::user("go"),
723            call("t0", "big.txt"), // the full read
724            result("t0", "the whole file"),
725            ranged("t1", 100),
726            result("t1", "lines 100-110"),
727            ranged("t2", 200),
728            result("t2", "lines 200-210"),
729        ];
730        // Three different slices of one file: nothing supersedes anything —
731        // each result holds content none of the others has.
732        assert_eq!(evict_superseded_results(&mut m), 0);
733
734        // The same slice twice is a re-read, and the newest speaks for it.
735        m.push(ranged("t3", 100));
736        m.push(result("t3", "lines 100-110 again"));
737        assert_eq!(evict_superseded_results(&mut m), 1);
738        assert!(
739            body_of(&m[4]).starts_with(SUPERSEDED_MARKER),
740            "the older 100-slice"
741        );
742        assert_eq!(body_of(&m[2]), "the whole file", "the full read survived");
743    }
744
745    #[test]
746    fn different_targets_do_not_supersede_each_other() {
747        let mut m = vec![
748            Message::user("go"),
749            call("t0", "a.md"),
750            result("t0", "a contents"),
751            call("t1", "b.md"),
752            result("t1", "b contents"),
753        ];
754        assert_eq!(evict_superseded_results(&mut m), 0);
755    }
756
757    #[test]
758    fn identical_non_path_calls_dedup_and_different_arguments_do_not() {
759        let shell = |id: &str, cmd: &str| {
760            Message::assistant(vec![Block::ToolUse {
761                id: id.into(),
762                name: "shell".into(),
763                input: serde_json::json!({"command": cmd}),
764            }])
765        };
766        let mut m = vec![
767            Message::user("go"),
768            shell("t0", "cargo test"),
769            result("t0", "1 failed"),
770            shell("t1", "cargo build"),
771            result("t1", "ok"),
772            shell("t2", "cargo test"),
773            result("t2", "all passed"),
774        ];
775        // The first `cargo test` is stale — the suite has been re-run since —
776        // but `cargo build` asked a different question and keeps its answer.
777        assert_eq!(evict_superseded_results(&mut m), 1);
778        assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
779        assert_eq!(body_of(&m[4]), "ok");
780        assert_eq!(body_of(&m[6]), "all passed");
781    }
782
783    #[test]
784    fn eviction_is_idempotent_and_never_touches_the_calls() {
785        let mut m = vec![
786            Message::user("go"),
787            call("t0", "a.md"),
788            result("t0", "old"),
789            call("t1", "a.md"),
790            result("t1", "new"),
791        ];
792        let calls_before: Vec<_> = m
793            .iter()
794            .flat_map(|m| m.tool_uses())
795            .map(|(_, _, i)| i.clone())
796            .collect();
797        assert_eq!(evict_superseded_results(&mut m), 1);
798        assert_eq!(
799            evict_superseded_results(&mut m),
800            0,
801            "a second pass re-evicted"
802        );
803
804        let calls_after: Vec<_> = m
805            .iter()
806            .flat_map(|m| m.tool_uses())
807            .map(|(_, _, i)| i.clone())
808            .collect();
809        assert_eq!(
810            calls_before, calls_after,
811            "eviction disturbed the tool calls"
812        );
813        assert!(orphaned_tool_results(&m).is_empty());
814        assert!(orphaned_tool_uses(&m).is_empty());
815    }
816
817    #[test]
818    fn a_result_shorter_than_the_budget_is_not_touched() {
819        let mut m = vec![
820            Message::user("go"),
821            call("t0", "a.md"),
822            result("t0", "amount: 43"),
823        ];
824        assert_eq!(thin_old_results(&mut m, 0, 240), 0);
825        assert!(!format!("{:?}", m[2].content).contains("truncated"));
826    }
827
828    #[test]
829    fn thinning_says_it_thinned_so_the_model_can_tell() {
830        // A silently shortened file reads as a short file, and the model would
831        // conclude the rest of it does not exist.
832        let mut m = walk(2);
833        thin_old_results(&mut m, 0, 240);
834        let body = m[2].content.iter().find_map(|b| match b {
835            Block::ToolResult { content, .. } => Some(content.clone()),
836            _ => None,
837        });
838        assert!(body.unwrap().ends_with(TRUNCATION_MARKER));
839    }
840    use serde_json::json;
841
842    /// A transcript in the shape the loop actually produces: a task, then
843    /// alternating assistant tool calls and their results, then an answer.
844    fn transcript(turns: usize) -> Vec<Message> {
845        let mut messages = vec![Message::user("do the thing")];
846        for i in 0..turns {
847            messages.push(Message::assistant(vec![Block::ToolUse {
848                id: format!("t{i}"),
849                name: "echo".into(),
850                input: json!({"n": i}),
851            }]));
852            messages.push(Message::tool_results(vec![Block::ToolResult {
853                tool_use_id: format!("t{i}"),
854                content: format!("result {i}"),
855                is_error: false,
856            }]));
857        }
858        messages.push(Message::assistant(vec![Block::text("done")]));
859        messages
860    }
861
862    #[test]
863    fn a_cut_never_orphans_a_tool_result() {
864        // The failure this exists to prevent is a 400 from a real API twenty
865        // turns into a real session, so check every target, not a lucky one.
866        let messages = transcript(6);
867        for target in 0..messages.len() {
868            let Some(cut) = cut_point(&messages, target) else {
869                continue;
870            };
871            let rebuilt = rebuild(&messages, cut, "a summary", &[]);
872
873            assert!(
874                orphaned_tool_results(&rebuilt).is_empty(),
875                "cutting at {cut} (target {target}) orphaned a tool result"
876            );
877            assert!(
878                orphaned_tool_uses(&rebuilt).is_empty(),
879                "cutting at {cut} (target {target}) left a tool call unanswered"
880            );
881        }
882    }
883
884    #[test]
885    fn the_cut_lands_on_an_assistant_turn_and_at_or_after_the_target() {
886        let messages = transcript(5);
887        for target in 0..messages.len() {
888            let Some(cut) = cut_point(&messages, target) else {
889                continue;
890            };
891            assert!(
892                cut >= target.max(1),
893                "a cut before the target drops too much"
894            );
895            assert_eq!(messages[cut].role, Role::Assistant);
896        }
897    }
898
899    #[test]
900    fn the_original_task_survives_and_the_recent_turns_are_verbatim() {
901        let messages = transcript(6);
902        let cut = cut_point(&messages, 6).unwrap();
903        let rebuilt = rebuild(&messages, cut, "we established that X is 42", &[]);
904
905        // The task is still there, so the agent still knows what it is doing.
906        assert!(rebuilt[0].text().contains("do the thing"));
907        assert!(rebuilt[0].text().contains("X is 42"));
908        assert_eq!(rebuilt[0].role, Role::User);
909
910        // ...and the tail was not paraphrased.
911        assert_eq!(rebuilt.len(), 1 + messages.len() - cut);
912        assert_eq!(
913            rebuilt.last().unwrap().text(),
914            messages.last().unwrap().text()
915        );
916    }
917
918    #[test]
919    fn the_rebuilt_transcript_never_has_two_user_messages_in_a_row() {
920        // Some providers reject it outright, and it is exactly what a naive
921        // "prepend the summary as a message" would produce.
922        let messages = transcript(6);
923        let cut = cut_point(&messages, 5).unwrap();
924        let rebuilt = rebuild(&messages, cut, "s", &[]);
925
926        for pair in rebuilt.windows(2) {
927            assert!(
928                !(pair[0].role == Role::User && pair[1].role == Role::User),
929                "consecutive user messages"
930            );
931        }
932    }
933
934    /// The measured failure of summarising is that it keeps what is true and
935    /// drops how far you got. A task list is nothing but how far you got, and
936    /// it lives in a tool rather than in the messages — so it crosses verbatim.
937    #[test]
938    fn tool_state_crosses_a_compaction_verbatim() {
939        let messages = transcript(6);
940        let cut = cut_point(&messages, 6).unwrap();
941        let list = "1/3 done\n[x] read the config\n[~] fix the port\n[ ] run the tests\n";
942        let rebuilt = rebuild(
943            &messages,
944            cut,
945            "we established that X is 42",
946            &[("todo", list)],
947        );
948
949        let head = rebuilt[0].text();
950        assert!(head.contains("X is 42"), "the summary is still there");
951        assert!(head.contains("[~] fix the port"), "{head}");
952        assert!(head.contains("[ ] run the tests"), "{head}");
953        // After the summary, not before: it is the one part known to be current
954        // rather than paraphrased.
955        assert!(
956            head.find(CARRIED_HEADER).unwrap() > head.find("X is 42").unwrap(),
957            "{head}"
958        );
959    }
960
961    /// The bug a second compaction would otherwise introduce: two task lists in
962    /// the prompt, one of them wrong, with nothing to say which.
963    #[test]
964    fn a_second_compaction_replaces_the_carried_state_rather_than_stacking_it() {
965        let messages = transcript(6);
966        let cut = cut_point(&messages, 6).unwrap();
967        let first = rebuild(&messages, cut, "summary one", &[("todo", "[ ] step one")]);
968
969        // Now compact the already-compacted transcript, as a long run does.
970        let cut = cut_point(&first, first.len().saturating_sub(2)).unwrap();
971        let second = rebuild(
972            &first,
973            cut,
974            "summary two",
975            &[("todo", "[x] step one\n[ ] step two")],
976        );
977
978        let head = second[0].text();
979        assert_eq!(head.matches(CARRIED_HEADER).count(), 1, "{head}");
980        assert!(head.contains("[ ] step two"), "{head}");
981        assert!(
982            !head.contains("[ ] step one"),
983            "last compaction's list survived beside this one's: {head}"
984        );
985        // Summaries *do* accumulate — each describes a different stretch — and
986        // that is the difference being tested.
987        assert!(head.contains("summary one") && head.contains("summary two"));
988    }
989
990    /// Nothing to carry must produce nothing, not an empty section: a heading
991    /// with no list under it reads as "the plan is finished".
992    #[test]
993    fn no_tool_state_leaves_no_trace() {
994        let messages = transcript(6);
995        let cut = cut_point(&messages, 6).unwrap();
996        let rebuilt = rebuild(&messages, cut, "a summary", &[]);
997        assert!(!rebuilt[0].text().contains(CARRIED_HEADER));
998    }
999
1000    #[test]
1001    fn a_short_conversation_is_left_alone() {
1002        let messages = vec![
1003            Message::user("hi"),
1004            Message::assistant(vec![Block::text("hello")]),
1005        ];
1006        // There is a legal cut, but nothing worth dropping.
1007        let cut = cut_point(&messages, 1).unwrap();
1008        assert!(!worth_compacting(&messages, cut));
1009    }
1010
1011    #[test]
1012    fn a_transcript_ending_mid_tool_call_still_cuts_safely() {
1013        // The shape left behind by an interrupted run: the assistant asked for
1014        // a tool and the results are the last thing in the transcript.
1015        let mut messages = transcript(4);
1016        messages.pop();
1017        assert_eq!(messages.last().unwrap().role, Role::User);
1018
1019        let cut = cut_point(&messages, 3).unwrap();
1020        let rebuilt = rebuild(&messages, cut, "s", &[]);
1021        assert!(orphaned_tool_results(&rebuilt).is_empty());
1022    }
1023}