Skip to main content

zeroclaw_runtime/agent/
history_pruner.rs

1use zeroclaw_api::model_provider::ChatMessage;
2
3pub use zeroclaw_config::scattered_types::HistoryPrunerConfig;
4
5// ---------------------------------------------------------------------------
6// Stats
7// ---------------------------------------------------------------------------
8
9#[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
17// ---------------------------------------------------------------------------
18// Token estimation
19// ---------------------------------------------------------------------------
20
21fn 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    // Apply 1.2x safety margin consistent with context_compressor to avoid
27    // underestimation that leads to context_length_exceeded errors.
28    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
29    {
30        (raw as f64 * 1.2) as usize
31    }
32}
33
34// ---------------------------------------------------------------------------
35// Protected-index helpers
36// ---------------------------------------------------------------------------
37
38fn 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// ---------------------------------------------------------------------------
54// Orphaned tool-message sanitiser
55// ---------------------------------------------------------------------------
56
57/// Outcome of a single `remove_orphaned_tool_messages` pass. The caller
58/// is responsible for logging — that's where the agent/channel/session
59/// context lives.
60#[derive(Debug, Default, Clone)]
61pub struct PrunedOrphans {
62    /// Total tool / assistant messages removed across both passes.
63    pub removed: usize,
64    /// `tool_call_id`s that lost their pairing.
65    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
97/// Remove `tool`-role messages whose `tool_call_id` has no matching
98/// `tool_use` / `tool_calls` entry in a preceding assistant message.
99///
100/// After any history truncation (drain, remove, prune) the first surviving
101/// message(s) may be `tool` results whose assistant request was trimmed away.
102/// The Anthropic API (and others) reject these with a 400 error.
103pub fn remove_orphaned_tool_messages(messages: &mut Vec<ChatMessage>) -> PrunedOrphans {
104    let mut outcome = PrunedOrphans::default();
105    // Pass 1: Remove assistant(tool_calls) + their tool_results when the
106    // assistant is preceded by another assistant. Normalization would merge
107    // them, destroying structured tool_use blocks and orphaning the results.
108    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    // Pass 2: Remove remaining orphan tool messages whose tool_call_id
144    // is not in the preceding assistant's structured tool_calls array.
145    // A substring match on the assistant's *text* is NOT sufficient —
146    // compaction summaries are instructed to preserve identifiers, so an
147    // id can appear in prose without an actual tool_use block backing it.
148    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
184/// Try to extract a `tool_call_id` from a tool-role message's JSON content.
185///
186/// Tool messages are stored as JSON like:
187/// `{"content": "...", "tool_call_id": "toolu_01Abc..."}`
188fn 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
196/// Extract the list of structured tool-call IDs an assistant message
197/// is claiming to have invoked, if any. Returns `None` when the content
198/// does not parse as a JSON object with a `tool_calls` array — meaning the
199/// assistant has no native tool_use blocks backing any tool_results.
200fn 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
210// ---------------------------------------------------------------------------
211// Public entry point
212// ---------------------------------------------------------------------------
213
214pub 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    // Phase 1 – collapse assistant+tool groups atomically.
228    // An assistant message followed by one or more consecutive tool messages
229    // forms an atomic group (tool_use + tool_result pairing). Collapsing only
230    // part of the group would orphan tool_use blocks, causing API 400 errors
231    // from model_providers that enforce pairing (e.g., Anthropic).
232    //
233    // The group is collapsed only when *every* tool in it is unprotected —
234    // the same all-or-nothing rule Phase 2 uses. If `keep_recent` protects
235    // any tool in the group we skip the whole group. Partial collapse would
236    // leave a protected tool behind whose parent assistant has been
237    // rewritten to a summary with no "tool_calls" marker, which Phase 3's
238    // orphan sweep then evicts — silently violating `keep_recent`. See
239    // #5823.
240    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                // Count consecutive tool messages following this assistant
246                // and remember whether any of them is protected.
247                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                    // Protected tool inside the group → skip the whole
272                    // group intact so Phase 3's orphan sweep has no
273                    // pretext to remove those tools.
274                    i += 1 + tool_count;
275                    continue;
276                }
277            }
278            i += 1;
279        }
280    }
281
282    // Phase 2 – budget enforcement: drop messages to fit token budget.
283    // Tool groups (assistant + consecutive tool messages) are dropped
284    // atomically to preserve tool_use/tool_result pairing.
285    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                // Count following tool messages — drop as atomic group,
297                // but skip if any tool in the group is protected.
298                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                    // Group has protected tools — skip past it
317                    i += 1 + tool_count;
318                    continue;
319                }
320            }
321            // Non-tool-group message — safe to drop individually
322            messages.remove(i);
323            dropped_messages += 1;
324            dropped_any = true;
325            break;
326        }
327        if !dropped_any {
328            break;
329        }
330    }
331
332    // Phase 3 – merge consecutive synthetic tool-exchange summaries. GLM/Z.AI
333    // reject adjacent assistant messages, but these summaries are safe to
334    // combine because they are both pruner-generated placeholders.
335    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    // Phase 4 – remove orphaned tool messages left behind by phases 1-3.
351    dropped_messages += remove_orphaned_tool_messages(messages).removed;
352
353    // Phase 5 – separate any remaining adjacent assistant messages. These can
354    // happen when a protected assistant(tool_calls) group follows a collapsed
355    // summary. Insert a tiny user boundary rather than dropping protected data.
356    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        // assistant(tool_calls) + 2 tool messages → 1 summary assistant
532        assert_eq!(messages.len(), 4); // sys, summary, user, assistant
533        assert!(messages[1].content.contains("2 tool call(s)"));
534        // No tool messages remain
535        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, // very low — forces drops
552            keep_recent: 2,
553            collapse_tool_results: false, // skip collapse, go straight to drop
554        };
555        let stats = prune_history(&mut messages, &config);
556        assert!(stats.dropped_messages >= 3); // assistant + 2 tools dropped together
557        // No orphaned tool messages
558        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        // Simulate a conversation with multiple tool groups
571        let filler = "y".repeat(500);
572        let mut messages = vec![
573            msg("system", "sys"),
574            msg("user", "q1"),
575            msg("assistant", &filler), // tool group 1
576            msg("tool", &filler),
577            msg("user", "q2"),
578            msg("assistant", &filler), // tool group 2
579            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        // Verify invariant: no tool message without a preceding assistant
592        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, // protects last 3: tool call, tool result, recent
624            collapse_tool_results: true,
625        };
626        let stats = prune_history(&mut messages, &config);
627        // Protected tool group should not be collapsed
628        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        // Simulate 15 tool iterations with realistic content sizes
635        let mut messages = vec![msg("system", "You are helpful.")];
636        messages.push(msg("user", "Research this topic thoroughly"));
637
638        // 15 tool iterations — each adds assistant(tool_calls) + tool(result)
639        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            // Realistic tool result size (~2K chars each)
645            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        // 33 messages total: system + user + 15*(assistant+tool) + final assistant
654        assert_eq!(messages.len(), 33);
655
656        let config = HistoryPrunerConfig {
657            enabled: true,
658            max_tokens: 2000, // Forces pruning of older iterations
659            keep_recent: 4,
660            collapse_tool_results: true,
661        };
662
663        prune_history(&mut messages, &config);
664
665        // Invariant: no orphaned tool messages after pruning
666        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    // -----------------------------------------------------------------------
870    // remove_orphaned_tool_messages tests
871    // -----------------------------------------------------------------------
872
873    #[test]
874    fn orphan_tool_at_start_is_removed() {
875        // Simulates the exact bug: session drain removes the assistant
876        // message but leaves its tool results at the start.
877        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        // A properly paired assistant+tool sequence must survive.
901        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        // An assistant with 3 tool_calls followed by 3 tool results.
918        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        // Tool result references a tool_call_id not in the assistant message.
936        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        // The valid tool result stays, the orphan is gone.
949        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        // Phase 1 collapsed an assistant+tool pair into a summary, but
956        // a subsequent tool message referenced the original tool_call_id.
957        let mut messages = vec![
958            msg("system", "sys"),
959            msg("assistant", "[Tool result: truncated...]"), // collapsed
960            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        // When poisoned turn removal leaves an assistant(text) followed by
977        // assistant(tool_calls), the second assistant and its tool_results
978        // must be removed — normalization would merge them, destroying the
979        // structured tool_use blocks and orphaning the results at the API.
980        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        // Conservative: if we can't parse the tool_call_id, keep the
1012        // message as long as the preceding assistant has tool_calls.
1013        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        // Phase 2 should not drop tool messages that fall within the
1029        // keep_recent protection window, even when the assistant that
1030        // starts the group is outside the window.
1031        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),  // outside keep_recent
1042            msg("tool", tool_content),         // inside keep_recent (3rd from end)
1043            msg("user", "recent question"),    // inside keep_recent (2nd from end)
1044            msg("assistant", "recent answer"), // inside keep_recent (1st from end)
1045        ];
1046        // Budget tight enough that Phase 2 fires, keep_recent=3 protects last 3
1047        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        // The protected tool message must survive
1055        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    /// Regression test for issue #5813: a compaction summary preserves
1062    /// identifiers by design (UUIDs, tokens, tool_call_ids). That means the
1063    /// summary text may contain the tool_call_id of a tool_result whose
1064    /// tool_use was dropped. The orphan detector must not be fooled by a
1065    /// substring match on the summary — it must confirm the id appears in
1066    /// a structured tool_calls array.
1067    #[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    /// Regression test for issue #5743: MiniMax rejects orphaned tool-role
1090    /// messages whose assistant (with `tool_calls`) was trimmed by the
1091    /// channel orchestrator's proactive history trimming.
1092    #[test]
1093    fn orphan_tool_from_trimmed_channel_history() {
1094        // Simulates the scenario: channel history was trimmed and the
1095        // assistant message containing tool_calls was dropped, leaving
1096        // orphaned tool results with MiniMax-style IDs.
1097        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    /// Regression for #5823:
1114    ///
1115    /// When `keep_recent` protects the *tail* of a multi-tool group but not
1116    /// the preceding assistant, Phase 1 used to collapse the unprotected
1117    /// tools and rewrite the assistant to a summary that no longer contained
1118    /// `"tool_calls"`. Phase 3's orphan sweep then classified the still-live
1119    /// protected tool as an orphan (because the new summary does not contain
1120    /// `"tool_calls"`) and removed it — silently violating `keep_recent`.
1121    ///
1122    /// After the fix Phase 1 treats the group as atomic: if any tool in it
1123    /// is protected, the entire group is left intact.
1124    #[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            // Budget is well above the estimated token cost so Phase 2 does
1148            // not drop anything; this test isolates the Phase 1 / Phase 3
1149            // interaction.
1150            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}