1use crate::llm::provider::Message;
2use hashbrown::{HashMap, HashSet};
3use std::sync::Arc;
4use std::time::Duration;
5use vtcode_macros::StringNewtype;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, StringNewtype)]
13pub struct ToolCallId(String);
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutputStatus {
18 Success,
19 Failed,
20 Canceled,
21 Timeout,
22}
23
24impl OutputStatus {
25 pub fn as_str(&self) -> &'static str {
27 match self {
28 Self::Success => "success",
29 Self::Failed => "failed",
30 Self::Canceled => "canceled",
31 Self::Timeout => "timeout",
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
38pub enum PairableHistoryItem {
39 ToolCall { call_id: ToolCallId, tool_name: String },
41 ToolOutput { call_id: ToolCallId, status: OutputStatus },
43}
44
45#[derive(Debug, Clone)]
47pub struct MissingOutput {
48 pub call_id: ToolCallId,
49 pub tool_name: String,
50}
51
52#[derive(Debug, Default, Clone)]
54pub struct HistoryValidationReport {
55 pub missing_outputs: Vec<MissingOutput>,
57 pub orphan_outputs: Vec<ToolCallId>,
59}
60
61impl HistoryValidationReport {
62 pub fn is_valid(&self) -> bool {
64 self.missing_outputs.is_empty() && self.orphan_outputs.is_empty()
65 }
66
67 pub fn summary(&self) -> String {
69 if self.is_valid() {
70 "History invariants are valid".to_string()
71 } else {
72 format!("{} missing outputs, {} orphan outputs", self.missing_outputs.len(), self.orphan_outputs.len())
73 }
74 }
75
76 pub fn exclude_pending(&mut self, is_pending: impl Fn(&str) -> bool) {
83 self.missing_outputs.retain(|m| !is_pending(m.call_id.as_str()));
84 }
85}
86
87#[cfg(test)]
88#[inline]
89pub(crate) fn record_turn_duration(
90 turn_durations: &mut Vec<u128>,
91 turn_total_ms: &mut u128,
92 turn_max_ms: &mut u128,
93 turn_count: &mut usize,
94 recorded: &mut bool,
95 start: &std::time::Instant,
96) {
97 if !*recorded {
98 let duration_ms = start.elapsed().as_millis();
99 turn_durations.push(duration_ms);
100 *turn_total_ms += duration_ms;
101 if duration_ms > *turn_max_ms {
102 *turn_max_ms = duration_ms;
103 }
104 *turn_count += 1;
105 *recorded = true;
106 }
107}
108
109pub struct ApiFailureTracker {
111 pub consecutive_failures: u32,
112 pub last_failure: Option<std::time::Instant>,
113}
114
115impl Default for ApiFailureTracker {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl ApiFailureTracker {
122 pub fn new() -> Self {
123 Self { consecutive_failures: 0, last_failure: None }
124 }
125
126 pub fn record_failure(&mut self) {
127 self.consecutive_failures += 1;
128 self.last_failure = Some(std::time::Instant::now());
129 }
130
131 pub fn reset(&mut self) {
132 self.consecutive_failures = 0;
133 self.last_failure = None;
134 }
135
136 pub fn should_circuit_break(&self) -> bool {
137 self.consecutive_failures >= 3
138 }
139
140 pub fn backoff_duration(&self) -> Duration {
141 let base_ms = 1000;
142 let max_ms = 30000;
143 let backoff_ms = base_ms * 2_u64.pow(self.consecutive_failures.saturating_sub(1));
144 Duration::from_millis(backoff_ms.min(max_ms))
145 }
146}
147
148pub fn summarize_list(items: &[String]) -> String {
149 const MAX_ITEMS: usize = 5;
150 if items.is_empty() {
151 return "none".into();
152 }
153 let shown: Vec<&str> = items.iter().take(MAX_ITEMS).map(|s| s.as_str()).collect();
154 if items.len() > MAX_ITEMS {
155 format!("{} [+{} more]", shown.join(", "), items.len() - MAX_ITEMS)
156 } else {
157 shown.join(", ")
158 }
159}
160
161pub fn validate_history_invariants(messages: &[Message]) -> HistoryValidationReport {
167 let mut call_map: HashMap<String, String> = HashMap::new();
168 let mut output_ids: HashSet<String> = HashSet::new();
169
170 for msg in messages {
172 if let Some(tool_calls) = &msg.tool_calls {
174 for tool_call in tool_calls {
175 call_map.insert(tool_call.id.clone(), msg.role.to_string());
176 }
177 }
178
179 if let Some(tool_call_id) = &msg.tool_call_id {
181 output_ids.insert(tool_call_id.clone());
182 }
183 }
184
185 let missing_outputs: Vec<_> = call_map
187 .keys()
188 .filter(|call_id| !output_ids.contains(*call_id))
189 .map(|call_id| MissingOutput {
190 call_id: ToolCallId::new(call_id.clone()),
191 tool_name: "unknown".to_string(),
192 })
193 .collect();
194
195 let orphan_outputs: Vec<_> = output_ids
197 .iter()
198 .filter(|output_id| !call_map.contains_key(*output_id))
199 .map(|output_id| ToolCallId::new(output_id.clone()))
200 .collect();
201
202 HistoryValidationReport { missing_outputs, orphan_outputs }
203}
204
205const REQUEST_HISTORY_CANCELLATION_RESULT: &str =
206 "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol.";
207
208#[derive(Debug)]
209struct RequestToolBatch {
210 assistant_index: usize,
211 matched_calls: Vec<bool>,
212 result_indices: Vec<usize>,
213}
214
215#[derive(Debug, Default)]
216struct RequestHistoryAnalysis {
217 batches: Vec<RequestToolBatch>,
218 batch_by_assistant_index: HashMap<usize, usize>,
219 result_batch_by_index: HashMap<usize, usize>,
220 invalid_result_indices: HashSet<usize>,
221}
222
223impl RequestHistoryAnalysis {
224 fn needs_repair(&self) -> bool {
225 if !self.invalid_result_indices.is_empty() {
226 return true;
227 }
228
229 self.batches.iter().any(|batch| {
230 batch.matched_calls.iter().any(|matched| !matched)
231 || batch
232 .result_indices
233 .iter()
234 .enumerate()
235 .any(|(offset, &result_index)| result_index != batch.assistant_index + offset + 1)
236 })
237 }
238}
239
240fn analyze_request_history(messages: &[Message]) -> RequestHistoryAnalysis {
241 let mut analysis = RequestHistoryAnalysis::default();
242 let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
243
244 for (message_index, message) in messages.iter().enumerate() {
245 if message.role != crate::llm::provider::MessageRole::Assistant {
246 continue;
247 }
248
249 let Some(tool_calls) = message.tool_calls.as_ref().filter(|calls| !calls.is_empty()) else {
250 continue;
251 };
252
253 let batch_index = analysis.batches.len();
254 let matched_calls = vec![false; tool_calls.len()];
255 analysis.batch_by_assistant_index.insert(message_index, batch_index);
256 analysis.batches.push(RequestToolBatch {
257 assistant_index: message_index,
258 matched_calls,
259 result_indices: Vec::new(),
260 });
261
262 for (call_index, call) in tool_calls.iter().enumerate() {
263 calls_by_id.entry(call.id.as_str()).or_default().push((batch_index, call_index));
264 }
265 }
266
267 for (message_index, message) in messages.iter().enumerate() {
268 let has_tool_call_id = message.tool_call_id.is_some();
269 let is_tool_result = message.role == crate::llm::provider::MessageRole::Tool;
270 if !is_tool_result && !has_tool_call_id {
271 continue;
272 }
273
274 let Some(tool_call_id) = message.tool_call_id.as_deref().filter(|id| !id.is_empty()) else {
275 analysis.invalid_result_indices.insert(message_index);
276 continue;
277 };
278
279 if !is_tool_result {
280 analysis.invalid_result_indices.insert(message_index);
281 continue;
282 }
283
284 let Some(candidates) = calls_by_id.get_mut(tool_call_id) else {
285 analysis.invalid_result_indices.insert(message_index);
286 continue;
287 };
288
289 let Some(&(batch_index, call_index)) = candidates.iter().rev().find(|&&(batch_index, call_index)| {
290 analysis.batches[batch_index].assistant_index < message_index
291 && !analysis.batches[batch_index].matched_calls[call_index]
292 }) else {
293 analysis.invalid_result_indices.insert(message_index);
296 continue;
297 };
298
299 analysis.batches[batch_index].matched_calls[call_index] = true;
300 analysis.batches[batch_index].result_indices.push(message_index);
301 analysis.result_batch_by_index.insert(message_index, batch_index);
302 }
303
304 analysis
305}
306
307pub fn request_history_needs_normalization(messages: &[Message]) -> bool {
312 analyze_request_history(messages).needs_repair()
313}
314
315pub fn normalize_history_for_request_shared(messages: Arc<Vec<Message>>) -> Arc<Vec<Message>> {
324 let analysis = analyze_request_history(&messages);
325 if !analysis.needs_repair() {
326 return messages;
327 }
328
329 let mut normalized = Vec::with_capacity(messages.len());
330 for (message_index, message) in messages.iter().enumerate() {
331 if analysis.invalid_result_indices.contains(&message_index)
332 || analysis.result_batch_by_index.contains_key(&message_index)
333 {
334 continue;
335 }
336
337 let Some(&batch_index) = analysis.batch_by_assistant_index.get(&message_index) else {
338 normalized.push(message.clone());
339 continue;
340 };
341
342 let batch = &analysis.batches[batch_index];
343 normalized.push(message.clone());
344 for &result_index in &batch.result_indices {
345 normalized.push(messages[result_index].clone());
346 }
347 for (call, matched) in message.tool_calls.iter().flatten().zip(&batch.matched_calls) {
348 if !matched {
349 normalized
350 .push(Message::tool_response(call.id.clone(), REQUEST_HISTORY_CANCELLATION_RESULT.to_owned()));
351 }
352 }
353 }
354
355 Arc::new(normalized)
356}
357
358pub fn normalize_history_for_request(messages: &[Message]) -> Vec<Message> {
363 Arc::unwrap_or_clone(normalize_history_for_request_shared(Arc::new(messages.to_vec())))
364}
365
366const CLEARED_TOOL_RESULT_NOTE: &str = "Older tool result cleared to bound context growth. Full output remains in session logs; re-run the tool if raw bytes are needed.";
370
371pub fn clear_old_tool_results(
392 messages: &[Message],
393 trigger_tokens: u64,
394 keep_tool_uses: u32,
395 clear_at_least_tokens: u64,
396 clear_tool_inputs: bool,
397) -> Vec<Message> {
398 if trigger_tokens == 0 || messages.is_empty() {
399 return messages.to_vec();
400 }
401
402 let estimated_tokens: u64 = messages
403 .iter()
404 .map(|message| message.estimate_tokens() as u64)
405 .fold(0u64, u64::saturating_add);
406 if estimated_tokens < trigger_tokens {
407 return messages.to_vec();
408 }
409
410 let tool_indices: Vec<usize> = messages
411 .iter()
412 .enumerate()
413 .filter(|(_, message)| message.role == crate::llm::provider::MessageRole::Tool)
414 .map(|(index, _)| index)
415 .collect();
416 let keep = keep_tool_uses as usize;
417 if tool_indices.len() <= keep {
418 return messages.to_vec();
419 }
420
421 let mut cleared_tokens = 0u64;
422 let mut cleared_call_ids: HashSet<String> = HashSet::new();
423 let mut out = messages.to_vec();
427 for &tool_index in &tool_indices[..tool_indices.len() - keep] {
428 let original = &messages[tool_index];
429 let original_tokens = original.estimate_tokens() as u64;
430 let stub = build_cleared_tool_result_stub(original);
431 let stub_tokens = stub.estimate_tokens() as u64;
432 if let Some(call_id) = original.tool_call_id.clone() {
433 cleared_call_ids.insert(call_id);
434 }
435 out[tool_index] = stub;
436 cleared_tokens = cleared_tokens.saturating_add(original_tokens.saturating_sub(stub_tokens));
437 }
438
439 if cleared_tokens < clear_at_least_tokens {
443 tracing::debug!(
444 cleared_tokens,
445 clear_at_least_tokens,
446 "tool-result clearing reclaimed less than the configured floor"
447 );
448 }
449
450 if clear_tool_inputs && !cleared_call_ids.is_empty() {
451 for message in &mut out {
452 if message.role != crate::llm::provider::MessageRole::Assistant {
453 continue;
454 }
455 let Some(tool_calls) = message.tool_calls.as_mut() else {
456 continue;
457 };
458 for call in tool_calls.iter_mut() {
459 if cleared_call_ids.contains(&call.id) {
460 if let Some(function) = call.function.as_mut() {
461 function.arguments = CLEARED_TOOL_INPUT_PLACEHOLDER.to_string();
464 }
465 if call.text.is_some() {
468 call.text = Some(CLEARED_TOOL_INPUT_PLACEHOLDER.to_string());
469 }
470 call.thought_signature = None;
471 }
472 }
473 }
474 }
475
476 out
477}
478
479const CLEARED_TOOL_INPUT_PLACEHOLDER: &str = "{\"cleared\":\"tool_input\"}";
481
482pub fn should_apply_local_tool_result_clearing(
488 provider_name: &str,
489 context_edits: bool,
490 clearing_enabled: bool,
491) -> bool {
492 clearing_enabled && !(provider_name.eq_ignore_ascii_case("anthropic") && context_edits)
493}
494
495fn build_cleared_tool_result_stub(original: &Message) -> Message {
496 let mut stub = original.clone();
497 let body = serde_json::json!({
499 "cleared": "tool_result",
500 "reason": "tool_result_clearing",
501 "note": CLEARED_TOOL_RESULT_NOTE,
502 "tool": original.origin_tool.as_deref().unwrap_or("tool"),
503 "tool_call_id": original.tool_call_id.as_deref().unwrap_or(""),
504 })
505 .to_string();
506 stub.content = crate::llm::provider::MessageContent::Text(body);
507 stub
508}
509
510pub fn safe_history_split_point(messages: &[Message], conversation_len: usize, preferred_split_at: usize) -> usize {
512 if preferred_split_at == 0 || preferred_split_at >= conversation_len {
513 return preferred_split_at;
514 }
515
516 let mut call_indices: HashMap<&str, usize> = HashMap::new();
517 for (i, msg) in messages.iter().enumerate() {
518 if let Some(tool_calls) = &msg.tool_calls {
519 for call in tool_calls {
520 call_indices.insert(&call.id, i);
521 }
522 }
523 }
524
525 let mut safe_split_at = preferred_split_at;
526 loop {
527 if safe_split_at == 0 {
528 break;
529 }
530
531 let has_orphan = ((safe_split_at + 1)..messages.len()).any(|i| {
532 messages
533 .get(i)
534 .and_then(|msg| msg.tool_call_id.as_ref())
535 .and_then(|id| call_indices.get(id.as_str()))
536 .is_some_and(|&call_idx| call_idx <= safe_split_at)
537 });
538
539 if !has_orphan {
540 break;
541 }
542
543 safe_split_at -= 1;
544 }
545
546 safe_split_at
547}
548
549pub fn ensure_call_outputs_present(messages: &mut Vec<Message>) {
551 let report = validate_history_invariants(messages);
552
553 for missing in report.missing_outputs.iter().rev() {
555 let synthetic_message = Message::tool_response(
556 missing.call_id.as_str().to_string(),
557 "canceled: Tool execution was interrupted. This synthetic output was created \
558 during history normalization to maintain conversation invariants."
559 .to_string(),
560 );
561
562 tracing::warn!("Creating synthetic output for call {} due to missing execution result", missing.call_id);
563
564 let insert_pos = messages
566 .iter()
567 .position(|msg| {
568 msg.tool_calls
569 .as_ref()
570 .is_some_and(|calls| calls.iter().any(|call| call.id == missing.call_id.as_str()))
571 })
572 .map(|pos| pos + 1);
573
574 if let Some(pos) = insert_pos {
575 messages.insert(pos, synthetic_message);
576 } else {
577 messages.push(synthetic_message);
579 }
580 }
581}
582
583pub fn remove_orphan_outputs(messages: &mut Vec<Message>) {
585 let report = validate_history_invariants(messages);
586
587 if report.orphan_outputs.is_empty() {
588 return;
589 }
590
591 let orphan_ids: HashSet<String> = report.orphan_outputs.iter().map(|id| id.as_str().to_string()).collect();
592
593 let initial_len = messages.len();
594
595 messages.retain(|msg| {
599 if let Some(tool_call_id) = msg.tool_call_id.as_ref()
600 && orphan_ids.contains(tool_call_id)
601 {
602 tracing::warn!("Removing orphan output for call {}", tool_call_id);
603 return false;
604 }
605 true
606 });
607
608 if messages.len() != initial_len {
609 tracing::info!("Removed {} orphan outputs", initial_len - messages.len());
610 }
611}
612
613pub fn normalize_history(messages: &mut Vec<Message>) {
615 ensure_call_outputs_present(messages);
616 remove_orphan_outputs(messages);
617
618 let report = validate_history_invariants(messages);
620 if !report.is_valid() {
621 tracing::warn!("History validation: {}", report.summary());
622 } else {
623 tracing::debug!("History normalized successfully");
624 }
625}
626
627pub fn recover_history_from_crash(messages: &mut Vec<Message>) {
629 let report = validate_history_invariants(messages);
630
631 if !report.missing_outputs.is_empty() {
632 tracing::warn!("Found {} missing outputs during recovery", report.missing_outputs.len());
633 ensure_call_outputs_present(messages);
634 }
635
636 if !report.orphan_outputs.is_empty() {
637 tracing::warn!("Found {} orphan outputs during recovery", report.orphan_outputs.len());
638 remove_orphan_outputs(messages);
639 }
640
641 if report.is_valid() {
642 tracing::debug!("History invariants are valid");
643 }
644}
645
646#[cfg(test)]
651mod tests {
652 use super::*;
653 use crate::llm::provider::{Message, MessageRole};
654 fn make_tool_call(call_id: &str, tool_name: &str) -> Message {
656 Message::assistant_with_tools(
657 "".to_string(),
658 vec![crate::llm::provider::ToolCall::function(
659 call_id.to_string(),
660 tool_name.to_string(),
661 "{}".to_string(),
662 )],
663 )
664 }
665
666 fn make_tool_response(call_id: &str, content: &str) -> Message {
667 Message::tool_response(call_id.to_string(), content.to_string())
668 }
669
670 #[test]
672 fn test_validate_history_valid_matched_pairs() {
673 let mut messages = vec![
674 make_tool_call("call_1", "list_files"),
675 make_tool_response("call_1", "file1.rs\nfile2.rs"),
676 ];
677
678 let report = validate_history_invariants(&messages);
679 assert!(report.is_valid(), "Valid paired call/output should pass");
680 assert!(report.missing_outputs.is_empty());
681 assert!(report.orphan_outputs.is_empty());
682
683 normalize_history(&mut messages);
685 assert_eq!(messages.len(), 2);
686 }
687
688 #[test]
690 fn test_validate_history_missing_output() {
691 let messages = vec![make_tool_call("call_1", "list_files")];
692
693 let report = validate_history_invariants(&messages);
694 assert!(!report.is_valid());
695 assert_eq!(report.missing_outputs.len(), 1);
696 assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_1");
697 assert!(report.orphan_outputs.is_empty());
698 }
699
700 #[test]
702 fn test_validate_history_orphan_output() {
703 let messages = vec![make_tool_response("orphan_call", "Some result")];
704
705 let report = validate_history_invariants(&messages);
706 assert!(!report.is_valid());
707 assert!(report.missing_outputs.is_empty());
708 assert_eq!(report.orphan_outputs.len(), 1);
709 assert_eq!(report.orphan_outputs[0].as_str(), "orphan_call");
710 }
711
712 #[test]
714 fn test_ensure_call_outputs_present() {
715 let mut messages = vec![make_tool_call("call_1", "list_files")];
716 let initial_len = messages.len();
717
718 ensure_call_outputs_present(&mut messages);
719
720 assert_eq!(messages.len(), initial_len + 1);
721 let last_msg = &messages[initial_len];
722 assert_eq!(last_msg.tool_call_id, Some("call_1".to_string()));
723 assert!(last_msg.content.as_text().contains("canceled"));
724
725 let report = validate_history_invariants(&messages);
726 assert!(report.is_valid());
727 }
728
729 #[test]
731 fn test_remove_orphan_outputs() {
732 let mut messages = vec![
733 make_tool_call("call_1", "list_files"),
734 make_tool_response("call_1", "valid result"),
735 make_tool_response("orphan_call", "orphan result"),
736 ];
737
738 let initial_len = messages.len();
739 remove_orphan_outputs(&mut messages);
740
741 assert_eq!(messages.len(), initial_len - 1);
742 assert!(
743 messages
744 .iter()
745 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
746 );
747 assert!(
748 !messages
749 .iter()
750 .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan_call") })
751 );
752
753 let report = validate_history_invariants(&messages);
754 assert!(report.is_valid());
755 }
756
757 #[test]
759 fn test_normalize_combined_fixes() {
760 let mut messages = vec![
761 make_tool_call("call_1", "read_file"),
762 make_tool_call("call_2", "write_file"),
763 make_tool_response("call_2", "written"),
764 make_tool_response("orphan", "orphan result"),
765 ];
766
767 normalize_history(&mut messages);
768
769 let report = validate_history_invariants(&messages);
770 assert!(report.is_valid());
771 assert!(
772 messages
773 .iter()
774 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
775 );
776 assert!(
777 !messages
778 .iter()
779 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan"))
780 );
781 }
782
783 #[test]
784 fn request_normalization_shared_reuses_clean_history_arc() {
785 let messages = Arc::new(vec![Message::user("clean history".to_string())]);
786
787 let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
788
789 assert!(Arc::ptr_eq(&messages, &normalized));
790 }
791
792 #[test]
793 fn request_normalization_reused_ids_match_causally_preceding_batches() {
794 let messages = Arc::new(vec![
795 make_tool_response("reused", "early result"),
796 make_tool_call("reused", "read_file"),
797 make_tool_response("reused", "first result"),
798 make_tool_call("reused", "write_file"),
799 Message::user("interleaved".to_string()),
800 make_tool_response("reused", "second result"),
801 make_tool_response("reused", "duplicate result"),
802 ]);
803 let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
804 assert_eq!(
805 normalized.as_ref(),
806 &vec![
807 make_tool_call("reused", "read_file"),
808 make_tool_response("reused", "first result"),
809 make_tool_call("reused", "write_file"),
810 make_tool_response("reused", "second result"),
811 Message::user("interleaved".to_string()),
812 ]
813 );
814 assert_eq!(messages.len(), 7);
815 assert_eq!(messages[0].content.as_text(), "early result");
816 }
817
818 #[test]
819 fn request_normalization_shared_repairs_asymmetric_batch_without_mutating_source() {
820 let messages = Arc::new(vec![
821 Message::assistant_with_tools(
822 "three calls".to_string(),
823 ["call_a", "call_b", "call_c"]
824 .into_iter()
825 .map(|id| {
826 crate::llm::provider::ToolCall::function(
827 id.to_string(),
828 "read_file".to_string(),
829 "{}".to_string(),
830 )
831 })
832 .collect(),
833 ),
834 Message::user("interleaved request".to_string()),
835 make_tool_response("call_b", "result B"),
836 make_tool_response("call_a", "result A"),
837 make_tool_response("call_b", "duplicate B"),
838 ]);
839 let source_before = messages.as_ref().clone();
840
841 let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
842
843 assert!(!Arc::ptr_eq(&messages, &normalized));
844 assert_eq!(normalized.len(), 5);
845 assert_eq!(normalized[0], source_before[0]);
846 assert_eq!(normalized[1], make_tool_response("call_b", "result B"));
847 assert_eq!(normalized[2], make_tool_response("call_a", "result A"));
848 assert_eq!(normalized[3].role, MessageRole::Tool);
849 assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_c"));
850 assert_eq!(
851 normalized[3].content.as_text(),
852 "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol."
853 );
854 assert_eq!(normalized[4], Message::user("interleaved request".to_string()));
855 assert_eq!(messages.as_ref(), &source_before);
856 let repeated = normalize_history_for_request_shared(Arc::clone(&normalized));
857 assert!(Arc::ptr_eq(&normalized, &repeated));
858 }
859
860 #[test]
861 fn request_normalization_groups_split_results_after_intervening_messages() {
862 let messages = vec![
863 Message::assistant_with_tools(
864 "".to_string(),
865 vec![
866 crate::llm::provider::ToolCall::function(
867 "call_1".to_string(),
868 "read_file".to_string(),
869 "{}".to_string(),
870 ),
871 crate::llm::provider::ToolCall::function(
872 "call_2".to_string(),
873 "read_file".to_string(),
874 "{}".to_string(),
875 ),
876 ],
877 ),
878 Message::system("intervening system note".to_string()),
879 make_tool_response("call_2", "result two"),
880 Message::user("intervening user note".to_string()),
881 make_tool_response("call_1", "result one"),
882 Message::assistant("done".to_string()),
883 ];
884
885 let normalized = normalize_history_for_request(&messages);
886
887 assert_eq!(normalized[0].role, MessageRole::Assistant);
888 assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_2"));
889 assert_eq!(normalized[1].content.as_text(), "result two");
890 assert_eq!(normalized[2].tool_call_id.as_deref(), Some("call_1"));
891 assert_eq!(normalized[2].content.as_text(), "result one");
892 assert_eq!(normalized[3].role, MessageRole::System);
893 assert_eq!(normalized[4].role, MessageRole::User);
894 assert_eq!(normalized[5].role, MessageRole::Assistant);
895 }
896
897 #[test]
898 fn request_normalization_drops_early_orphan_duplicate_and_synthesizes_missing_results() {
899 let messages = vec![
900 make_tool_response("call_1", "causally early"),
901 make_tool_response("orphan", "orphaned"),
902 make_tool_call("call_1", "read_file"),
903 make_tool_call("call_2", "read_file"),
904 make_tool_response("call_1", "valid"),
905 make_tool_response("call_1", "duplicate"),
906 ];
907
908 let normalized = normalize_history_for_request(&messages);
909
910 assert_eq!(normalized.len(), 4);
911 assert_eq!(normalized[0].role, MessageRole::Assistant);
912 assert_eq!(normalized[0].tool_calls.as_ref().map(Vec::len), Some(1));
913 assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_1"));
914 assert_eq!(normalized[1].content.as_text(), "valid");
915 assert_eq!(normalized[2].role, MessageRole::Assistant);
916 assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_2"));
917 assert!(normalized[3].content.as_text().contains("bounded placeholder"));
918 }
919
920 #[test]
921 fn request_normalization_is_idempotent_and_does_not_mutate_durable_history() {
922 let messages = vec![
923 make_tool_call("call_1", "read_file"),
924 Message::system("intervening".to_string()),
925 make_tool_response("call_1", "result"),
926 ];
927 let durable_before = messages.clone();
928
929 assert!(request_history_needs_normalization(&messages));
930 let normalized = normalize_history_for_request(&messages);
931 assert!(!request_history_needs_normalization(&normalized));
932 assert_eq!(normalize_history_for_request(&normalized), normalized);
933 assert_eq!(messages, durable_before);
934 }
935
936 #[test]
938 fn test_recover_from_crash() {
939 let mut messages = vec![
940 make_tool_call("crashed_call", "dangerous_op"),
941 make_tool_response("old_call", "stale result"),
942 ];
943
944 recover_history_from_crash(&mut messages);
945
946 let report = validate_history_invariants(&messages);
947 assert!(report.is_valid());
948 assert!(
949 messages
950 .iter()
951 .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "crashed_call") })
952 );
953 assert!(
954 !messages
955 .iter()
956 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "old_call"))
957 );
958 }
959
960 #[test]
962 fn test_validation_report_summary() {
963 let valid = HistoryValidationReport::default();
964 assert_eq!(valid.summary(), "History invariants are valid");
965 assert!(valid.is_valid());
966
967 let invalid = HistoryValidationReport {
968 missing_outputs: vec![
969 MissingOutput {
970 call_id: ToolCallId::new("call_1"),
971 tool_name: "tool_a".into(),
972 },
973 MissingOutput {
974 call_id: ToolCallId::new("call_2"),
975 tool_name: "tool_b".into(),
976 },
977 ],
978 orphan_outputs: vec![ToolCallId::new("orphan_1")],
979 };
980 assert_eq!(invalid.summary(), "2 missing outputs, 1 orphan outputs");
981 assert!(!invalid.is_valid());
982 }
983
984 #[test]
986 fn test_multiple_calls_partial_outputs() {
987 let _messages: Vec<Message> = (1..=3)
988 .flat_map(|i| {
989 vec![
990 make_tool_call(&format!("call_{i}"), &format!("tool_{i}")),
991 if i != 2 {
992 make_tool_response(&format!("call_{i}"), &format!("result_{i}"))
993 } else {
994 Message::tool_response("placeholder".into(), "".into())
998 },
999 ]
1000 })
1001 .collect();
1002 let mut messages = vec![
1004 make_tool_call("call_1", "tool_1"),
1005 make_tool_response("call_1", "result_1"),
1006 make_tool_call("call_2", "tool_2"),
1007 make_tool_call("call_3", "tool_3"),
1008 make_tool_response("call_3", "result_3"),
1009 ];
1010
1011 let report = validate_history_invariants(&messages);
1012 assert!(!report.is_valid());
1013 assert_eq!(report.missing_outputs.len(), 1);
1014 assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_2");
1015
1016 normalize_history(&mut messages);
1017 assert!(validate_history_invariants(&messages).is_valid());
1018 }
1019
1020 #[test]
1022 fn test_output_status_as_str() {
1023 assert_eq!(OutputStatus::Success.as_str(), "success");
1024 assert_eq!(OutputStatus::Failed.as_str(), "failed");
1025 assert_eq!(OutputStatus::Canceled.as_str(), "canceled");
1026 assert_eq!(OutputStatus::Timeout.as_str(), "timeout");
1027 }
1028
1029 #[test]
1031 fn test_find_safe_split_point() {
1032 let messages = vec![
1033 Message::user("User 1".into()), make_tool_call("call_a", "tool_a"), make_tool_response("call_a", "Result A"), make_tool_call("call_b", "tool_b"), make_tool_response("call_b", "Result B"), ];
1039 let conversation_len = 5;
1040
1041 let safe = safe_history_split_point(&messages, conversation_len, 3);
1043 assert_eq!(safe, 2, "Should move split to include Call A");
1044
1045 let safe2 = safe_history_split_point(&messages, conversation_len, 4);
1047 assert_eq!(safe2, 4, "Should stay at 4 as it is safe");
1048 }
1049
1050 #[test]
1051 fn test_summarize_list_formatting() {
1052 assert_eq!(summarize_list(&[]), "none");
1053 assert_eq!(summarize_list(&["a".into()]), "a");
1054 assert_eq!(summarize_list(&["a".into(), "b".into()]), "a, b");
1055 let many: Vec<String> = (1..=7).map(|i| format!("item{i}")).collect();
1056 let result = summarize_list(&many);
1057 assert!(result.contains("item1, item2, item3, item4, item5"));
1058 assert!(result.contains("[+2 more]"));
1059 }
1060
1061 fn bulky_tool_history(count: usize, body_chars: usize) -> Vec<Message> {
1062 let mut messages = vec![Message::user("start".to_string())];
1063 for i in 0..count {
1064 let call_id = format!("call_{i}");
1065 messages.push(make_tool_call(&call_id, "read_file"));
1066 messages.push(make_tool_response(&call_id, &"x".repeat(body_chars)));
1067 }
1068 messages.push(Message::assistant("done".to_string()));
1069 messages
1070 }
1071
1072 fn is_cleared_stub(message: &Message) -> bool {
1073 message.content.as_text().contains("\"cleared\":\"tool_result\"")
1074 }
1075
1076 #[test]
1077 fn clear_old_tool_results_noop_below_trigger() {
1078 let messages = bulky_tool_history(3, 200);
1079 let cleared = clear_old_tool_results(&messages, u64::MAX, 1, 1, false);
1080 assert_eq!(cleared.len(), messages.len());
1081 assert!(
1082 cleared
1083 .iter()
1084 .filter(|m| m.role == MessageRole::Tool)
1085 .all(|m| !is_cleared_stub(m))
1086 );
1087 }
1088
1089 #[test]
1090 fn clear_old_tool_results_keeps_newest_and_stubs_all_older() {
1091 let messages = bulky_tool_history(4, 4_000);
1094 let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1095 let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1096 assert_eq!(tool_msgs.len(), 4);
1097 assert!(is_cleared_stub(tool_msgs[0]), "oldest result is cleared");
1098 assert!(is_cleared_stub(tool_msgs[1]));
1099 assert!(is_cleared_stub(tool_msgs[2]));
1100 assert!(!is_cleared_stub(tool_msgs[3]), "newest result stays intact");
1101 for (original, rewritten) in messages.iter().zip(cleared.iter()) {
1103 assert_eq!(original.role, rewritten.role);
1104 assert_eq!(original.tool_call_id, rewritten.tool_call_id);
1105 }
1106 }
1107
1108 #[test]
1109 fn clear_old_tool_results_respects_keep_tool_uses() {
1110 let messages = bulky_tool_history(5, 3_000);
1111 let cleared = clear_old_tool_results(&messages, 1, 3, 1, false);
1112 let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1113 assert_eq!(tool_msgs.len(), 5);
1114 assert!(!is_cleared_stub(tool_msgs[2]));
1115 assert!(!is_cleared_stub(tool_msgs[3]));
1116 assert!(!is_cleared_stub(tool_msgs[4]));
1117 }
1118
1119 #[test]
1120 fn clear_old_tool_results_optional_clear_tool_inputs() {
1121 let mut messages = bulky_tool_history(3, 3_000);
1122 if let Some(call) = messages
1124 .get_mut(1)
1125 .and_then(|m| m.tool_calls.as_mut())
1126 .and_then(|calls| calls.first_mut())
1127 .and_then(|call| call.function.as_mut())
1128 {
1129 call.arguments = "{\"path\":\"secret-path\"}".to_string();
1130 }
1131
1132 let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1133 let first_call_args = cleared
1134 .get(1)
1135 .and_then(|m| m.tool_calls.as_ref())
1136 .and_then(|calls| calls.first())
1137 .and_then(|call| call.function.as_ref())
1138 .map(|function| function.arguments.as_str())
1139 .unwrap_or_default();
1140 assert!(!first_call_args.contains("secret-path"), "cleared call arguments must drop the original payload");
1141 assert!(
1142 first_call_args.contains("\"cleared\":\"tool_input\""),
1143 "cleared arguments must be a JSON placeholder, got {first_call_args:?}"
1144 );
1145 assert!(
1146 serde_json::from_str::<serde_json::Value>(first_call_args).is_ok(),
1147 "cleared arguments must stay valid JSON for the wire"
1148 );
1149 let last_call_args = cleared
1151 .get(5)
1152 .and_then(|m| m.tool_calls.as_ref())
1153 .and_then(|calls| calls.first())
1154 .and_then(|call| call.function.as_ref())
1155 .map(|function| function.arguments.as_str())
1156 .unwrap_or_default();
1157 assert_eq!(last_call_args, "{}");
1158 }
1159
1160 #[test]
1161 fn clear_old_tool_results_default_config_clears_paired_inputs() {
1162 let default_clear_tool_inputs =
1166 vtcode_config::core::agent::ToolResultClearingConfig::default().clear_tool_inputs;
1167 assert!(default_clear_tool_inputs, "config default must clear tool inputs");
1168
1169 let mut messages = bulky_tool_history(3, 3_000);
1170 if let Some(call) = messages
1171 .get_mut(1)
1172 .and_then(|m| m.tool_calls.as_mut())
1173 .and_then(|calls| calls.first_mut())
1174 .and_then(|call| call.function.as_mut())
1175 {
1176 call.arguments = "{\"input\":\"*** Begin Patch\\n*** End Patch\"}".to_string();
1177 }
1178
1179 let cleared = clear_old_tool_results(&messages, 1, 1, 1, default_clear_tool_inputs);
1180 let cleared_args = cleared
1181 .get(1)
1182 .and_then(|m| m.tool_calls.as_ref())
1183 .and_then(|calls| calls.first())
1184 .and_then(|call| call.function.as_ref())
1185 .map(|function| function.arguments.as_str())
1186 .unwrap_or_default();
1187 assert!(
1188 !cleared_args.contains("Begin Patch"),
1189 "default-on clearing must drop patch bodies, got {cleared_args:?}"
1190 );
1191 assert_eq!(cleared_args, CLEARED_TOOL_INPUT_PLACEHOLDER);
1192
1193 let kept_args = cleared
1194 .get(5)
1195 .and_then(|m| m.tool_calls.as_ref())
1196 .and_then(|calls| calls.first())
1197 .and_then(|call| call.function.as_ref())
1198 .map(|function| function.arguments.as_str())
1199 .unwrap_or_default();
1200 assert_eq!(kept_args, "{}", "kept call arguments must stay intact");
1201 }
1202
1203 #[test]
1204 fn clear_old_tool_results_is_idempotent_on_stubs() {
1205 let messages = bulky_tool_history(4, 3_000);
1206 let once = clear_old_tool_results(&messages, 1, 1, 1_000, false);
1207 let twice = clear_old_tool_results(&once, 1, 1, 1_000, false);
1208 let stubs_once = once.iter().filter(|m| is_cleared_stub(m)).count();
1209 let stubs_twice = twice.iter().filter(|m| is_cleared_stub(m)).count();
1210 assert_eq!(stubs_once, stubs_twice);
1211 }
1212
1213 #[test]
1214 fn clear_old_tool_results_leaves_durable_history_unchanged() {
1215 let messages = bulky_tool_history(3, 2_500);
1216 let snapshot: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1217 let request_messages = clear_old_tool_results(&messages, 1, 1, 1, false);
1218 let after: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1219 assert_eq!(snapshot, after, "durable history must not be mutated");
1220 assert!(
1221 request_messages
1222 .iter()
1223 .filter(|m| m.role == MessageRole::Tool)
1224 .any(is_cleared_stub),
1225 "request messages should carry stubs"
1226 );
1227 }
1228
1229 #[test]
1230 fn local_tool_result_clearing_gate_mirrors_native_edits() {
1231 use super::should_apply_local_tool_result_clearing as gate;
1232 assert!(gate("zai", false, true), "non-Anthropic uses local clearing");
1233 assert!(gate("openai", false, true), "OpenAI uses local clearing");
1234 assert!(gate("anthropic", false, true), "Anthropic without edits falls back to local");
1235 assert!(!gate("anthropic", true, true), "Anthropic with context_edits uses native clear_tool_uses only");
1236 assert!(!gate("zai", false, false), "disabled config never clears");
1237 }
1238
1239 #[test]
1240 fn clear_old_tool_results_stub_is_valid_json_for_non_ascii_tool_names() {
1241 let mut messages = bulky_tool_history(3, 2_500);
1242 messages[2].origin_tool = Some("读取文件".to_string());
1244 messages[2].tool_call_id = Some("call_ünïcode".to_string());
1245 let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1246 let stub = cleared
1247 .iter()
1248 .filter(|m| is_cleared_stub(m))
1249 .find(|m| m.content.as_text().contains("call_ünïcode"))
1250 .expect("stub for the renamed call");
1251 let parsed: serde_json::Value =
1252 serde_json::from_str(stub.content.as_text().as_ref()).expect("stub must be valid JSON");
1253 assert_eq!(parsed["tool"], "读取文件");
1254 assert_eq!(parsed["tool_call_id"], "call_ünïcode");
1255 }
1256
1257 #[test]
1258 fn clear_old_tool_results_clear_tool_inputs_covers_freeform_text() {
1259 let mut messages = bulky_tool_history(3, 2_500);
1260 if let Some(call) = messages
1262 .get_mut(1)
1263 .and_then(|m| m.tool_calls.as_mut())
1264 .and_then(|calls| calls.first_mut())
1265 {
1266 call.text = Some("raw freeform payload".to_string());
1267 call.thought_signature = Some("sig".to_string());
1268 }
1269 let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1270 let call = cleared
1271 .get(1)
1272 .and_then(|m| m.tool_calls.as_ref())
1273 .and_then(|calls| calls.first())
1274 .expect("call preserved");
1275 assert_eq!(call.text.as_deref(), Some(CLEARED_TOOL_INPUT_PLACEHOLDER));
1276 assert!(call.thought_signature.is_none());
1277 }
1278}