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
91fn assistant_is_unresolved_dispatch(
98 messages: &[ChatMessage],
99 prev_idx: usize,
100 next_idx: usize,
101) -> bool {
102 match extract_assistant_tool_call_ids(&messages[prev_idx].content) {
103 Some(ids) if !ids.is_empty() => {
104 let between = &messages[prev_idx + 1..next_idx];
105 !ids.iter().all(|id| {
106 between.iter().any(|m| {
107 m.role == "tool" && extract_tool_call_id(&m.content).as_ref() == Some(id)
108 })
109 })
110 }
111 _ => false,
112 }
113}
114
115impl PrunedOrphans {
116 pub fn is_empty(&self) -> bool {
117 self.removed == 0
118 }
119}
120
121pub fn remove_orphaned_tool_messages(messages: &mut Vec<ChatMessage>) -> PrunedOrphans {
128 let mut outcome = PrunedOrphans::default();
129 let mut i = 0;
145 while i < messages.len() {
146 let assistant_tool_call_ids = if messages[i].role == "assistant" {
147 extract_assistant_tool_call_ids(&messages[i].content)
148 } else {
149 None
150 };
151 if let Some(doomed_ids) = assistant_tool_call_ids
152 && i > 0
153 && messages[i - 1].role == "assistant"
154 && ((is_tool_exchange_summary(&messages[i - 1].content)
155 && !assistant_tool_calls_have_immediate_results(messages, i, &doomed_ids))
156 || assistant_is_unresolved_dispatch(messages, i - 1, i))
157 {
158 outcome
159 .orphan_tool_call_ids
160 .extend(doomed_ids.iter().cloned());
161 messages.remove(i);
162 outcome.removed += 1;
163 while i < messages.len() && messages[i].role == "tool" {
164 let dominated = match extract_tool_call_id(&messages[i].content) {
165 Some(id) => doomed_ids.iter().any(|d| d == &id),
166 None => true,
167 };
168 if dominated {
169 messages.remove(i);
170 outcome.removed += 1;
171 } else {
172 break;
173 }
174 }
175 } else {
176 i += 1;
177 }
178 }
179
180 i = 0;
186 while i < messages.len() {
187 if messages[i].role != "tool" {
188 i += 1;
189 continue;
190 }
191
192 let assistant_idx = (0..i)
193 .rev()
194 .take_while(|&j| messages[j].role == "assistant" || messages[j].role == "tool")
195 .find(|&j| messages[j].role == "assistant");
196
197 let is_orphan = match assistant_idx {
198 None => true,
199 Some(idx) => match extract_assistant_tool_call_ids(&messages[idx].content) {
200 None => true,
201 Some(ids) => match extract_tool_call_id(&messages[i].content) {
202 Some(tool_call_id) => !ids.iter().any(|id| id == &tool_call_id),
203 None => false,
204 },
205 },
206 };
207
208 if is_orphan {
209 if let Some(id) = extract_tool_call_id(&messages[i].content) {
210 outcome.orphan_tool_call_ids.push(id);
211 }
212 messages.remove(i);
213 outcome.removed += 1;
214 } else {
215 i += 1;
216 }
217 }
218 outcome
219}
220
221fn extract_tool_call_id(content: &str) -> Option<String> {
226 let value: serde_json::Value = serde_json::from_str(content).ok()?;
227 value
228 .get("tool_call_id")
229 .and_then(|v| v.as_str())
230 .map(|s| s.to_string())
231}
232
233fn extract_assistant_tool_call_ids(content: &str) -> Option<Vec<String>> {
238 let value: serde_json::Value = serde_json::from_str(content).ok()?;
239 let arr = value.get("tool_calls")?.as_array()?;
240 let ids: Vec<String> = arr
241 .iter()
242 .filter_map(|call| call.get("id").and_then(|v| v.as_str()).map(str::to_owned))
243 .collect();
244 if ids.is_empty() { None } else { Some(ids) }
245}
246
247pub fn prune_history(messages: &mut Vec<ChatMessage>, config: &HistoryPrunerConfig) -> PruneStats {
252 let messages_before = messages.len();
253 if !config.enabled || messages.is_empty() {
254 return PruneStats {
255 messages_before,
256 messages_after: messages_before,
257 collapsed_pairs: 0,
258 dropped_messages: 0,
259 };
260 }
261
262 let mut collapsed_pairs: usize = 0;
263
264 if config.collapse_tool_results {
278 let mut i = 0;
279 while i < messages.len() {
280 let protected = protected_indices(messages, config.keep_recent);
281 if messages[i].role == "assistant" && !protected[i] {
282 let mut tool_count = 0;
285 let mut any_tool_protected = false;
286 while i + 1 + tool_count < messages.len()
287 && messages[i + 1 + tool_count].role == "tool"
288 {
289 if protected[i + 1 + tool_count] {
290 any_tool_protected = true;
291 }
292 tool_count += 1;
293 }
294 if tool_count > 0 && !any_tool_protected {
295 let summary =
296 format!("[Tool exchange: {tool_count} tool call(s) — results collapsed]");
297 messages[i] = ChatMessage {
298 role: "assistant".to_string(),
299 content: summary,
300 };
301 for _ in 0..tool_count {
302 messages.remove(i + 1);
303 }
304 collapsed_pairs += tool_count;
305 continue;
306 }
307 if tool_count > 0 {
308 i += 1 + tool_count;
312 continue;
313 }
314 }
315 i += 1;
316 }
317 }
318
319 let mut dropped_messages: usize = 0;
323 while estimate_tokens(messages) > config.max_tokens {
324 let protected = protected_indices(messages, config.keep_recent);
325 let mut dropped_any = false;
326 let mut i = 0;
327 while i < messages.len() {
328 if protected[i] {
329 i += 1;
330 continue;
331 }
332 if messages[i].role == "assistant" {
333 let mut tool_count = 0;
336 let mut any_tool_protected = false;
337 while i + 1 + tool_count < messages.len()
338 && messages[i + 1 + tool_count].role == "tool"
339 {
340 if protected[i + 1 + tool_count] {
341 any_tool_protected = true;
342 }
343 tool_count += 1;
344 }
345 if tool_count > 0 && !any_tool_protected {
346 for _ in 0..=tool_count {
347 messages.remove(i);
348 }
349 dropped_messages += 1 + tool_count;
350 dropped_any = true;
351 break;
352 } else if tool_count > 0 {
353 i += 1 + tool_count;
355 continue;
356 }
357 }
358 messages.remove(i);
360 dropped_messages += 1;
361 dropped_any = true;
362 break;
363 }
364 if !dropped_any {
365 break;
366 }
367 }
368
369 let mut i = 0;
373 while i + 1 < messages.len() {
374 if messages[i].role == "assistant"
375 && messages[i + 1].role == "assistant"
376 && is_tool_exchange_summary(&messages[i].content)
377 && is_tool_exchange_summary(&messages[i + 1].content)
378 {
379 let next = messages.remove(i + 1);
380 messages[i].content = format!("{}\n\n{}", messages[i].content, next.content);
381 dropped_messages += 1;
382 } else {
383 i += 1;
384 }
385 }
386
387 dropped_messages += remove_orphaned_tool_messages(messages).removed;
389
390 let mut i = 1;
394 while i < messages.len() {
395 if messages[i - 1].role == "assistant" && messages[i].role == "assistant" {
396 messages.insert(
397 i,
398 ChatMessage {
399 role: "user".to_string(),
400 content: "[context continues]".to_string(),
401 },
402 );
403 i += 2;
404 } else {
405 i += 1;
406 }
407 }
408
409 PruneStats {
410 messages_before,
411 messages_after: messages.len(),
412 collapsed_pairs,
413 dropped_messages,
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 fn msg(role: &str, content: &str) -> ChatMessage {
422 ChatMessage {
423 role: role.to_string(),
424 content: content.to_string(),
425 }
426 }
427
428 #[test]
429 fn prune_disabled_is_noop() {
430 let mut messages = vec![
431 msg("system", "You are helpful."),
432 msg("user", "Hello"),
433 msg("assistant", "Hi there!"),
434 ];
435 let config = HistoryPrunerConfig {
436 enabled: false,
437 ..Default::default()
438 };
439 let stats = prune_history(&mut messages, &config);
440 assert_eq!(messages.len(), 3);
441 assert_eq!(messages[0].content, "You are helpful.");
442 assert_eq!(stats.messages_before, 3);
443 assert_eq!(stats.messages_after, 3);
444 assert_eq!(stats.collapsed_pairs, 0);
445 }
446
447 #[test]
448 fn prune_under_budget_no_change() {
449 let mut messages = vec![
450 msg("system", "You are helpful."),
451 msg("user", "Hello"),
452 msg("assistant", "Hi!"),
453 ];
454 let config = HistoryPrunerConfig {
455 enabled: true,
456 max_tokens: 8192,
457 keep_recent: 2,
458 collapse_tool_results: false,
459 };
460 let stats = prune_history(&mut messages, &config);
461 assert_eq!(messages.len(), 3);
462 assert_eq!(stats.collapsed_pairs, 0);
463 assert_eq!(stats.dropped_messages, 0);
464 }
465
466 #[test]
467 fn prune_collapses_tool_pairs() {
468 let tool_result = "a".repeat(160);
469 let mut messages = vec![
470 msg("system", "sys"),
471 msg("assistant", "calling tool X"),
472 msg("tool", &tool_result),
473 msg("user", "thanks"),
474 msg("assistant", "done"),
475 ];
476 let config = HistoryPrunerConfig {
477 enabled: true,
478 max_tokens: 100_000,
479 keep_recent: 2,
480 collapse_tool_results: true,
481 };
482 let stats = prune_history(&mut messages, &config);
483 assert_eq!(stats.collapsed_pairs, 1);
484 assert_eq!(messages.len(), 4);
485 assert_eq!(messages[1].role, "assistant");
486 assert!(messages[1].content.contains("1 tool call(s)"));
487 }
488
489 #[test]
490 fn prune_preserves_system_and_recent() {
491 let big = "x".repeat(40_000);
492 let mut messages = vec![
493 msg("system", "system prompt"),
494 msg("user", &big),
495 msg("assistant", "old reply"),
496 msg("user", "recent1"),
497 msg("assistant", "recent2"),
498 ];
499 let config = HistoryPrunerConfig {
500 enabled: true,
501 max_tokens: 100,
502 keep_recent: 2,
503 collapse_tool_results: false,
504 };
505 let stats = prune_history(&mut messages, &config);
506 assert!(messages.iter().any(|m| m.role == "system"));
507 assert!(messages.iter().any(|m| m.content == "recent1"));
508 assert!(messages.iter().any(|m| m.content == "recent2"));
509 assert!(stats.dropped_messages > 0);
510 }
511
512 #[test]
513 fn prune_drops_oldest_when_over_budget() {
514 let filler = "y".repeat(400);
515 let mut messages = vec![
516 msg("system", "sys"),
517 msg("user", &filler),
518 msg("assistant", &filler),
519 msg("user", "recent-user"),
520 msg("assistant", "recent-assistant"),
521 ];
522 let config = HistoryPrunerConfig {
523 enabled: true,
524 max_tokens: 150,
525 keep_recent: 2,
526 collapse_tool_results: false,
527 };
528 let stats = prune_history(&mut messages, &config);
529 assert!(stats.dropped_messages >= 1);
530 assert_eq!(messages[0].role, "system");
531 assert!(messages.iter().any(|m| m.content == "recent-user"));
532 assert!(messages.iter().any(|m| m.content == "recent-assistant"));
533 }
534
535 #[test]
536 fn prune_empty_messages() {
537 let mut messages: Vec<ChatMessage> = vec![];
538 let config = HistoryPrunerConfig {
539 enabled: true,
540 ..Default::default()
541 };
542 let stats = prune_history(&mut messages, &config);
543 assert_eq!(stats.messages_before, 0);
544 assert_eq!(stats.messages_after, 0);
545 }
546
547 #[test]
548 fn prune_collapses_multi_tool_group() {
549 let mut messages = vec![
550 msg("system", "sys"),
551 msg(
552 "assistant",
553 r#"{"content":null,"tool_calls":[{"id":"t1","name":"shell","arguments":"{}"},{"id":"t2","name":"web","arguments":"{}"}]}"#,
554 ),
555 msg("tool", r#"{"tool_call_id":"t1","content":"result1"}"#),
556 msg("tool", r#"{"tool_call_id":"t2","content":"result2"}"#),
557 msg("user", "thanks"),
558 msg("assistant", "done"),
559 ];
560 let config = HistoryPrunerConfig {
561 enabled: true,
562 max_tokens: 100_000,
563 keep_recent: 2,
564 collapse_tool_results: true,
565 };
566 let stats = prune_history(&mut messages, &config);
567 assert_eq!(stats.collapsed_pairs, 2);
568 assert_eq!(messages.len(), 4); assert!(messages[1].content.contains("2 tool call(s)"));
571 assert!(!messages.iter().any(|m| m.role == "tool"));
573 }
574
575 #[test]
576 fn prune_drops_tool_group_atomically() {
577 let big = "x".repeat(2000);
578 let mut messages = vec![
579 msg("system", "sys"),
580 msg("assistant", &big),
581 msg("tool", &big),
582 msg("tool", &big),
583 msg("user", "recent"),
584 msg("assistant", "recent reply"),
585 ];
586 let config = HistoryPrunerConfig {
587 enabled: true,
588 max_tokens: 50, keep_recent: 2,
590 collapse_tool_results: false, };
592 let stats = prune_history(&mut messages, &config);
593 assert!(stats.dropped_messages >= 3); for (i, m) in messages.iter().enumerate() {
596 if m.role == "tool" {
597 assert!(
598 i > 0 && messages[i - 1].role == "assistant",
599 "tool message at index {i} has no preceding assistant"
600 );
601 }
602 }
603 }
604
605 #[test]
606 fn prune_never_orphans_tool_use() {
607 let filler = "y".repeat(500);
609 let mut messages = vec![
610 msg("system", "sys"),
611 msg("user", "q1"),
612 msg("assistant", &filler), msg("tool", &filler),
614 msg("user", "q2"),
615 msg("assistant", &filler), msg("tool", &filler),
617 msg("tool", &filler),
618 msg("user", "recent"),
619 msg("assistant", "recent reply"),
620 ];
621 let config = HistoryPrunerConfig {
622 enabled: true,
623 max_tokens: 100,
624 keep_recent: 2,
625 collapse_tool_results: true,
626 };
627 prune_history(&mut messages, &config);
628 for (i, m) in messages.iter().enumerate() {
630 if m.role == "tool" {
631 assert!(
632 i > 0 && messages[i - 1].role == "assistant",
633 "orphaned tool message at index {i}: {:?}",
634 messages.iter().map(|m| &m.role).collect::<Vec<_>>()
635 );
636 }
637 }
638 }
639
640 #[test]
641 fn prune_protects_recent_tool_groups() {
642 let mut messages = vec![
643 msg("system", "sys"),
644 msg("user", "old"),
645 msg("assistant", "old reply"),
646 msg("user", "do something"),
647 msg(
648 "assistant",
649 r#"{"content":"checking","tool_calls":[{"id":"toolu_recent","name":"shell","arguments":"{}"}]}"#,
650 ),
651 msg(
652 "tool",
653 r#"{"tool_call_id":"toolu_recent","content":"tool result"}"#,
654 ),
655 msg("user", "recent"),
656 ];
657 let config = HistoryPrunerConfig {
658 enabled: true,
659 max_tokens: 100_000,
660 keep_recent: 3, collapse_tool_results: true,
662 };
663 let stats = prune_history(&mut messages, &config);
664 assert!(messages.iter().any(|m| m.role == "tool"));
666 assert_eq!(stats.collapsed_pairs, 0);
667 }
668
669 #[test]
670 fn prune_under_realistic_token_pressure_preserves_tool_pairing() {
671 let mut messages = vec![msg("system", "You are helpful.")];
673 messages.push(msg("user", "Research this topic thoroughly"));
674
675 for i in 0..15 {
677 let tool_json = format!(
678 r#"{{"content":"iteration {i}","tool_calls":[{{"id":"t{i}","name":"web_search","arguments":"{{}}"}}]}}"#
679 );
680 messages.push(msg("assistant", &tool_json));
681 let result = format!(
683 r#"{{"tool_call_id":"t{i}","content":"{}"}}"#,
684 "x".repeat(2000)
685 );
686 messages.push(msg("tool", &result));
687 }
688 messages.push(msg("assistant", "Here's what I found..."));
689
690 assert_eq!(messages.len(), 33);
692
693 let config = HistoryPrunerConfig {
694 enabled: true,
695 max_tokens: 2000, keep_recent: 4,
697 collapse_tool_results: true,
698 };
699
700 prune_history(&mut messages, &config);
701
702 for (i, m) in messages.iter().enumerate() {
704 if m.role == "tool" {
705 assert!(
706 i > 0 && messages[i - 1].role == "assistant",
707 "orphaned tool at index {i}: roles = {:?}",
708 messages.iter().map(|m| &m.role).collect::<Vec<_>>()
709 );
710 }
711 }
712 }
713
714 #[test]
715 fn prune_merges_consecutive_collapsed_assistant_messages() {
716 let mut messages = vec![
717 msg("system", "sys"),
718 msg(
719 "assistant",
720 r#"{"content":null,"tool_calls":[{"id":"t1","name":"shell","arguments":"{}"}]}"#,
721 ),
722 msg("tool", r#"{"tool_call_id":"t1","content":"first"}"#),
723 msg(
724 "assistant",
725 r#"{"content":null,"tool_calls":[{"id":"t2","name":"web","arguments":"{}"}]}"#,
726 ),
727 msg("tool", r#"{"tool_call_id":"t2","content":"second"}"#),
728 msg("user", "recent"),
729 msg("assistant", "done"),
730 ];
731
732 let config = HistoryPrunerConfig {
733 enabled: true,
734 max_tokens: 100_000,
735 keep_recent: 2,
736 collapse_tool_results: true,
737 };
738 let stats = prune_history(&mut messages, &config);
739
740 assert_eq!(stats.collapsed_pairs, 2);
741 assert_eq!(messages.len(), 4);
742 assert_eq!(messages[1].role, "assistant");
743 assert!(messages[1].content.contains("1 tool call(s)"));
744 assert_eq!(messages.iter().filter(|m| m.role == "assistant").count(), 2);
745 assert!(
746 messages
747 .windows(2)
748 .all(|pair| !(pair[0].role == "assistant" && pair[1].role == "assistant")),
749 "pruned roles should not contain adjacent assistants: {:?}",
750 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
751 );
752 }
753
754 #[test]
755 fn prune_preserves_straddled_tool_group_after_collapsed_summary() {
756 let mut messages = vec![
757 msg("system", "sys"),
758 msg(
759 "assistant",
760 r#"{"content":null,"tool_calls":[{"id":"old","name":"shell","arguments":"{}"}]}"#,
761 ),
762 msg("tool", r#"{"tool_call_id":"old","content":"old result"}"#),
763 msg(
764 "assistant",
765 r#"{"content":null,"tool_calls":[{"id":"live","name":"shell","arguments":"{}"}]}"#,
766 ),
767 msg("tool", r#"{"tool_call_id":"live","content":"live result"}"#),
768 msg("user", "follow up"),
769 ];
770
771 let config = HistoryPrunerConfig {
772 enabled: true,
773 max_tokens: 100_000,
774 keep_recent: 3,
775 collapse_tool_results: true,
776 };
777 let stats = prune_history(&mut messages, &config);
778
779 assert_eq!(stats.collapsed_pairs, 1);
780 assert!(
781 messages
782 .iter()
783 .any(|m| m.role == "assistant" && m.content.contains("\"id\":\"live\"")),
784 "protected assistant tool call should survive: {messages:?}"
785 );
786 assert!(
787 messages
788 .iter()
789 .any(|m| m.role == "tool" && m.content.contains("\"tool_call_id\":\"live\"")),
790 "matching protected tool result should survive: {messages:?}"
791 );
792 assert!(
793 messages
794 .iter()
795 .any(|m| m.role == "user" && m.content == "[context continues]"),
796 "Phase 5 should separate collapsed summary from live assistant"
797 );
798 assert!(
799 messages
800 .windows(2)
801 .all(|pair| !(pair[0].role == "assistant" && pair[1].role == "assistant")),
802 "pruned roles should not contain adjacent assistants: {:?}",
803 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
804 );
805 }
806
807 #[test]
808 fn prune_removes_dangling_tool_call_after_collapsed_summary() {
809 let mut messages = vec![
810 msg("system", "sys"),
811 msg(
812 "assistant",
813 "[Tool exchange: 1 tool call(s) — results collapsed]",
814 ),
815 msg(
816 "assistant",
817 r#"{"content":null,"tool_calls":[{"id":"dangling","name":"shell","arguments":"{}"}]}"#,
818 ),
819 msg("user", "follow up"),
820 ];
821
822 let config = HistoryPrunerConfig {
823 enabled: true,
824 max_tokens: 100_000,
825 keep_recent: 2,
826 collapse_tool_results: true,
827 };
828 let stats = prune_history(&mut messages, &config);
829
830 assert_eq!(stats.dropped_messages, 1);
831 assert!(
832 !messages
833 .iter()
834 .any(|m| m.content.contains("\"id\":\"dangling\"")),
835 "dangling assistant tool call should not survive: {messages:?}"
836 );
837 assert_eq!(
838 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(),
839 vec!["system", "assistant", "user"]
840 );
841 }
842
843 #[test]
844 fn prune_does_not_merge_json_tool_call_assistants_as_summaries() {
845 let mut messages = vec![
846 msg("system", "sys"),
847 msg(
848 "assistant",
849 r#"{"content":null,"tool_calls":[{"id":"live1","name":"shell","arguments":"{}"}]}"#,
850 ),
851 msg("tool", r#"{"tool_call_id":"live1","content":"first"}"#),
852 msg(
853 "assistant",
854 r#"{"content":null,"tool_calls":[{"id":"live2","name":"web","arguments":"{}"}]}"#,
855 ),
856 msg("tool", r#"{"tool_call_id":"live2","content":"second"}"#),
857 ];
858
859 let config = HistoryPrunerConfig {
860 enabled: true,
861 max_tokens: 100_000,
862 keep_recent: 4,
863 collapse_tool_results: true,
864 };
865 let stats = prune_history(&mut messages, &config);
866
867 assert_eq!(stats.collapsed_pairs, 0);
868 assert!(
869 messages
870 .iter()
871 .any(|m| m.content.contains("\"id\":\"live1\"")),
872 "first protected tool call should remain structured"
873 );
874 assert!(
875 messages
876 .iter()
877 .any(|m| m.content.contains("\"id\":\"live2\"")),
878 "second protected tool call should remain structured"
879 );
880 }
881
882 #[test]
883 fn prune_inserts_separator_when_tight_budget_leaves_protected_assistants() {
884 let mut messages = vec![
885 msg("system", "sys"),
886 msg("assistant", "protected assistant one"),
887 msg("assistant", "protected assistant two"),
888 ];
889
890 let config = HistoryPrunerConfig {
891 enabled: true,
892 max_tokens: 1,
893 keep_recent: 2,
894 collapse_tool_results: false,
895 };
896 let stats = prune_history(&mut messages, &config);
897
898 assert_eq!(stats.dropped_messages, 0);
899 assert_eq!(
900 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(),
901 vec!["system", "assistant", "user", "assistant"]
902 );
903 assert_eq!(messages[2].content, "[context continues]");
904 }
905
906 #[test]
911 fn orphan_tool_at_start_is_removed() {
912 let mut messages = vec![
915 msg("system", "sys"),
916 msg(
917 "tool",
918 r#"{"content":"file listing","tool_call_id":"toolu_01HiJXWbhx"}"#,
919 ),
920 msg(
921 "tool",
922 r#"{"content":"another result","tool_call_id":"toolu_01AQP25qUz"}"#,
923 ),
924 msg("user", "thanks"),
925 msg("assistant", "done"),
926 ];
927 let pruned = remove_orphaned_tool_messages(&mut messages);
928 assert_eq!(pruned.removed, 2);
929 assert_eq!(messages.len(), 3);
930 assert_eq!(messages[0].role, "system");
931 assert_eq!(messages[1].role, "user");
932 assert_eq!(messages[2].role, "assistant");
933 }
934
935 #[test]
936 fn valid_tool_pair_preserved() {
937 let assistant_with_tools = r#"{"content":"checking","tool_calls":[{"id":"toolu_abc123","name":"shell","arguments":"{}"}]}"#;
939 let tool_result = r#"{"content":"ok","tool_call_id":"toolu_abc123"}"#;
940 let mut messages = vec![
941 msg("system", "sys"),
942 msg("user", "do it"),
943 msg("assistant", assistant_with_tools),
944 msg("tool", tool_result),
945 msg("assistant", "done"),
946 ];
947 let pruned = remove_orphaned_tool_messages(&mut messages);
948 assert_eq!(pruned.removed, 0);
949 assert_eq!(messages.len(), 5);
950 }
951
952 #[test]
953 fn multi_tool_call_batch_preserved() {
954 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":"{}"}]}"#;
956 let mut messages = vec![
957 msg("system", "sys"),
958 msg("user", "do all 3"),
959 msg("assistant", assistant_content),
960 msg("tool", r#"{"content":"r1","tool_call_id":"toolu_aaa"}"#),
961 msg("tool", r#"{"content":"r2","tool_call_id":"toolu_bbb"}"#),
962 msg("tool", r#"{"content":"r3","tool_call_id":"toolu_ccc"}"#),
963 msg("assistant", "all done"),
964 ];
965 let pruned = remove_orphaned_tool_messages(&mut messages);
966 assert_eq!(pruned.removed, 0);
967 assert_eq!(messages.len(), 7);
968 }
969
970 #[test]
971 fn mismatched_tool_id_is_removed() {
972 let assistant_content = r#"{"content":"running","tool_calls":[{"id":"toolu_aaa","name":"shell","arguments":"{}"}]}"#;
974 let mut messages = vec![
975 msg("system", "sys"),
976 msg("user", "go"),
977 msg("assistant", assistant_content),
978 msg("tool", r#"{"content":"ok","tool_call_id":"toolu_aaa"}"#),
979 msg("tool", r#"{"content":"stale","tool_call_id":"toolu_GONE"}"#),
980 msg("assistant", "done"),
981 ];
982 let pruned = remove_orphaned_tool_messages(&mut messages);
983 assert_eq!(pruned.removed, 1);
984 assert_eq!(messages.len(), 5);
985 assert_eq!(messages[3].role, "tool");
987 assert!(messages[3].content.contains("toolu_aaa"));
988 }
989
990 #[test]
991 fn orphan_tool_in_middle_after_collapsed_pair() {
992 let mut messages = vec![
995 msg("system", "sys"),
996 msg("assistant", "[Tool result: truncated...]"), msg(
998 "tool",
999 r#"{"content":"leftover","tool_call_id":"toolu_OLD"}"#,
1000 ),
1001 msg("user", "next"),
1002 msg("assistant", "ok"),
1003 ];
1004 let pruned = remove_orphaned_tool_messages(&mut messages);
1005 assert_eq!(pruned.removed, 1);
1006 assert_eq!(messages.len(), 4);
1007 assert_eq!(messages[1].role, "assistant");
1008 assert_eq!(messages[2].role, "user");
1009 }
1010
1011 #[test]
1012 fn preamble_then_tool_calls_is_kept_intact() {
1013 let tool_calls_assistant = r#"{"content":null,"tool_calls":[{"id":"toolu_LIVE","name":"shell","arguments":"{}"}]}"#;
1018 let mut messages = vec![
1019 msg("system", "sys"),
1020 msg("user", "do something"),
1021 msg("assistant", "Let me check."),
1022 msg("assistant", tool_calls_assistant),
1023 msg("tool", r#"{"content":"ok","tool_call_id":"toolu_LIVE"}"#),
1024 msg("assistant", "Here are the results."),
1025 ];
1026 let before = messages.len();
1027 let pruned = remove_orphaned_tool_messages(&mut messages);
1028 assert_eq!(
1029 pruned.removed, 0,
1030 "preamble + dispatch + result is a healthy turn, not orphan poisoning"
1031 );
1032 assert_eq!(messages.len(), before);
1033 }
1034
1035 #[test]
1036 fn back_to_back_unresolved_tool_calls_strips_later_dispatch() {
1037 let first_dispatch = r#"{"content":null,"tool_calls":[{"id":"toolu_LOST","name":"shell","arguments":"{}"}]}"#;
1043 let second_dispatch = r#"{"content":null,"tool_calls":[{"id":"toolu_DEAD","name":"shell","arguments":"{}"}]}"#;
1044 let mut messages = vec![
1045 msg("system", "sys"),
1046 msg("user", "do something"),
1047 msg("assistant", first_dispatch),
1048 msg("assistant", second_dispatch),
1049 msg("tool", r#"{"content":"ok","tool_call_id":"toolu_DEAD"}"#),
1050 msg("assistant", "summary"),
1051 ];
1052 let pruned = remove_orphaned_tool_messages(&mut messages);
1053 assert_eq!(
1054 pruned.removed, 2,
1055 "second dispatch + its tool_result must be removed when prior dispatch is unresolved"
1056 );
1057 assert_eq!(messages.len(), 4);
1064 assert_eq!(messages[2].content, first_dispatch);
1065 assert_eq!(messages[3].content, "summary");
1066 }
1067
1068 #[test]
1069 fn tool_without_parseable_id_kept_if_assistant_has_tool_calls() {
1070 let assistant_content = r#"{"content":"running","tool_calls":[{"id":"toolu_x","name":"shell","arguments":"{}"}]}"#;
1073 let mut messages = vec![
1074 msg("system", "sys"),
1075 msg("user", "go"),
1076 msg("assistant", assistant_content),
1077 msg("tool", "plain text result without json"),
1078 msg("assistant", "done"),
1079 ];
1080 let pruned = remove_orphaned_tool_messages(&mut messages);
1081 assert_eq!(pruned.removed, 0);
1082 assert_eq!(messages.len(), 5);
1083 }
1084
1085 #[test]
1086 fn phase2_budget_respects_protected_tool_messages() {
1087 let tool_content = r#"{"tool_call_id":"toolu_recent","content":"result"}"#;
1091 let assistant_tool = r#"{"content":"calling","tool_calls":[{"id":"toolu_recent","name":"shell","arguments":"{}"}]}"#;
1092 let mut messages = vec![
1093 msg("system", "sys"),
1094 msg("user", "old question"),
1095 msg(
1096 "assistant",
1097 "old answer with lots of padding text to inflate token count significantly beyond budget",
1098 ),
1099 msg("user", "another old question"),
1100 msg("assistant", assistant_tool), msg("tool", tool_content), msg("user", "recent question"), msg("assistant", "recent answer"), ];
1105 let config = HistoryPrunerConfig {
1107 enabled: true,
1108 max_tokens: 50,
1109 keep_recent: 3,
1110 collapse_tool_results: true,
1111 };
1112 prune_history(&mut messages, &config);
1113 assert!(
1115 messages.iter().any(|m| m.content.contains("toolu_recent")),
1116 "Protected tool message was dropped by Phase 2 budget enforcement"
1117 );
1118 }
1119
1120 #[test]
1127 fn orphan_tool_not_fooled_by_id_in_summary_text() {
1128 let summary = "[CONTEXT SUMMARY \u{2014} 4 messages compressed]\n\
1129 Earlier turns invoked shell with tool_calls id toolu_01Orphan \
1130 and returned ok.";
1131 let mut messages = vec![
1132 msg("system", "sys"),
1133 msg("assistant", summary),
1134 msg(
1135 "tool",
1136 r#"{"tool_call_id":"toolu_01Orphan","content":"stale"}"#,
1137 ),
1138 msg("user", "new question"),
1139 ];
1140 let pruned = remove_orphaned_tool_messages(&mut messages);
1141 assert_eq!(
1142 pruned.removed, 1,
1143 "orphan must be removed even if its id is mentioned in summary text"
1144 );
1145 assert!(!messages.iter().any(|m| m.role == "tool"));
1146 }
1147
1148 #[test]
1152 fn orphan_tool_from_trimmed_channel_history() {
1153 let tool_result =
1157 r#"{"content":"search results","tool_call_id":"chatcmpl-tool-92a12a15c14f3b36"}"#;
1158 let mut messages = vec![
1159 msg("system", "You are a helpful assistant"),
1160 msg("tool", tool_result),
1161 msg("assistant", "Here are the search results"),
1162 msg("user", "Thanks, now summarize them"),
1163 ];
1164 let pruned = remove_orphaned_tool_messages(&mut messages);
1165 assert_eq!(pruned.removed, 1, "orphaned tool message should be removed");
1166 assert_eq!(messages.len(), 3);
1167 assert_eq!(messages[0].role, "system");
1168 assert_eq!(messages[1].role, "assistant");
1169 assert_eq!(messages[2].role, "user");
1170 }
1171
1172 #[test]
1184 fn prune_does_not_evict_protected_tool_when_group_straddles_keep_recent() {
1185 let mut messages = vec![
1186 msg("system", "sys"),
1187 msg("user", "query"),
1188 msg(
1189 "assistant",
1190 r#"{"content":null,"tool_calls":[
1191 {"id":"t1","name":"shell","arguments":"{}"},
1192 {"id":"t2","name":"web","arguments":"{}"}
1193 ]}"#,
1194 ),
1195 msg("tool", r#"{"tool_call_id":"t1","content":"first"}"#),
1196 msg(
1197 "tool",
1198 r#"{"tool_call_id":"t2","content":"PROTECTED second"}"#,
1199 ),
1200 msg("user", "follow up"),
1201 msg("assistant", "final"),
1202 ];
1203
1204 let config = HistoryPrunerConfig {
1205 enabled: true,
1206 max_tokens: 100_000,
1210 keep_recent: 3,
1211 collapse_tool_results: true,
1212 };
1213
1214 let stats = prune_history(&mut messages, &config);
1215
1216 assert_eq!(stats.messages_before, 7);
1217 assert!(
1218 messages
1219 .iter()
1220 .any(|m| m.content.contains("PROTECTED second")),
1221 "a tool message protected by keep_recent must survive; \
1222 got roles {:?}",
1223 messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
1224 );
1225 }
1226}