1use zeroclaw_api::model_provider::ChatMessage;
2
3pub use zeroclaw_config::scattered_types::HistoryPrunerConfig;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct PruneStats {
11 pub messages_before: usize,
12 pub messages_after: usize,
13 pub collapsed_pairs: usize,
14 pub dropped_messages: usize,
15}
16
17fn estimate_tokens(messages: &[ChatMessage]) -> usize {
22 let raw: usize = messages
23 .iter()
24 .map(|m| m.content.len().div_ceil(4) + 4)
25 .sum();
26 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
29 {
30 (raw as f64 * 1.2) as usize
31 }
32}
33
34fn protected_indices(messages: &[ChatMessage], keep_recent: usize) -> Vec<bool> {
39 let len = messages.len();
40 let mut protected = vec![false; len];
41 for (i, msg) in messages.iter().enumerate() {
42 if msg.role == "system" {
43 protected[i] = true;
44 }
45 }
46 let recent_start = len.saturating_sub(keep_recent);
47 for p in protected.iter_mut().skip(recent_start) {
48 *p = true;
49 }
50 protected
51}
52
53#[derive(Debug, Default, Clone)]
61pub struct PrunedOrphans {
62 pub removed: usize,
64 pub orphan_tool_call_ids: Vec<String>,
66}
67
68fn is_tool_exchange_summary(content: &str) -> bool {
69 content.starts_with("[Tool exchange:") && content.contains("results collapsed]")
70}
71
72fn assistant_tool_calls_have_immediate_results(
73 messages: &[ChatMessage],
74 assistant_idx: usize,
75 tool_call_ids: &[String],
76) -> bool {
77 if tool_call_ids.is_empty() {
78 return false;
79 }
80
81 tool_call_ids.iter().all(|expected| {
82 messages
83 .iter()
84 .skip(assistant_idx + 1)
85 .take_while(|msg| msg.role == "tool")
86 .filter_map(|msg| extract_tool_call_id(&msg.content))
87 .any(|actual| actual == *expected)
88 })
89}
90
91impl PrunedOrphans {
92 pub fn is_empty(&self) -> bool {
93 self.removed == 0
94 }
95}
96
97pub fn remove_orphaned_tool_messages(messages: &mut Vec<ChatMessage>) -> PrunedOrphans {
104 let mut outcome = PrunedOrphans::default();
105 let mut i = 0;
109 while i < messages.len() {
110 let assistant_tool_call_ids = if messages[i].role == "assistant" {
111 extract_assistant_tool_call_ids(&messages[i].content)
112 } else {
113 None
114 };
115 if let Some(doomed_ids) = assistant_tool_call_ids
116 && i > 0
117 && messages[i - 1].role == "assistant"
118 && (!is_tool_exchange_summary(&messages[i - 1].content)
119 || !assistant_tool_calls_have_immediate_results(messages, i, &doomed_ids))
120 {
121 outcome
122 .orphan_tool_call_ids
123 .extend(doomed_ids.iter().cloned());
124 messages.remove(i);
125 outcome.removed += 1;
126 while i < messages.len() && messages[i].role == "tool" {
127 let dominated = match extract_tool_call_id(&messages[i].content) {
128 Some(id) => doomed_ids.iter().any(|d| d == &id),
129 None => true,
130 };
131 if dominated {
132 messages.remove(i);
133 outcome.removed += 1;
134 } else {
135 break;
136 }
137 }
138 } else {
139 i += 1;
140 }
141 }
142
143 i = 0;
149 while i < messages.len() {
150 if messages[i].role != "tool" {
151 i += 1;
152 continue;
153 }
154
155 let assistant_idx = (0..i)
156 .rev()
157 .take_while(|&j| messages[j].role == "assistant" || messages[j].role == "tool")
158 .find(|&j| messages[j].role == "assistant");
159
160 let is_orphan = match assistant_idx {
161 None => true,
162 Some(idx) => match extract_assistant_tool_call_ids(&messages[idx].content) {
163 None => true,
164 Some(ids) => match extract_tool_call_id(&messages[i].content) {
165 Some(tool_call_id) => !ids.iter().any(|id| id == &tool_call_id),
166 None => false,
167 },
168 },
169 };
170
171 if is_orphan {
172 if let Some(id) = extract_tool_call_id(&messages[i].content) {
173 outcome.orphan_tool_call_ids.push(id);
174 }
175 messages.remove(i);
176 outcome.removed += 1;
177 } else {
178 i += 1;
179 }
180 }
181 outcome
182}
183
184fn extract_tool_call_id(content: &str) -> Option<String> {
189 let value: serde_json::Value = serde_json::from_str(content).ok()?;
190 value
191 .get("tool_call_id")
192 .and_then(|v| v.as_str())
193 .map(|s| s.to_string())
194}
195
196fn extract_assistant_tool_call_ids(content: &str) -> Option<Vec<String>> {
201 let value: serde_json::Value = serde_json::from_str(content).ok()?;
202 let arr = value.get("tool_calls")?.as_array()?;
203 let ids: Vec<String> = arr
204 .iter()
205 .filter_map(|call| call.get("id").and_then(|v| v.as_str()).map(str::to_owned))
206 .collect();
207 if ids.is_empty() { None } else { Some(ids) }
208}
209
210pub fn prune_history(messages: &mut Vec<ChatMessage>, config: &HistoryPrunerConfig) -> PruneStats {
215 let messages_before = messages.len();
216 if !config.enabled || messages.is_empty() {
217 return PruneStats {
218 messages_before,
219 messages_after: messages_before,
220 collapsed_pairs: 0,
221 dropped_messages: 0,
222 };
223 }
224
225 let mut collapsed_pairs: usize = 0;
226
227 if config.collapse_tool_results {
241 let mut i = 0;
242 while i < messages.len() {
243 let protected = protected_indices(messages, config.keep_recent);
244 if messages[i].role == "assistant" && !protected[i] {
245 let mut tool_count = 0;
248 let mut any_tool_protected = false;
249 while i + 1 + tool_count < messages.len()
250 && messages[i + 1 + tool_count].role == "tool"
251 {
252 if protected[i + 1 + tool_count] {
253 any_tool_protected = true;
254 }
255 tool_count += 1;
256 }
257 if tool_count > 0 && !any_tool_protected {
258 let summary =
259 format!("[Tool exchange: {tool_count} tool call(s) — results collapsed]");
260 messages[i] = ChatMessage {
261 role: "assistant".to_string(),
262 content: summary,
263 };
264 for _ in 0..tool_count {
265 messages.remove(i + 1);
266 }
267 collapsed_pairs += tool_count;
268 continue;
269 }
270 if tool_count > 0 {
271 i += 1 + tool_count;
275 continue;
276 }
277 }
278 i += 1;
279 }
280 }
281
282 let mut dropped_messages: usize = 0;
286 while estimate_tokens(messages) > config.max_tokens {
287 let protected = protected_indices(messages, config.keep_recent);
288 let mut dropped_any = false;
289 let mut i = 0;
290 while i < messages.len() {
291 if protected[i] {
292 i += 1;
293 continue;
294 }
295 if messages[i].role == "assistant" {
296 let mut tool_count = 0;
299 let mut any_tool_protected = false;
300 while i + 1 + tool_count < messages.len()
301 && messages[i + 1 + tool_count].role == "tool"
302 {
303 if protected[i + 1 + tool_count] {
304 any_tool_protected = true;
305 }
306 tool_count += 1;
307 }
308 if tool_count > 0 && !any_tool_protected {
309 for _ in 0..=tool_count {
310 messages.remove(i);
311 }
312 dropped_messages += 1 + tool_count;
313 dropped_any = true;
314 break;
315 } else if tool_count > 0 {
316 i += 1 + tool_count;
318 continue;
319 }
320 }
321 messages.remove(i);
323 dropped_messages += 1;
324 dropped_any = true;
325 break;
326 }
327 if !dropped_any {
328 break;
329 }
330 }
331
332 let mut i = 0;
336 while i + 1 < messages.len() {
337 if messages[i].role == "assistant"
338 && messages[i + 1].role == "assistant"
339 && is_tool_exchange_summary(&messages[i].content)
340 && is_tool_exchange_summary(&messages[i + 1].content)
341 {
342 let next = messages.remove(i + 1);
343 messages[i].content = format!("{}\n\n{}", messages[i].content, next.content);
344 dropped_messages += 1;
345 } else {
346 i += 1;
347 }
348 }
349
350 dropped_messages += remove_orphaned_tool_messages(messages).removed;
352
353 let mut i = 1;
357 while i < messages.len() {
358 if messages[i - 1].role == "assistant" && messages[i].role == "assistant" {
359 messages.insert(
360 i,
361 ChatMessage {
362 role: "user".to_string(),
363 content: "[context continues]".to_string(),
364 },
365 );
366 i += 2;
367 } else {
368 i += 1;
369 }
370 }
371
372 PruneStats {
373 messages_before,
374 messages_after: messages.len(),
375 collapsed_pairs,
376 dropped_messages,
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn msg(role: &str, content: &str) -> ChatMessage {
385 ChatMessage {
386 role: role.to_string(),
387 content: content.to_string(),
388 }
389 }
390
391 #[test]
392 fn prune_disabled_is_noop() {
393 let mut messages = vec![
394 msg("system", "You are helpful."),
395 msg("user", "Hello"),
396 msg("assistant", "Hi there!"),
397 ];
398 let config = HistoryPrunerConfig {
399 enabled: false,
400 ..Default::default()
401 };
402 let stats = prune_history(&mut messages, &config);
403 assert_eq!(messages.len(), 3);
404 assert_eq!(messages[0].content, "You are helpful.");
405 assert_eq!(stats.messages_before, 3);
406 assert_eq!(stats.messages_after, 3);
407 assert_eq!(stats.collapsed_pairs, 0);
408 }
409
410 #[test]
411 fn prune_under_budget_no_change() {
412 let mut messages = vec![
413 msg("system", "You are helpful."),
414 msg("user", "Hello"),
415 msg("assistant", "Hi!"),
416 ];
417 let config = HistoryPrunerConfig {
418 enabled: true,
419 max_tokens: 8192,
420 keep_recent: 2,
421 collapse_tool_results: false,
422 };
423 let stats = prune_history(&mut messages, &config);
424 assert_eq!(messages.len(), 3);
425 assert_eq!(stats.collapsed_pairs, 0);
426 assert_eq!(stats.dropped_messages, 0);
427 }
428
429 #[test]
430 fn prune_collapses_tool_pairs() {
431 let tool_result = "a".repeat(160);
432 let mut messages = vec![
433 msg("system", "sys"),
434 msg("assistant", "calling tool X"),
435 msg("tool", &tool_result),
436 msg("user", "thanks"),
437 msg("assistant", "done"),
438 ];
439 let config = HistoryPrunerConfig {
440 enabled: true,
441 max_tokens: 100_000,
442 keep_recent: 2,
443 collapse_tool_results: true,
444 };
445 let stats = prune_history(&mut messages, &config);
446 assert_eq!(stats.collapsed_pairs, 1);
447 assert_eq!(messages.len(), 4);
448 assert_eq!(messages[1].role, "assistant");
449 assert!(messages[1].content.contains("1 tool call(s)"));
450 }
451
452 #[test]
453 fn prune_preserves_system_and_recent() {
454 let big = "x".repeat(40_000);
455 let mut messages = vec![
456 msg("system", "system prompt"),
457 msg("user", &big),
458 msg("assistant", "old reply"),
459 msg("user", "recent1"),
460 msg("assistant", "recent2"),
461 ];
462 let config = HistoryPrunerConfig {
463 enabled: true,
464 max_tokens: 100,
465 keep_recent: 2,
466 collapse_tool_results: false,
467 };
468 let stats = prune_history(&mut messages, &config);
469 assert!(messages.iter().any(|m| m.role == "system"));
470 assert!(messages.iter().any(|m| m.content == "recent1"));
471 assert!(messages.iter().any(|m| m.content == "recent2"));
472 assert!(stats.dropped_messages > 0);
473 }
474
475 #[test]
476 fn prune_drops_oldest_when_over_budget() {
477 let filler = "y".repeat(400);
478 let mut messages = vec![
479 msg("system", "sys"),
480 msg("user", &filler),
481 msg("assistant", &filler),
482 msg("user", "recent-user"),
483 msg("assistant", "recent-assistant"),
484 ];
485 let config = HistoryPrunerConfig {
486 enabled: true,
487 max_tokens: 150,
488 keep_recent: 2,
489 collapse_tool_results: false,
490 };
491 let stats = prune_history(&mut messages, &config);
492 assert!(stats.dropped_messages >= 1);
493 assert_eq!(messages[0].role, "system");
494 assert!(messages.iter().any(|m| m.content == "recent-user"));
495 assert!(messages.iter().any(|m| m.content == "recent-assistant"));
496 }
497
498 #[test]
499 fn prune_empty_messages() {
500 let mut messages: Vec<ChatMessage> = vec![];
501 let config = HistoryPrunerConfig {
502 enabled: true,
503 ..Default::default()
504 };
505 let stats = prune_history(&mut messages, &config);
506 assert_eq!(stats.messages_before, 0);
507 assert_eq!(stats.messages_after, 0);
508 }
509
510 #[test]
511 fn prune_collapses_multi_tool_group() {
512 let mut messages = vec![
513 msg("system", "sys"),
514 msg(
515 "assistant",
516 r#"{"content":null,"tool_calls":[{"id":"t1","name":"shell","arguments":"{}"},{"id":"t2","name":"web","arguments":"{}"}]}"#,
517 ),
518 msg("tool", r#"{"tool_call_id":"t1","content":"result1"}"#),
519 msg("tool", r#"{"tool_call_id":"t2","content":"result2"}"#),
520 msg("user", "thanks"),
521 msg("assistant", "done"),
522 ];
523 let config = HistoryPrunerConfig {
524 enabled: true,
525 max_tokens: 100_000,
526 keep_recent: 2,
527 collapse_tool_results: true,
528 };
529 let stats = prune_history(&mut messages, &config);
530 assert_eq!(stats.collapsed_pairs, 2);
531 assert_eq!(messages.len(), 4); assert!(messages[1].content.contains("2 tool call(s)"));
534 assert!(!messages.iter().any(|m| m.role == "tool"));
536 }
537
538 #[test]
539 fn prune_drops_tool_group_atomically() {
540 let big = "x".repeat(2000);
541 let mut messages = vec![
542 msg("system", "sys"),
543 msg("assistant", &big),
544 msg("tool", &big),
545 msg("tool", &big),
546 msg("user", "recent"),
547 msg("assistant", "recent reply"),
548 ];
549 let config = HistoryPrunerConfig {
550 enabled: true,
551 max_tokens: 50, keep_recent: 2,
553 collapse_tool_results: false, };
555 let stats = prune_history(&mut messages, &config);
556 assert!(stats.dropped_messages >= 3); for (i, m) in messages.iter().enumerate() {
559 if m.role == "tool" {
560 assert!(
561 i > 0 && messages[i - 1].role == "assistant",
562 "tool message at index {i} has no preceding assistant"
563 );
564 }
565 }
566 }
567
568 #[test]
569 fn prune_never_orphans_tool_use() {
570 let filler = "y".repeat(500);
572 let mut messages = vec![
573 msg("system", "sys"),
574 msg("user", "q1"),
575 msg("assistant", &filler), msg("tool", &filler),
577 msg("user", "q2"),
578 msg("assistant", &filler), msg("tool", &filler),
580 msg("tool", &filler),
581 msg("user", "recent"),
582 msg("assistant", "recent reply"),
583 ];
584 let config = HistoryPrunerConfig {
585 enabled: true,
586 max_tokens: 100,
587 keep_recent: 2,
588 collapse_tool_results: true,
589 };
590 prune_history(&mut messages, &config);
591 for (i, m) in messages.iter().enumerate() {
593 if m.role == "tool" {
594 assert!(
595 i > 0 && messages[i - 1].role == "assistant",
596 "orphaned tool message at index {i}: {:?}",
597 messages.iter().map(|m| &m.role).collect::<Vec<_>>()
598 );
599 }
600 }
601 }
602
603 #[test]
604 fn prune_protects_recent_tool_groups() {
605 let mut messages = vec![
606 msg("system", "sys"),
607 msg("user", "old"),
608 msg("assistant", "old reply"),
609 msg("user", "do something"),
610 msg(
611 "assistant",
612 r#"{"content":"checking","tool_calls":[{"id":"toolu_recent","name":"shell","arguments":"{}"}]}"#,
613 ),
614 msg(
615 "tool",
616 r#"{"tool_call_id":"toolu_recent","content":"tool result"}"#,
617 ),
618 msg("user", "recent"),
619 ];
620 let config = HistoryPrunerConfig {
621 enabled: true,
622 max_tokens: 100_000,
623 keep_recent: 3, collapse_tool_results: true,
625 };
626 let stats = prune_history(&mut messages, &config);
627 assert!(messages.iter().any(|m| m.role == "tool"));
629 assert_eq!(stats.collapsed_pairs, 0);
630 }
631
632 #[test]
633 fn prune_under_realistic_token_pressure_preserves_tool_pairing() {
634 let mut messages = vec![msg("system", "You are helpful.")];
636 messages.push(msg("user", "Research this topic thoroughly"));
637
638 for i in 0..15 {
640 let tool_json = format!(
641 r#"{{"content":"iteration {i}","tool_calls":[{{"id":"t{i}","name":"web_search","arguments":"{{}}"}}]}}"#
642 );
643 messages.push(msg("assistant", &tool_json));
644 let result = format!(
646 r#"{{"tool_call_id":"t{i}","content":"{}"}}"#,
647 "x".repeat(2000)
648 );
649 messages.push(msg("tool", &result));
650 }
651 messages.push(msg("assistant", "Here's what I found..."));
652
653 assert_eq!(messages.len(), 33);
655
656 let config = HistoryPrunerConfig {
657 enabled: true,
658 max_tokens: 2000, keep_recent: 4,
660 collapse_tool_results: true,
661 };
662
663 prune_history(&mut messages, &config);
664
665 for (i, m) in messages.iter().enumerate() {
667 if m.role == "tool" {
668 assert!(
669 i > 0 && messages[i - 1].role == "assistant",
670 "orphaned tool at index {i}: roles = {:?}",
671 messages.iter().map(|m| &m.role).collect::<Vec<_>>()
672 );
673 }
674 }
675 }
676
677 #[test]
678 fn prune_merges_consecutive_collapsed_assistant_messages() {
679 let mut messages = vec![
680 msg("system", "sys"),
681 msg(
682 "assistant",
683 r#"{"content":null,"tool_calls":[{"id":"t1","name":"shell","arguments":"{}"}]}"#,
684 ),
685 msg("tool", r#"{"tool_call_id":"t1","content":"first"}"#),
686 msg(
687 "assistant",
688 r#"{"content":null,"tool_calls":[{"id":"t2","name":"web","arguments":"{}"}]}"#,
689 ),
690 msg("tool", r#"{"tool_call_id":"t2","content":"second"}"#),
691 msg("user", "recent"),
692 msg("assistant", "done"),
693 ];
694
695 let config = HistoryPrunerConfig {
696 enabled: true,
697 max_tokens: 100_000,
698 keep_recent: 2,
699 collapse_tool_results: true,
700 };
701 let stats = prune_history(&mut messages, &config);
702
703 assert_eq!(stats.collapsed_pairs, 2);
704 assert_eq!(messages.len(), 4);
705 assert_eq!(messages[1].role, "assistant");
706 assert!(messages[1].content.contains("1 tool call(s)"));
707 assert_eq!(messages.iter().filter(|m| m.role == "assistant").count(), 2);
708 assert!(
709 messages
710 .windows(2)
711 .all(|pair| !(pair[0].role == "assistant" && pair[1].role == "assistant")),
712 "pruned roles should not contain adjacent assistants: {:?}",
713 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
714 );
715 }
716
717 #[test]
718 fn prune_preserves_straddled_tool_group_after_collapsed_summary() {
719 let mut messages = vec![
720 msg("system", "sys"),
721 msg(
722 "assistant",
723 r#"{"content":null,"tool_calls":[{"id":"old","name":"shell","arguments":"{}"}]}"#,
724 ),
725 msg("tool", r#"{"tool_call_id":"old","content":"old result"}"#),
726 msg(
727 "assistant",
728 r#"{"content":null,"tool_calls":[{"id":"live","name":"shell","arguments":"{}"}]}"#,
729 ),
730 msg("tool", r#"{"tool_call_id":"live","content":"live result"}"#),
731 msg("user", "follow up"),
732 ];
733
734 let config = HistoryPrunerConfig {
735 enabled: true,
736 max_tokens: 100_000,
737 keep_recent: 3,
738 collapse_tool_results: true,
739 };
740 let stats = prune_history(&mut messages, &config);
741
742 assert_eq!(stats.collapsed_pairs, 1);
743 assert!(
744 messages
745 .iter()
746 .any(|m| m.role == "assistant" && m.content.contains("\"id\":\"live\"")),
747 "protected assistant tool call should survive: {messages:?}"
748 );
749 assert!(
750 messages
751 .iter()
752 .any(|m| m.role == "tool" && m.content.contains("\"tool_call_id\":\"live\"")),
753 "matching protected tool result should survive: {messages:?}"
754 );
755 assert!(
756 messages
757 .iter()
758 .any(|m| m.role == "user" && m.content == "[context continues]"),
759 "Phase 5 should separate collapsed summary from live assistant"
760 );
761 assert!(
762 messages
763 .windows(2)
764 .all(|pair| !(pair[0].role == "assistant" && pair[1].role == "assistant")),
765 "pruned roles should not contain adjacent assistants: {:?}",
766 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
767 );
768 }
769
770 #[test]
771 fn prune_removes_dangling_tool_call_after_collapsed_summary() {
772 let mut messages = vec![
773 msg("system", "sys"),
774 msg(
775 "assistant",
776 "[Tool exchange: 1 tool call(s) — results collapsed]",
777 ),
778 msg(
779 "assistant",
780 r#"{"content":null,"tool_calls":[{"id":"dangling","name":"shell","arguments":"{}"}]}"#,
781 ),
782 msg("user", "follow up"),
783 ];
784
785 let config = HistoryPrunerConfig {
786 enabled: true,
787 max_tokens: 100_000,
788 keep_recent: 2,
789 collapse_tool_results: true,
790 };
791 let stats = prune_history(&mut messages, &config);
792
793 assert_eq!(stats.dropped_messages, 1);
794 assert!(
795 !messages
796 .iter()
797 .any(|m| m.content.contains("\"id\":\"dangling\"")),
798 "dangling assistant tool call should not survive: {messages:?}"
799 );
800 assert_eq!(
801 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(),
802 vec!["system", "assistant", "user"]
803 );
804 }
805
806 #[test]
807 fn prune_does_not_merge_json_tool_call_assistants_as_summaries() {
808 let mut messages = vec![
809 msg("system", "sys"),
810 msg(
811 "assistant",
812 r#"{"content":null,"tool_calls":[{"id":"live1","name":"shell","arguments":"{}"}]}"#,
813 ),
814 msg("tool", r#"{"tool_call_id":"live1","content":"first"}"#),
815 msg(
816 "assistant",
817 r#"{"content":null,"tool_calls":[{"id":"live2","name":"web","arguments":"{}"}]}"#,
818 ),
819 msg("tool", r#"{"tool_call_id":"live2","content":"second"}"#),
820 ];
821
822 let config = HistoryPrunerConfig {
823 enabled: true,
824 max_tokens: 100_000,
825 keep_recent: 4,
826 collapse_tool_results: true,
827 };
828 let stats = prune_history(&mut messages, &config);
829
830 assert_eq!(stats.collapsed_pairs, 0);
831 assert!(
832 messages
833 .iter()
834 .any(|m| m.content.contains("\"id\":\"live1\"")),
835 "first protected tool call should remain structured"
836 );
837 assert!(
838 messages
839 .iter()
840 .any(|m| m.content.contains("\"id\":\"live2\"")),
841 "second protected tool call should remain structured"
842 );
843 }
844
845 #[test]
846 fn prune_inserts_separator_when_tight_budget_leaves_protected_assistants() {
847 let mut messages = vec![
848 msg("system", "sys"),
849 msg("assistant", "protected assistant one"),
850 msg("assistant", "protected assistant two"),
851 ];
852
853 let config = HistoryPrunerConfig {
854 enabled: true,
855 max_tokens: 1,
856 keep_recent: 2,
857 collapse_tool_results: false,
858 };
859 let stats = prune_history(&mut messages, &config);
860
861 assert_eq!(stats.dropped_messages, 0);
862 assert_eq!(
863 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(),
864 vec!["system", "assistant", "user", "assistant"]
865 );
866 assert_eq!(messages[2].content, "[context continues]");
867 }
868
869 #[test]
874 fn orphan_tool_at_start_is_removed() {
875 let mut messages = vec![
878 msg("system", "sys"),
879 msg(
880 "tool",
881 r#"{"content":"file listing","tool_call_id":"toolu_01HiJXWbhx"}"#,
882 ),
883 msg(
884 "tool",
885 r#"{"content":"another result","tool_call_id":"toolu_01AQP25qUz"}"#,
886 ),
887 msg("user", "thanks"),
888 msg("assistant", "done"),
889 ];
890 let pruned = remove_orphaned_tool_messages(&mut messages);
891 assert_eq!(pruned.removed, 2);
892 assert_eq!(messages.len(), 3);
893 assert_eq!(messages[0].role, "system");
894 assert_eq!(messages[1].role, "user");
895 assert_eq!(messages[2].role, "assistant");
896 }
897
898 #[test]
899 fn valid_tool_pair_preserved() {
900 let assistant_with_tools = r#"{"content":"checking","tool_calls":[{"id":"toolu_abc123","name":"shell","arguments":"{}"}]}"#;
902 let tool_result = r#"{"content":"ok","tool_call_id":"toolu_abc123"}"#;
903 let mut messages = vec![
904 msg("system", "sys"),
905 msg("user", "do it"),
906 msg("assistant", assistant_with_tools),
907 msg("tool", tool_result),
908 msg("assistant", "done"),
909 ];
910 let pruned = remove_orphaned_tool_messages(&mut messages);
911 assert_eq!(pruned.removed, 0);
912 assert_eq!(messages.len(), 5);
913 }
914
915 #[test]
916 fn multi_tool_call_batch_preserved() {
917 let assistant_content = r#"{"content":"running","tool_calls":[{"id":"toolu_aaa","name":"shell","arguments":"{}"},{"id":"toolu_bbb","name":"shell","arguments":"{}"},{"id":"toolu_ccc","name":"shell","arguments":"{}"}]}"#;
919 let mut messages = vec![
920 msg("system", "sys"),
921 msg("user", "do all 3"),
922 msg("assistant", assistant_content),
923 msg("tool", r#"{"content":"r1","tool_call_id":"toolu_aaa"}"#),
924 msg("tool", r#"{"content":"r2","tool_call_id":"toolu_bbb"}"#),
925 msg("tool", r#"{"content":"r3","tool_call_id":"toolu_ccc"}"#),
926 msg("assistant", "all done"),
927 ];
928 let pruned = remove_orphaned_tool_messages(&mut messages);
929 assert_eq!(pruned.removed, 0);
930 assert_eq!(messages.len(), 7);
931 }
932
933 #[test]
934 fn mismatched_tool_id_is_removed() {
935 let assistant_content = r#"{"content":"running","tool_calls":[{"id":"toolu_aaa","name":"shell","arguments":"{}"}]}"#;
937 let mut messages = vec![
938 msg("system", "sys"),
939 msg("user", "go"),
940 msg("assistant", assistant_content),
941 msg("tool", r#"{"content":"ok","tool_call_id":"toolu_aaa"}"#),
942 msg("tool", r#"{"content":"stale","tool_call_id":"toolu_GONE"}"#),
943 msg("assistant", "done"),
944 ];
945 let pruned = remove_orphaned_tool_messages(&mut messages);
946 assert_eq!(pruned.removed, 1);
947 assert_eq!(messages.len(), 5);
948 assert_eq!(messages[3].role, "tool");
950 assert!(messages[3].content.contains("toolu_aaa"));
951 }
952
953 #[test]
954 fn orphan_tool_in_middle_after_collapsed_pair() {
955 let mut messages = vec![
958 msg("system", "sys"),
959 msg("assistant", "[Tool result: truncated...]"), msg(
961 "tool",
962 r#"{"content":"leftover","tool_call_id":"toolu_OLD"}"#,
963 ),
964 msg("user", "next"),
965 msg("assistant", "ok"),
966 ];
967 let pruned = remove_orphaned_tool_messages(&mut messages);
968 assert_eq!(pruned.removed, 1);
969 assert_eq!(messages.len(), 4);
970 assert_eq!(messages[1].role, "assistant");
971 assert_eq!(messages[2].role, "user");
972 }
973
974 #[test]
975 fn consecutive_assistant_with_tool_calls_stripped() {
976 let tool_calls_assistant = r#"{"content":null,"tool_calls":[{"id":"toolu_DEAD","name":"shell","arguments":"{}"}]}"#;
981 let mut messages = vec![
982 msg("system", "sys"),
983 msg("user", "do something"),
984 msg("assistant", "Here are the results."),
985 msg("assistant", tool_calls_assistant),
986 msg("tool", r#"{"content":"ok","tool_call_id":"toolu_DEAD"}"#),
987 msg(
988 "assistant",
989 "The model_provider returned an empty response.",
990 ),
991 ];
992 let pruned = remove_orphaned_tool_messages(&mut messages);
993 assert_eq!(
994 pruned.removed, 2,
995 "should remove assistant(tool_calls) + tool_result"
996 );
997 assert_eq!(messages.len(), 4);
998 assert_eq!(messages[0].role, "system");
999 assert_eq!(messages[1].role, "user");
1000 assert_eq!(messages[2].role, "assistant");
1001 assert_eq!(messages[2].content, "Here are the results.");
1002 assert_eq!(messages[3].role, "assistant");
1003 assert_eq!(
1004 messages[3].content,
1005 "The model_provider returned an empty response."
1006 );
1007 }
1008
1009 #[test]
1010 fn tool_without_parseable_id_kept_if_assistant_has_tool_calls() {
1011 let assistant_content = r#"{"content":"running","tool_calls":[{"id":"toolu_x","name":"shell","arguments":"{}"}]}"#;
1014 let mut messages = vec![
1015 msg("system", "sys"),
1016 msg("user", "go"),
1017 msg("assistant", assistant_content),
1018 msg("tool", "plain text result without json"),
1019 msg("assistant", "done"),
1020 ];
1021 let pruned = remove_orphaned_tool_messages(&mut messages);
1022 assert_eq!(pruned.removed, 0);
1023 assert_eq!(messages.len(), 5);
1024 }
1025
1026 #[test]
1027 fn phase2_budget_respects_protected_tool_messages() {
1028 let tool_content = r#"{"tool_call_id":"toolu_recent","content":"result"}"#;
1032 let assistant_tool = r#"{"content":"calling","tool_calls":[{"id":"toolu_recent","name":"shell","arguments":"{}"}]}"#;
1033 let mut messages = vec![
1034 msg("system", "sys"),
1035 msg("user", "old question"),
1036 msg(
1037 "assistant",
1038 "old answer with lots of padding text to inflate token count significantly beyond budget",
1039 ),
1040 msg("user", "another old question"),
1041 msg("assistant", assistant_tool), msg("tool", tool_content), msg("user", "recent question"), msg("assistant", "recent answer"), ];
1046 let config = HistoryPrunerConfig {
1048 enabled: true,
1049 max_tokens: 50,
1050 keep_recent: 3,
1051 collapse_tool_results: true,
1052 };
1053 prune_history(&mut messages, &config);
1054 assert!(
1056 messages.iter().any(|m| m.content.contains("toolu_recent")),
1057 "Protected tool message was dropped by Phase 2 budget enforcement"
1058 );
1059 }
1060
1061 #[test]
1068 fn orphan_tool_not_fooled_by_id_in_summary_text() {
1069 let summary = "[CONTEXT SUMMARY \u{2014} 4 messages compressed]\n\
1070 Earlier turns invoked shell with tool_calls id toolu_01Orphan \
1071 and returned ok.";
1072 let mut messages = vec![
1073 msg("system", "sys"),
1074 msg("assistant", summary),
1075 msg(
1076 "tool",
1077 r#"{"tool_call_id":"toolu_01Orphan","content":"stale"}"#,
1078 ),
1079 msg("user", "new question"),
1080 ];
1081 let pruned = remove_orphaned_tool_messages(&mut messages);
1082 assert_eq!(
1083 pruned.removed, 1,
1084 "orphan must be removed even if its id is mentioned in summary text"
1085 );
1086 assert!(!messages.iter().any(|m| m.role == "tool"));
1087 }
1088
1089 #[test]
1093 fn orphan_tool_from_trimmed_channel_history() {
1094 let tool_result =
1098 r#"{"content":"search results","tool_call_id":"chatcmpl-tool-92a12a15c14f3b36"}"#;
1099 let mut messages = vec![
1100 msg("system", "You are a helpful assistant"),
1101 msg("tool", tool_result),
1102 msg("assistant", "Here are the search results"),
1103 msg("user", "Thanks, now summarize them"),
1104 ];
1105 let pruned = remove_orphaned_tool_messages(&mut messages);
1106 assert_eq!(pruned.removed, 1, "orphaned tool message should be removed");
1107 assert_eq!(messages.len(), 3);
1108 assert_eq!(messages[0].role, "system");
1109 assert_eq!(messages[1].role, "assistant");
1110 assert_eq!(messages[2].role, "user");
1111 }
1112
1113 #[test]
1125 fn prune_does_not_evict_protected_tool_when_group_straddles_keep_recent() {
1126 let mut messages = vec![
1127 msg("system", "sys"),
1128 msg("user", "query"),
1129 msg(
1130 "assistant",
1131 r#"{"content":null,"tool_calls":[
1132 {"id":"t1","name":"shell","arguments":"{}"},
1133 {"id":"t2","name":"web","arguments":"{}"}
1134 ]}"#,
1135 ),
1136 msg("tool", r#"{"tool_call_id":"t1","content":"first"}"#),
1137 msg(
1138 "tool",
1139 r#"{"tool_call_id":"t2","content":"PROTECTED second"}"#,
1140 ),
1141 msg("user", "follow up"),
1142 msg("assistant", "final"),
1143 ];
1144
1145 let config = HistoryPrunerConfig {
1146 enabled: true,
1147 max_tokens: 100_000,
1151 keep_recent: 3,
1152 collapse_tool_results: true,
1153 };
1154
1155 let stats = prune_history(&mut messages, &config);
1156
1157 assert_eq!(stats.messages_before, 7);
1158 assert!(
1159 messages
1160 .iter()
1161 .any(|m| m.content.contains("PROTECTED second")),
1162 "a tool message protected by keep_recent must survive; \
1163 got roles {:?}",
1164 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
1165 );
1166 }
1167}