1use crate::message::{Block, Message, Role};
24
25pub 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
33pub 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
57pub 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
64pub 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#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum SummaryVerdict {
90 Complete,
91 Missing(Vec<String>),
92}
93
94pub 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 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
123pub 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
140pub 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 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
194pub fn cut_point(messages: &[Message], target: usize) -> Option<usize> {
199 (target.max(1)..messages.len()).find(|&i| is_safe_cut(messages, i))
202}
203
204fn is_safe_cut(messages: &[Message], i: usize) -> bool {
210 messages.get(i).is_some_and(|m| m.role == Role::Assistant)
211}
212
213pub 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
224pub 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 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
269pub const TRUNCATION_MARKER: &str = "\n… [earlier output truncated to save context]";
272
273pub const THINNED_RESULT_CHARS: usize = 240;
278
279pub 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 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
319pub const SUPERSEDED_MARKER: &str = "[stale:";
322
323pub fn evict_superseded_results(messages: &mut [Message]) -> usize {
346 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 let mut calls: Vec<(String, String, String)> = Vec::new(); 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 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 match authoritative.get(target) {
408 Some(&winner) if winner != tool_use_id => {
409 let later = superseder.get(target).copied().unwrap_or(name);
410 *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
427fn target_of(name: &str, input: &serde_json::Value) -> String {
429 match input.get("path").and_then(serde_json::Value::as_str) {
430 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 None => format!("{name}\u{0}{input}"),
452 }
453}
454
455pub fn worth_compacting(messages: &[Message], cut: usize) -> bool {
460 cut > MIN_DROPPED && messages.len() > cut
461}
462
463const MIN_DROPPED: usize = 4;
465
466pub 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
489pub 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 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 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 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 for text in ["NONE", "none", "None.", "**NONE**", "Verdict:\nNONE"] {
611 assert_eq!(parse_omissions(text), Some(Complete), "{text:?}");
612 }
613 let found = parse_omissions("none of the file paths survive the summary").unwrap();
615 assert!(matches!(found, Missing(_)));
616
617 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 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 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 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 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"), 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 assert_eq!(evict_superseded_results(&mut m), 0);
733
734 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 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 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 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 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 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 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 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 #[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 assert!(
956 head.find(CARRIED_HEADER).unwrap() > head.find("X is 42").unwrap(),
957 "{head}"
958 );
959 }
960
961 #[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 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 assert!(head.contains("summary one") && head.contains("summary two"));
988 }
989
990 #[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 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 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}