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::Image {
182 media_type, source, ..
183 } => {
184 out.push_str(&format!(
185 "[{who}] {}\n",
186 Block::image_placeholder(media_type, source.as_deref())
187 ));
188 }
189 Block::Thinking { .. } | Block::Text { .. } => {}
192 }
193 }
194 }
195 out
196}
197
198fn clip(s: &str, max: usize) -> String {
199 let flat = s.trim();
200 if flat.chars().count() <= max {
201 return flat.to_string();
202 }
203 format!(
204 "{}… [{} characters omitted]",
205 flat.chars().take(max).collect::<String>(),
206 flat.chars().count() - max
207 )
208}
209
210pub fn cut_point(messages: &[Message], target: usize) -> Option<usize> {
215 (target.max(1)..messages.len()).find(|&i| is_safe_cut(messages, i))
218}
219
220fn is_safe_cut(messages: &[Message], i: usize) -> bool {
226 messages.get(i).is_some_and(|m| m.role == Role::Assistant)
227}
228
229pub const CARRIED_HEADER: &str =
237 "[Live state, carried past the compaction and current as of now — it supersedes \
238 anything about it in the summaries above:]";
239
240pub fn rebuild(
252 messages: &[Message],
253 cut: usize,
254 summary: &str,
255 carried: &[(&str, &str)],
256) -> Vec<Message> {
257 let mut out = Vec::with_capacity(messages.len() - cut + 1);
258
259 let mut head = messages[0].clone();
260 head.content.retain(|block| match block {
265 Block::Text { text } => !text.trim_start().starts_with(CARRIED_HEADER),
266 _ => true,
267 });
268 head.content.push(Block::text(format!(
269 "\n\n[Earlier turns were compacted to fit the context window. What \
270 happened in them:]\n{summary}"
271 )));
272 if !carried.is_empty() {
273 let mut block = format!("\n\n{CARRIED_HEADER}\n");
274 for (label, body) in carried {
275 block.push_str(&format!("\n## {label}\n{}\n", body.trim_end()));
276 }
277 head.content.push(Block::text(block));
278 }
279 out.push(head);
280
281 out.extend(messages[cut..].iter().cloned());
282 out
283}
284
285pub const TRUNCATION_MARKER: &str = "\n… [earlier output truncated to save context]";
288
289pub const THINNED_RESULT_CHARS: usize = 240;
294
295pub fn thin_old_results(messages: &mut [Message], keep_recent: usize, keep_chars: usize) -> usize {
314 let cutoff = messages.len().saturating_sub(keep_recent);
315 let mut thinned = 0;
316
317 for message in messages.iter_mut().take(cutoff) {
318 for block in &mut message.content {
319 let Block::ToolResult { content, .. } = block else {
320 continue;
321 };
322 if content.ends_with(TRUNCATION_MARKER) || content.chars().count() <= keep_chars {
325 continue;
326 }
327 let head: String = content.chars().take(keep_chars).collect();
328 *content = format!("{head}{TRUNCATION_MARKER}");
329 thinned += 1;
330 }
331 }
332 thinned
333}
334
335pub const SUPERSEDED_MARKER: &str = "[stale:";
338
339pub fn evict_superseded_results(messages: &mut [Message]) -> usize {
362 let mut errored = std::collections::HashMap::new();
364 for message in messages.iter() {
365 for block in &message.content {
366 if let Block::ToolResult {
367 tool_use_id,
368 is_error,
369 ..
370 } = block
371 {
372 errored.insert(tool_use_id.clone(), *is_error);
373 }
374 }
375 }
376
377 let mut calls: Vec<(String, String, String)> = Vec::new(); for message in messages.iter() {
381 for block in &message.content {
382 if let Block::ToolUse { id, name, input } = block {
383 calls.push((id.clone(), name.clone(), target_of(name, input)));
384 }
385 }
386 }
387 let mut authoritative: std::collections::HashMap<&str, &str> = Default::default();
388 for (id, _, target) in &calls {
389 if errored.get(id) == Some(&false) {
390 authoritative.insert(target, id);
391 }
392 }
393 let superseder: std::collections::HashMap<&str, &str> = calls
395 .iter()
396 .filter(|(id, _, target)| authoritative.get(target.as_str()) == Some(&id.as_str()))
397 .map(|(_, name, target)| (target.as_str(), name.as_str()))
398 .collect();
399
400 let call_of: std::collections::HashMap<&str, (&str, &str)> = calls
401 .iter()
402 .map(|(id, name, target)| (id.as_str(), (name.as_str(), target.as_str())))
403 .collect();
404
405 let mut evicted = 0;
406 for message in messages.iter_mut() {
407 for block in &mut message.content {
408 let Block::ToolResult {
409 tool_use_id,
410 content,
411 is_error,
412 } = block
413 else {
414 continue;
415 };
416 if *is_error || content.starts_with(SUPERSEDED_MARKER) {
417 continue;
418 }
419 let Some(&(name, target)) = call_of.get(tool_use_id.as_str()) else {
420 continue;
421 };
422 match authoritative.get(target) {
424 Some(&winner) if winner != tool_use_id => {
425 let later = superseder.get(target).copied().unwrap_or(name);
426 *content = format!(
429 "{SUPERSEDED_MARKER} a later {later} call covered the same \
430 target, so this older result no longer reflects it. The \
431 newest result is authoritative; call {name} again if this \
432 content is needed.]"
433 );
434 evicted += 1;
435 }
436 _ => {}
437 }
438 }
439 }
440 evicted
441}
442
443pub const REPEAT_MARKER: &str = "[repeat:";
447
448const REFUSAL_PREFIXES: &[&str] = &[
464 "Denied by the user:",
465 "Blocked by policy:",
466 "Blocked by a hook:",
467];
468
469fn is_refusal(content: &str) -> bool {
472 REFUSAL_PREFIXES.iter().any(|p| content.starts_with(p))
473}
474
475pub fn collapse_repeated_failures(messages: &mut [Message]) -> usize {
508 let mut target_of_call: std::collections::HashMap<String, String> = Default::default();
511 for message in messages.iter() {
512 for block in &message.content {
513 if let Block::ToolUse { id, name, input } = block {
514 target_of_call.insert(id.clone(), target_of(name, input));
515 }
516 }
517 }
518
519 let mut newest: std::collections::HashMap<(String, String), String> = Default::default();
522 let key_of = |tool_use_id: &String, content: &String, is_error: bool| {
523 if !is_error || content.starts_with(REPEAT_MARKER) || is_refusal(content) {
524 return None;
525 }
526 let target = target_of_call.get(tool_use_id)?;
527 Some((target.clone(), content.trim().to_string()))
528 };
529 for message in messages.iter() {
530 for block in &message.content {
531 if let Block::ToolResult {
532 tool_use_id,
533 content,
534 is_error,
535 } = block
536 {
537 if let Some(key) = key_of(tool_use_id, content, *is_error) {
538 newest.insert(key, tool_use_id.clone());
539 }
540 }
541 }
542 }
543
544 let mut collapsed = 0;
545 for message in messages.iter_mut() {
546 for block in &mut message.content {
547 let Block::ToolResult {
548 tool_use_id,
549 content,
550 is_error,
551 } = block
552 else {
553 continue;
554 };
555 let Some(key) = key_of(tool_use_id, content, *is_error) else {
556 continue;
557 };
558 match newest.get(&key) {
559 Some(latest) if latest != tool_use_id => {
560 *content = format!(
564 "{REPEAT_MARKER} this call failed again later with the same error, \
565 which is kept in full below. Repeating it unchanged has not worked.]"
566 );
567 collapsed += 1;
568 }
569 _ => {}
570 }
571 }
572 }
573 collapsed
574}
575
576fn target_of(name: &str, input: &serde_json::Value) -> String {
578 match input.get("path").and_then(serde_json::Value::as_str) {
579 Some(path) => format!(
588 "path\u{0}{path}\u{0}{}\u{0}{}",
589 input
590 .get("offset")
591 .and_then(serde_json::Value::as_u64)
592 .unwrap_or(0),
593 input
594 .get("limit")
595 .and_then(serde_json::Value::as_u64)
596 .unwrap_or(0),
597 ),
598 None => format!("{name}\u{0}{input}"),
601 }
602}
603
604pub fn worth_compacting(messages: &[Message], cut: usize) -> bool {
609 cut > MIN_DROPPED && messages.len() > cut
610}
611
612const MIN_DROPPED: usize = 4;
614
615pub fn orphaned_tool_uses(messages: &[Message]) -> Vec<String> {
620 let mut answered = Vec::new();
621 let mut asked = Vec::new();
622
623 for message in messages {
624 for block in &message.content {
625 match block {
626 Block::ToolUse { id, .. } => asked.push(id.clone()),
627 Block::ToolResult { tool_use_id, .. } => answered.push(tool_use_id.clone()),
628 _ => {}
629 }
630 }
631 }
632 asked
633 .into_iter()
634 .filter(|id| !answered.contains(id))
635 .collect()
636}
637
638pub fn orphaned_tool_results(messages: &[Message]) -> Vec<String> {
640 let mut asked = Vec::new();
641 let mut orphans = Vec::new();
642
643 for message in messages {
644 for block in &message.content {
645 match block {
646 Block::ToolUse { id, .. } => asked.push(id.clone()),
647 Block::ToolResult { tool_use_id, .. } if !asked.contains(tool_use_id) => {
648 orphans.push(tool_use_id.clone())
649 }
650 _ => {}
651 }
652 }
653 }
654 orphans
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn call(id: &str, path: &str) -> Message {
662 Message::assistant(vec![Block::ToolUse {
663 id: id.into(),
664 name: "fs_read".into(),
665 input: serde_json::json!({"path": path}),
666 }])
667 }
668
669 fn result(id: &str, body: &str) -> Message {
670 Message::tool_results(vec![Block::ToolResult {
671 tool_use_id: id.into(),
672 content: body.into(),
673 is_error: false,
674 }])
675 }
676
677 fn walk(n: usize) -> Vec<Message> {
679 let mut m = vec![Message::user("follow the chain")];
680 for i in 0..n {
681 m.push(call(&format!("t{i}"), &format!("entry-{i}.md")));
682 m.push(result(&format!("t{i}"), &"x".repeat(500)));
683 }
684 m
685 }
686
687 #[test]
688 fn thinning_keeps_every_call_and_shortens_only_the_results() {
689 let mut m = walk(8);
690 let before_calls: Vec<_> = m
691 .iter()
692 .flat_map(|m| m.tool_uses())
693 .map(|(_, _, i)| i.clone())
694 .collect();
695
696 let thinned = thin_old_results(&mut m, 4, 240);
697
698 assert!(thinned > 0);
699 let after_calls: Vec<_> = m
702 .iter()
703 .flat_map(|m| m.tool_uses())
704 .map(|(_, _, i)| i.clone())
705 .collect();
706 assert_eq!(
707 before_calls, after_calls,
708 "thinning disturbed the tool calls"
709 );
710 assert_eq!(m.len(), 17, "thinning removed messages");
711 }
712
713 #[test]
714 fn recent_results_are_left_alone() {
715 let mut m = walk(8);
716 thin_old_results(&mut m, 4, 240);
717
718 let last_result = m.last().unwrap().content.iter().find_map(|b| match b {
719 Block::ToolResult { content, .. } => Some(content.clone()),
720 _ => None,
721 });
722 assert_eq!(
723 last_result.unwrap().len(),
724 500,
725 "the newest result was thinned"
726 );
727 }
728
729 #[test]
730 fn thinning_is_idempotent() {
731 let mut m = walk(8);
734 thin_old_results(&mut m, 4, 240);
735 let after_one: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
736
737 let second = thin_old_results(&mut m, 4, 240);
738 let after_two: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
739
740 assert_eq!(second, 0, "a second pass thinned already-thinned results");
741 assert_eq!(after_one, after_two);
742 }
743
744 fn body_of(message: &Message) -> String {
745 message
746 .content
747 .iter()
748 .find_map(|b| match b {
749 Block::ToolResult { content, .. } => Some(content.clone()),
750 _ => None,
751 })
752 .unwrap()
753 }
754
755 #[test]
756 fn a_verdict_parses_through_the_ways_models_actually_phrase_it() {
757 use SummaryVerdict::*;
758 for text in ["NONE", "none", "None.", "**NONE**", "Verdict:\nNONE"] {
760 assert_eq!(parse_omissions(text), Some(Complete), "{text:?}");
761 }
762 let found = parse_omissions("none of the file paths survive the summary").unwrap();
764 assert!(matches!(found, Missing(_)));
765
766 let found = parse_omissions("- the amount 847\n- the path audit/entry-d084.md").unwrap();
768 assert_eq!(
769 found,
770 Missing(vec![
771 "the amount 847".into(),
772 "the path audit/entry-d084.md".into()
773 ])
774 );
775
776 assert_eq!(parse_omissions(""), None);
779 assert_eq!(parse_omissions(" \n "), None);
780 }
781
782 #[test]
783 fn the_retry_instruction_names_every_omission_and_keeps_the_original_brief() {
784 let retry = retry_instruction(&["the amount 847".into(), "the QX-4417 reference".into()]);
785 assert!(
786 retry.contains(SUMMARY_INSTRUCTION),
787 "the retry must still say how to summarise"
788 );
789 assert!(retry.contains("- the amount 847"));
790 assert!(retry.contains("- the QX-4417 reference"));
791 }
792
793 #[test]
794 fn a_rereads_earlier_copy_is_evicted_and_the_newest_survives_whole() {
795 let mut m = vec![
799 Message::user("go"),
800 call("t0", "a.md"),
801 result("t0", "old contents"),
802 call("t1", "a.md"),
803 result("t1", "new contents"),
804 ];
805 assert_eq!(evict_superseded_results(&mut m), 1);
806 assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
807 assert!(
808 body_of(&m[2]).contains("fs_read"),
809 "the marker names the recovery"
810 );
811 assert_eq!(
812 body_of(&m[4]),
813 "new contents",
814 "the authoritative copy was touched"
815 );
816 }
817
818 #[test]
819 fn a_write_supersedes_an_earlier_read_of_the_same_path() {
820 let mut m = vec![
823 Message::user("go"),
824 call("t0", "a.md"),
825 result("t0", "pre-edit contents"),
826 Message::assistant(vec![Block::ToolUse {
827 id: "t1".into(),
828 name: "fs_write".into(),
829 input: serde_json::json!({"path": "a.md", "content": "post"}),
830 }]),
831 result("t1", "wrote 4 bytes"),
832 ];
833 assert_eq!(evict_superseded_results(&mut m), 1);
834 assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
835 assert!(
836 body_of(&m[2]).contains("fs_write"),
837 "the marker says what superseded it"
838 );
839 }
840
841 #[test]
842 fn errors_neither_supersede_nor_get_evicted() {
843 let mut m = vec![
844 Message::user("go"),
845 call("t0", "a.md"),
846 result("t0", "good contents"),
847 call("t1", "a.md"),
848 Message::tool_results(vec![Block::ToolResult {
849 tool_use_id: "t1".into(),
850 content: "permission denied".into(),
851 is_error: true,
852 }]),
853 ];
854 assert_eq!(evict_superseded_results(&mut m), 0);
857 assert_eq!(body_of(&m[2]), "good contents");
858 assert_eq!(body_of(&m[4]), "permission denied");
859 }
860
861 #[test]
862 fn a_pile_of_identical_failures_collapses_to_its_newest_member() {
863 let mut m = vec![Message::user("go")];
870 for i in 0..4 {
871 m.push(call(&format!("t{i}"), "a.md"));
872 m.push(err_result(&format!("t{i}"), "permission denied"));
873 }
874
875 assert_eq!(collapse_repeated_failures(&mut m), 3);
876 for i in 0..3 {
877 assert!(
878 body_of(&m[2 + i * 2]).starts_with(REPEAT_MARKER),
879 "attempt {i} was left to condition the next one"
880 );
881 }
882 assert_eq!(
883 body_of(&m[8]),
884 "permission denied",
885 "the newest failure must survive whole — it is the diagnosis that \
886 stops the call being retried"
887 );
888 }
889
890 #[test]
891 fn a_persons_repeated_refusals_are_never_collapsed() {
892 let mut m = vec![Message::user("go")];
898 for i in 0..3 {
899 m.push(call(&format!("t{i}"), "secrets.env"));
900 m.push(err_result(
901 &format!("t{i}"),
902 "Denied by the user: not that file",
903 ));
904 }
905 assert_eq!(collapse_repeated_failures(&mut m), 0);
906 for i in 0..3 {
907 assert_eq!(
908 body_of(&m[2 + i * 2]),
909 "Denied by the user: not that file",
910 "a refusal the miner reads was overwritten"
911 );
912 }
913
914 for prefix in ["Blocked by policy:", "Blocked by a hook:"] {
918 let mut m = vec![Message::user("go")];
919 for i in 0..3 {
920 m.push(call(&format!("t{i}"), "a.md"));
921 m.push(err_result(&format!("t{i}"), &format!("{prefix} no")));
922 }
923 assert_eq!(collapse_repeated_failures(&mut m), 0, "{prefix}");
924 }
925
926 let mut m = vec![Message::user("go")];
929 for i in 0..3 {
930 m.push(call(&format!("d{i}"), "denied.md"));
931 m.push(err_result(&format!("d{i}"), "Denied by the user: no"));
932 m.push(call(&format!("e{i}"), "gone.md"));
933 m.push(err_result(&format!("e{i}"), "no such file"));
934 }
935 assert_eq!(collapse_repeated_failures(&mut m), 2);
936 }
937
938 #[test]
939 fn two_different_failures_on_one_target_are_two_facts() {
940 let mut m = vec![
945 Message::user("go"),
946 call("t0", "a.md"),
947 err_result("t0", "no such file"),
948 call("t1", "a.md"),
949 err_result("t1", "permission denied"),
950 ];
951 assert_eq!(collapse_repeated_failures(&mut m), 0);
952 assert_eq!(body_of(&m[2]), "no such file");
953 assert_eq!(body_of(&m[4]), "permission denied");
954 }
955
956 #[test]
957 fn identical_failures_on_different_targets_are_left_alone() {
958 let mut m = vec![
961 Message::user("go"),
962 call("t0", "a.md"),
963 err_result("t0", "no such file"),
964 call("t1", "b.md"),
965 err_result("t1", "no such file"),
966 ];
967 assert_eq!(collapse_repeated_failures(&mut m), 0);
968 }
969
970 #[test]
971 fn a_successful_result_is_never_collapsed_by_the_failure_pass() {
972 let mut m = vec![
975 Message::user("go"),
976 call("t0", "a.md"),
977 result("t0", "contents"),
978 call("t1", "a.md"),
979 result("t1", "contents"),
980 ];
981 assert_eq!(collapse_repeated_failures(&mut m), 0);
982 assert_eq!(body_of(&m[2]), "contents");
983 }
984
985 #[test]
986 fn collapsing_is_idempotent_and_keeps_every_result_block() {
987 let mut m = vec![Message::user("go")];
991 for i in 0..3 {
992 m.push(call(&format!("t{i}"), "a.md"));
993 m.push(err_result(&format!("t{i}"), "permission denied"));
994 }
995 let blocks = m.len();
996
997 assert_eq!(collapse_repeated_failures(&mut m), 2);
998 let after_one: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
999
1000 assert_eq!(
1001 collapse_repeated_failures(&mut m),
1002 0,
1003 "a second pass collapsed its own markers"
1004 );
1005 let after_two: Vec<String> = m.iter().map(|m| format!("{:?}", m.content)).collect();
1006
1007 assert_eq!(after_one, after_two);
1008 assert_eq!(m.len(), blocks, "a result block was dropped");
1009 assert!(orphaned_tool_results(&m).is_empty());
1010 assert!(orphaned_tool_uses(&m).is_empty());
1011 }
1012
1013 fn err_result(id: &str, content: &str) -> Message {
1014 Message::tool_results(vec![Block::ToolResult {
1015 tool_use_id: id.into(),
1016 content: content.into(),
1017 is_error: true,
1018 }])
1019 }
1020
1021 #[test]
1022 fn a_ranged_read_speaks_only_for_its_slice() {
1023 let ranged = |id: &str, offset: u64| {
1024 Message::assistant(vec![Block::ToolUse {
1025 id: id.into(),
1026 name: "fs_read".into(),
1027 input: serde_json::json!({"path": "big.txt", "offset": offset, "limit": 10}),
1028 }])
1029 };
1030 let mut m = vec![
1031 Message::user("go"),
1032 call("t0", "big.txt"), result("t0", "the whole file"),
1034 ranged("t1", 100),
1035 result("t1", "lines 100-110"),
1036 ranged("t2", 200),
1037 result("t2", "lines 200-210"),
1038 ];
1039 assert_eq!(evict_superseded_results(&mut m), 0);
1042
1043 m.push(ranged("t3", 100));
1045 m.push(result("t3", "lines 100-110 again"));
1046 assert_eq!(evict_superseded_results(&mut m), 1);
1047 assert!(
1048 body_of(&m[4]).starts_with(SUPERSEDED_MARKER),
1049 "the older 100-slice"
1050 );
1051 assert_eq!(body_of(&m[2]), "the whole file", "the full read survived");
1052 }
1053
1054 #[test]
1055 fn different_targets_do_not_supersede_each_other() {
1056 let mut m = vec![
1057 Message::user("go"),
1058 call("t0", "a.md"),
1059 result("t0", "a contents"),
1060 call("t1", "b.md"),
1061 result("t1", "b contents"),
1062 ];
1063 assert_eq!(evict_superseded_results(&mut m), 0);
1064 }
1065
1066 #[test]
1067 fn identical_non_path_calls_dedup_and_different_arguments_do_not() {
1068 let shell = |id: &str, cmd: &str| {
1069 Message::assistant(vec![Block::ToolUse {
1070 id: id.into(),
1071 name: "shell".into(),
1072 input: serde_json::json!({"command": cmd}),
1073 }])
1074 };
1075 let mut m = vec![
1076 Message::user("go"),
1077 shell("t0", "cargo test"),
1078 result("t0", "1 failed"),
1079 shell("t1", "cargo build"),
1080 result("t1", "ok"),
1081 shell("t2", "cargo test"),
1082 result("t2", "all passed"),
1083 ];
1084 assert_eq!(evict_superseded_results(&mut m), 1);
1087 assert!(body_of(&m[2]).starts_with(SUPERSEDED_MARKER));
1088 assert_eq!(body_of(&m[4]), "ok");
1089 assert_eq!(body_of(&m[6]), "all passed");
1090 }
1091
1092 #[test]
1093 fn eviction_is_idempotent_and_never_touches_the_calls() {
1094 let mut m = vec![
1095 Message::user("go"),
1096 call("t0", "a.md"),
1097 result("t0", "old"),
1098 call("t1", "a.md"),
1099 result("t1", "new"),
1100 ];
1101 let calls_before: Vec<_> = m
1102 .iter()
1103 .flat_map(|m| m.tool_uses())
1104 .map(|(_, _, i)| i.clone())
1105 .collect();
1106 assert_eq!(evict_superseded_results(&mut m), 1);
1107 assert_eq!(
1108 evict_superseded_results(&mut m),
1109 0,
1110 "a second pass re-evicted"
1111 );
1112
1113 let calls_after: Vec<_> = m
1114 .iter()
1115 .flat_map(|m| m.tool_uses())
1116 .map(|(_, _, i)| i.clone())
1117 .collect();
1118 assert_eq!(
1119 calls_before, calls_after,
1120 "eviction disturbed the tool calls"
1121 );
1122 assert!(orphaned_tool_results(&m).is_empty());
1123 assert!(orphaned_tool_uses(&m).is_empty());
1124 }
1125
1126 #[test]
1127 fn a_result_shorter_than_the_budget_is_not_touched() {
1128 let mut m = vec![
1129 Message::user("go"),
1130 call("t0", "a.md"),
1131 result("t0", "amount: 43"),
1132 ];
1133 assert_eq!(thin_old_results(&mut m, 0, 240), 0);
1134 assert!(!format!("{:?}", m[2].content).contains("truncated"));
1135 }
1136
1137 #[test]
1138 fn thinning_says_it_thinned_so_the_model_can_tell() {
1139 let mut m = walk(2);
1142 thin_old_results(&mut m, 0, 240);
1143 let body = m[2].content.iter().find_map(|b| match b {
1144 Block::ToolResult { content, .. } => Some(content.clone()),
1145 _ => None,
1146 });
1147 assert!(body.unwrap().ends_with(TRUNCATION_MARKER));
1148 }
1149 use serde_json::json;
1150
1151 fn transcript(turns: usize) -> Vec<Message> {
1154 let mut messages = vec![Message::user("do the thing")];
1155 for i in 0..turns {
1156 messages.push(Message::assistant(vec![Block::ToolUse {
1157 id: format!("t{i}"),
1158 name: "echo".into(),
1159 input: json!({"n": i}),
1160 }]));
1161 messages.push(Message::tool_results(vec![Block::ToolResult {
1162 tool_use_id: format!("t{i}"),
1163 content: format!("result {i}"),
1164 is_error: false,
1165 }]));
1166 }
1167 messages.push(Message::assistant(vec![Block::text("done")]));
1168 messages
1169 }
1170
1171 #[test]
1172 fn a_cut_never_orphans_a_tool_result() {
1173 let messages = transcript(6);
1176 for target in 0..messages.len() {
1177 let Some(cut) = cut_point(&messages, target) else {
1178 continue;
1179 };
1180 let rebuilt = rebuild(&messages, cut, "a summary", &[]);
1181
1182 assert!(
1183 orphaned_tool_results(&rebuilt).is_empty(),
1184 "cutting at {cut} (target {target}) orphaned a tool result"
1185 );
1186 assert!(
1187 orphaned_tool_uses(&rebuilt).is_empty(),
1188 "cutting at {cut} (target {target}) left a tool call unanswered"
1189 );
1190 }
1191 }
1192
1193 #[test]
1194 fn the_cut_lands_on_an_assistant_turn_and_at_or_after_the_target() {
1195 let messages = transcript(5);
1196 for target in 0..messages.len() {
1197 let Some(cut) = cut_point(&messages, target) else {
1198 continue;
1199 };
1200 assert!(
1201 cut >= target.max(1),
1202 "a cut before the target drops too much"
1203 );
1204 assert_eq!(messages[cut].role, Role::Assistant);
1205 }
1206 }
1207
1208 #[test]
1209 fn the_original_task_survives_and_the_recent_turns_are_verbatim() {
1210 let messages = transcript(6);
1211 let cut = cut_point(&messages, 6).unwrap();
1212 let rebuilt = rebuild(&messages, cut, "we established that X is 42", &[]);
1213
1214 assert!(rebuilt[0].text().contains("do the thing"));
1216 assert!(rebuilt[0].text().contains("X is 42"));
1217 assert_eq!(rebuilt[0].role, Role::User);
1218
1219 assert_eq!(rebuilt.len(), 1 + messages.len() - cut);
1221 assert_eq!(
1222 rebuilt.last().unwrap().text(),
1223 messages.last().unwrap().text()
1224 );
1225 }
1226
1227 #[test]
1228 fn the_rebuilt_transcript_never_has_two_user_messages_in_a_row() {
1229 let messages = transcript(6);
1232 let cut = cut_point(&messages, 5).unwrap();
1233 let rebuilt = rebuild(&messages, cut, "s", &[]);
1234
1235 for pair in rebuilt.windows(2) {
1236 assert!(
1237 !(pair[0].role == Role::User && pair[1].role == Role::User),
1238 "consecutive user messages"
1239 );
1240 }
1241 }
1242
1243 #[test]
1247 fn tool_state_crosses_a_compaction_verbatim() {
1248 let messages = transcript(6);
1249 let cut = cut_point(&messages, 6).unwrap();
1250 let list = "1/3 done\n[x] read the config\n[~] fix the port\n[ ] run the tests\n";
1251 let rebuilt = rebuild(
1252 &messages,
1253 cut,
1254 "we established that X is 42",
1255 &[("todo", list)],
1256 );
1257
1258 let head = rebuilt[0].text();
1259 assert!(head.contains("X is 42"), "the summary is still there");
1260 assert!(head.contains("[~] fix the port"), "{head}");
1261 assert!(head.contains("[ ] run the tests"), "{head}");
1262 assert!(
1265 head.find(CARRIED_HEADER).unwrap() > head.find("X is 42").unwrap(),
1266 "{head}"
1267 );
1268 }
1269
1270 #[test]
1273 fn a_second_compaction_replaces_the_carried_state_rather_than_stacking_it() {
1274 let messages = transcript(6);
1275 let cut = cut_point(&messages, 6).unwrap();
1276 let first = rebuild(&messages, cut, "summary one", &[("todo", "[ ] step one")]);
1277
1278 let cut = cut_point(&first, first.len().saturating_sub(2)).unwrap();
1280 let second = rebuild(
1281 &first,
1282 cut,
1283 "summary two",
1284 &[("todo", "[x] step one\n[ ] step two")],
1285 );
1286
1287 let head = second[0].text();
1288 assert_eq!(head.matches(CARRIED_HEADER).count(), 1, "{head}");
1289 assert!(head.contains("[ ] step two"), "{head}");
1290 assert!(
1291 !head.contains("[ ] step one"),
1292 "last compaction's list survived beside this one's: {head}"
1293 );
1294 assert!(head.contains("summary one") && head.contains("summary two"));
1297 }
1298
1299 #[test]
1302 fn no_tool_state_leaves_no_trace() {
1303 let messages = transcript(6);
1304 let cut = cut_point(&messages, 6).unwrap();
1305 let rebuilt = rebuild(&messages, cut, "a summary", &[]);
1306 assert!(!rebuilt[0].text().contains(CARRIED_HEADER));
1307 }
1308
1309 #[test]
1310 fn a_short_conversation_is_left_alone() {
1311 let messages = vec![
1312 Message::user("hi"),
1313 Message::assistant(vec![Block::text("hello")]),
1314 ];
1315 let cut = cut_point(&messages, 1).unwrap();
1317 assert!(!worth_compacting(&messages, cut));
1318 }
1319
1320 #[test]
1321 fn a_transcript_ending_mid_tool_call_still_cuts_safely() {
1322 let mut messages = transcript(4);
1325 messages.pop();
1326 assert_eq!(messages.last().unwrap().role, Role::User);
1327
1328 let cut = cut_point(&messages, 3).unwrap();
1329 let rebuilt = rebuild(&messages, cut, "s", &[]);
1330 assert!(orphaned_tool_results(&rebuilt).is_empty());
1331 }
1332}