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
371#[must_use]
375pub fn resolve_tool_result_clearing_trigger(
376 config: &crate::config::VTCodeConfig,
377 provider: &dyn crate::llm::provider::LLMProvider,
378 model: &str,
379 prompt_overhead_tokens: usize,
380 reserved_output_tokens: usize,
381) -> u64 {
382 let clearing = &config.agent.harness.tool_result_clearing;
383 if !clearing.enabled || clearing.trigger_tokens == 0 {
384 return 0;
385 }
386 let capacity = crate::compaction::effective_context_budget(Some(config), provider, model);
387 if capacity == 0 {
388 return clearing.trigger_tokens;
389 }
390 let history_budget = capacity
391 .saturating_sub(reserved_output_tokens)
392 .saturating_sub(prompt_overhead_tokens) as u64;
393 let cap = (history_budget / 5)
396 .saturating_mul(3)
397 .saturating_add((history_budget % 5) * 3 / 5)
398 .max(1);
399 clearing.trigger_tokens.min(cap)
400}
401
402pub fn clear_old_tool_results(
425 messages: &[Message],
426 trigger_tokens: u64,
427 keep_tool_uses: u32,
428 clear_at_least_tokens: u64,
429 clear_tool_inputs: bool,
430) -> Vec<Message> {
431 clear_old_tool_results_with_recent_batches(
432 messages,
433 trigger_tokens,
434 keep_tool_uses,
435 0,
436 clear_at_least_tokens,
437 clear_tool_inputs,
438 )
439}
440
441pub fn clear_old_tool_results_with_recent_batches(
445 messages: &[Message],
446 trigger_tokens: u64,
447 keep_tool_uses: u32,
448 keep_recent_tool_batches: u32,
449 clear_at_least_tokens: u64,
450 clear_tool_inputs: bool,
451) -> Vec<Message> {
452 if trigger_tokens == 0 || messages.is_empty() {
453 return messages.to_vec();
454 }
455
456 let estimated_tokens: u64 = messages
457 .iter()
458 .map(|message| message.estimate_tokens() as u64)
459 .fold(0u64, u64::saturating_add);
460 if estimated_tokens < trigger_tokens {
461 return messages.to_vec();
462 }
463
464 let tool_indices: Vec<usize> = messages
465 .iter()
466 .enumerate()
467 .filter(|(_, message)| message.role == crate::llm::provider::MessageRole::Tool)
468 .map(|(index, _)| index)
469 .collect();
470 let keep = keep_tool_uses as usize;
471 if tool_indices.len() <= keep {
472 return messages.to_vec();
473 }
474
475 let mut cleared_tokens = 0u64;
476 let analysis = analyze_request_history(messages);
477 let mut retained_batches: HashSet<usize> = tool_indices
478 .iter()
479 .rev()
480 .take(keep)
481 .filter_map(|index| analysis.result_batch_by_index.get(index).copied())
482 .collect();
483 retained_batches.extend((0..analysis.batches.len()).rev().take(keep_recent_tool_batches as usize));
486 let mut cleared_result_count = 0usize;
487 let mut cleared_calls_by_assistant: HashMap<usize, HashSet<&str>> = HashMap::new();
488 let mut out = messages.to_vec();
492 for &tool_index in &tool_indices[..tool_indices.len() - keep] {
493 let batch_index = analysis.result_batch_by_index.get(&tool_index);
494 if batch_index.is_some_and(|index| retained_batches.contains(index)) {
495 continue;
496 }
497 let original = &messages[tool_index];
498 let original_tokens = original.estimate_tokens() as u64;
499 let stub = build_cleared_tool_result_stub(original);
500 let stub_tokens = stub.estimate_tokens() as u64;
501 if stub_tokens >= original_tokens {
502 continue;
503 }
504 if let Some(&batch_index) = batch_index
505 && let Some(call_id) = original.tool_call_id.as_deref()
506 {
507 cleared_calls_by_assistant
508 .entry(analysis.batches[batch_index].assistant_index)
509 .or_default()
510 .insert(call_id);
511 }
512 out[tool_index] = stub;
513 cleared_result_count += 1;
514 cleared_tokens = cleared_tokens.saturating_add(original_tokens.saturating_sub(stub_tokens));
515 }
516
517 if cleared_tokens < clear_at_least_tokens {
521 tracing::debug!(
522 cleared_tokens,
523 clear_at_least_tokens,
524 "tool-result clearing reclaimed less than the configured floor"
525 );
526 }
527
528 if clear_tool_inputs {
529 for (assistant_index, original_message) in out.iter_mut().enumerate() {
530 let Some(cleared_call_ids) = cleared_calls_by_assistant.remove(&assistant_index) else {
531 continue;
532 };
533 let mut message = original_message.clone();
534 let Some(tool_calls) = message.tool_calls.as_mut() else {
535 continue;
536 };
537 for call in tool_calls.iter_mut() {
538 if cleared_call_ids.contains(call.id.as_str()) {
539 if let Some(function) = call.function.as_mut() {
540 function.arguments = CLEARED_TOOL_INPUT_PLACEHOLDER.to_string();
543 }
544 if call.text.is_some() {
547 call.text = Some(CLEARED_TOOL_INPUT_PLACEHOLDER.to_string());
548 }
549 call.thought_signature = None;
550 }
551 }
552 let input_growth =
555 (message.estimate_tokens() as u64).saturating_sub(original_message.estimate_tokens() as u64);
556 if input_growth <= cleared_tokens {
557 *original_message = message;
558 cleared_tokens = cleared_tokens.saturating_sub(input_growth);
559 }
560 }
561 }
562
563 tracing::debug!(
564 original_tokens = estimated_tokens,
565 projected_tokens = out
566 .iter()
567 .map(|message| message.estimate_tokens() as u64)
568 .fold(0u64, u64::saturating_add),
569 cleared_result_count,
570 protected_batch_count = retained_batches.len(),
571 "local tool-result clearing projection"
572 );
573 out
574}
575
576const CLEARED_TOOL_INPUT_PLACEHOLDER: &str = "{\"cleared\":\"tool_input\"}";
578
579pub fn should_apply_local_tool_result_clearing(
585 provider_name: &str,
586 context_edits: bool,
587 clearing_enabled: bool,
588) -> bool {
589 clearing_enabled && !(provider_name.eq_ignore_ascii_case("anthropic") && context_edits)
590}
591
592fn build_cleared_tool_result_stub(original: &Message) -> Message {
593 let mut stub = original.clone();
594 let body = serde_json::json!({
596 "cleared": "tool_result",
597 "reason": "tool_result_clearing",
598 "note": CLEARED_TOOL_RESULT_NOTE,
599 "tool": original.origin_tool.as_deref().unwrap_or("tool"),
600 "tool_call_id": original.tool_call_id.as_deref().unwrap_or(""),
601 })
602 .to_string();
603 stub.content = crate::llm::provider::MessageContent::Text(body);
604 stub
605}
606
607pub fn safe_history_split_point(messages: &[Message], conversation_len: usize, preferred_split_at: usize) -> usize {
609 if preferred_split_at == 0 || preferred_split_at >= conversation_len {
610 return preferred_split_at;
611 }
612
613 let mut call_indices: HashMap<&str, usize> = HashMap::new();
614 for (i, msg) in messages.iter().enumerate() {
615 if let Some(tool_calls) = &msg.tool_calls {
616 for call in tool_calls {
617 call_indices.insert(&call.id, i);
618 }
619 }
620 }
621
622 let mut safe_split_at = preferred_split_at;
623 loop {
624 if safe_split_at == 0 {
625 break;
626 }
627
628 let has_orphan = ((safe_split_at + 1)..messages.len()).any(|i| {
629 messages
630 .get(i)
631 .and_then(|msg| msg.tool_call_id.as_ref())
632 .and_then(|id| call_indices.get(id.as_str()))
633 .is_some_and(|&call_idx| call_idx <= safe_split_at)
634 });
635
636 if !has_orphan {
637 break;
638 }
639
640 safe_split_at -= 1;
641 }
642
643 safe_split_at
644}
645
646pub fn ensure_call_outputs_present(messages: &mut Vec<Message>) {
648 let report = validate_history_invariants(messages);
649
650 for missing in report.missing_outputs.iter().rev() {
652 let synthetic_message = Message::tool_response(
653 missing.call_id.as_str().to_string(),
654 "canceled: Tool execution was interrupted. This synthetic output was created \
655 during history normalization to maintain conversation invariants."
656 .to_string(),
657 );
658
659 tracing::warn!("Creating synthetic output for call {} due to missing execution result", missing.call_id);
660
661 let insert_pos = messages
663 .iter()
664 .position(|msg| {
665 msg.tool_calls
666 .as_ref()
667 .is_some_and(|calls| calls.iter().any(|call| call.id == missing.call_id.as_str()))
668 })
669 .map(|pos| pos + 1);
670
671 if let Some(pos) = insert_pos {
672 messages.insert(pos, synthetic_message);
673 } else {
674 messages.push(synthetic_message);
676 }
677 }
678}
679
680pub fn remove_orphan_outputs(messages: &mut Vec<Message>) {
682 let report = validate_history_invariants(messages);
683
684 if report.orphan_outputs.is_empty() {
685 return;
686 }
687
688 let orphan_ids: HashSet<String> = report.orphan_outputs.iter().map(|id| id.as_str().to_string()).collect();
689
690 let initial_len = messages.len();
691
692 messages.retain(|msg| {
696 if let Some(tool_call_id) = msg.tool_call_id.as_ref()
697 && orphan_ids.contains(tool_call_id)
698 {
699 tracing::warn!("Removing orphan output for call {}", tool_call_id);
700 return false;
701 }
702 true
703 });
704
705 if messages.len() != initial_len {
706 tracing::info!("Removed {} orphan outputs", initial_len - messages.len());
707 }
708}
709
710pub fn normalize_history(messages: &mut Vec<Message>) {
712 ensure_call_outputs_present(messages);
713 remove_orphan_outputs(messages);
714
715 let report = validate_history_invariants(messages);
717 if !report.is_valid() {
718 tracing::warn!("History validation: {}", report.summary());
719 } else {
720 tracing::debug!("History normalized successfully");
721 }
722}
723
724pub fn recover_history_from_crash(messages: &mut Vec<Message>) {
726 let report = validate_history_invariants(messages);
727
728 if !report.missing_outputs.is_empty() {
729 tracing::warn!("Found {} missing outputs during recovery", report.missing_outputs.len());
730 ensure_call_outputs_present(messages);
731 }
732
733 if !report.orphan_outputs.is_empty() {
734 tracing::warn!("Found {} orphan outputs during recovery", report.orphan_outputs.len());
735 remove_orphan_outputs(messages);
736 }
737
738 if report.is_valid() {
739 tracing::debug!("History invariants are valid");
740 }
741}
742
743#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::llm::provider::{Message, MessageRole};
751 fn make_tool_call(call_id: &str, tool_name: &str) -> Message {
753 Message::assistant_with_tools(
754 "".to_string(),
755 vec![crate::llm::provider::ToolCall::function(
756 call_id.to_string(),
757 tool_name.to_string(),
758 "{}".to_string(),
759 )],
760 )
761 }
762
763 fn make_tool_response(call_id: &str, content: &str) -> Message {
764 Message::tool_response(call_id.to_string(), content.to_string())
765 }
766
767 #[test]
769 fn test_validate_history_valid_matched_pairs() {
770 let mut messages = vec![
771 make_tool_call("call_1", "list_files"),
772 make_tool_response("call_1", "file1.rs\nfile2.rs"),
773 ];
774
775 let report = validate_history_invariants(&messages);
776 assert!(report.is_valid(), "Valid paired call/output should pass");
777 assert!(report.missing_outputs.is_empty());
778 assert!(report.orphan_outputs.is_empty());
779
780 normalize_history(&mut messages);
782 assert_eq!(messages.len(), 2);
783 }
784
785 #[test]
787 fn test_validate_history_missing_output() {
788 let messages = vec![make_tool_call("call_1", "list_files")];
789
790 let report = validate_history_invariants(&messages);
791 assert!(!report.is_valid());
792 assert_eq!(report.missing_outputs.len(), 1);
793 assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_1");
794 assert!(report.orphan_outputs.is_empty());
795 }
796
797 #[test]
799 fn test_validate_history_orphan_output() {
800 let messages = vec![make_tool_response("orphan_call", "Some result")];
801
802 let report = validate_history_invariants(&messages);
803 assert!(!report.is_valid());
804 assert!(report.missing_outputs.is_empty());
805 assert_eq!(report.orphan_outputs.len(), 1);
806 assert_eq!(report.orphan_outputs[0].as_str(), "orphan_call");
807 }
808
809 #[test]
811 fn test_ensure_call_outputs_present() {
812 let mut messages = vec![make_tool_call("call_1", "list_files")];
813 let initial_len = messages.len();
814
815 ensure_call_outputs_present(&mut messages);
816
817 assert_eq!(messages.len(), initial_len + 1);
818 let last_msg = &messages[initial_len];
819 assert_eq!(last_msg.tool_call_id, Some("call_1".to_string()));
820 assert!(last_msg.content.as_text().contains("canceled"));
821
822 let report = validate_history_invariants(&messages);
823 assert!(report.is_valid());
824 }
825
826 #[test]
828 fn test_remove_orphan_outputs() {
829 let mut messages = vec![
830 make_tool_call("call_1", "list_files"),
831 make_tool_response("call_1", "valid result"),
832 make_tool_response("orphan_call", "orphan result"),
833 ];
834
835 let initial_len = messages.len();
836 remove_orphan_outputs(&mut messages);
837
838 assert_eq!(messages.len(), initial_len - 1);
839 assert!(
840 messages
841 .iter()
842 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
843 );
844 assert!(
845 !messages
846 .iter()
847 .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan_call") })
848 );
849
850 let report = validate_history_invariants(&messages);
851 assert!(report.is_valid());
852 }
853
854 #[test]
856 fn test_normalize_combined_fixes() {
857 let mut messages = vec![
858 make_tool_call("call_1", "read_file"),
859 make_tool_call("call_2", "write_file"),
860 make_tool_response("call_2", "written"),
861 make_tool_response("orphan", "orphan result"),
862 ];
863
864 normalize_history(&mut messages);
865
866 let report = validate_history_invariants(&messages);
867 assert!(report.is_valid());
868 assert!(
869 messages
870 .iter()
871 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "call_1"))
872 );
873 assert!(
874 !messages
875 .iter()
876 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "orphan"))
877 );
878 }
879
880 #[test]
881 fn request_normalization_shared_reuses_clean_history_arc() {
882 let messages = Arc::new(vec![Message::user("clean history".to_string())]);
883
884 let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
885
886 assert!(Arc::ptr_eq(&messages, &normalized));
887 }
888
889 #[test]
890 fn request_normalization_reused_ids_match_causally_preceding_batches() {
891 let messages = Arc::new(vec![
892 make_tool_response("reused", "early result"),
893 make_tool_call("reused", "read_file"),
894 make_tool_response("reused", "first result"),
895 make_tool_call("reused", "write_file"),
896 Message::user("interleaved".to_string()),
897 make_tool_response("reused", "second result"),
898 make_tool_response("reused", "duplicate result"),
899 ]);
900 let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
901 assert_eq!(
902 normalized.as_ref(),
903 &vec![
904 make_tool_call("reused", "read_file"),
905 make_tool_response("reused", "first result"),
906 make_tool_call("reused", "write_file"),
907 make_tool_response("reused", "second result"),
908 Message::user("interleaved".to_string()),
909 ]
910 );
911 assert_eq!(messages.len(), 7);
912 assert_eq!(messages[0].content.as_text(), "early result");
913 }
914
915 #[test]
916 fn request_normalization_shared_repairs_asymmetric_batch_without_mutating_source() {
917 let messages = Arc::new(vec![
918 Message::assistant_with_tools(
919 "three calls".to_string(),
920 ["call_a", "call_b", "call_c"]
921 .into_iter()
922 .map(|id| {
923 crate::llm::provider::ToolCall::function(
924 id.to_string(),
925 "read_file".to_string(),
926 "{}".to_string(),
927 )
928 })
929 .collect(),
930 ),
931 Message::user("interleaved request".to_string()),
932 make_tool_response("call_b", "result B"),
933 make_tool_response("call_a", "result A"),
934 make_tool_response("call_b", "duplicate B"),
935 ]);
936 let source_before = messages.as_ref().clone();
937
938 let normalized = normalize_history_for_request_shared(Arc::clone(&messages));
939
940 assert!(!Arc::ptr_eq(&messages, &normalized));
941 assert_eq!(normalized.len(), 5);
942 assert_eq!(normalized[0], source_before[0]);
943 assert_eq!(normalized[1], make_tool_response("call_b", "result B"));
944 assert_eq!(normalized[2], make_tool_response("call_a", "result A"));
945 assert_eq!(normalized[3].role, MessageRole::Tool);
946 assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_c"));
947 assert_eq!(
948 normalized[3].content.as_text(),
949 "canceled: no tool result was recorded; this bounded placeholder preserves the tool-call protocol."
950 );
951 assert_eq!(normalized[4], Message::user("interleaved request".to_string()));
952 assert_eq!(messages.as_ref(), &source_before);
953 let repeated = normalize_history_for_request_shared(Arc::clone(&normalized));
954 assert!(Arc::ptr_eq(&normalized, &repeated));
955 }
956
957 #[test]
958 fn request_normalization_groups_split_results_after_intervening_messages() {
959 let messages = vec![
960 Message::assistant_with_tools(
961 "".to_string(),
962 vec![
963 crate::llm::provider::ToolCall::function(
964 "call_1".to_string(),
965 "read_file".to_string(),
966 "{}".to_string(),
967 ),
968 crate::llm::provider::ToolCall::function(
969 "call_2".to_string(),
970 "read_file".to_string(),
971 "{}".to_string(),
972 ),
973 ],
974 ),
975 Message::system("intervening system note".to_string()),
976 make_tool_response("call_2", "result two"),
977 Message::user("intervening user note".to_string()),
978 make_tool_response("call_1", "result one"),
979 Message::assistant("done".to_string()),
980 ];
981
982 let normalized = normalize_history_for_request(&messages);
983
984 assert_eq!(normalized[0].role, MessageRole::Assistant);
985 assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_2"));
986 assert_eq!(normalized[1].content.as_text(), "result two");
987 assert_eq!(normalized[2].tool_call_id.as_deref(), Some("call_1"));
988 assert_eq!(normalized[2].content.as_text(), "result one");
989 assert_eq!(normalized[3].role, MessageRole::System);
990 assert_eq!(normalized[4].role, MessageRole::User);
991 assert_eq!(normalized[5].role, MessageRole::Assistant);
992 }
993
994 #[test]
995 fn request_normalization_drops_early_orphan_duplicate_and_synthesizes_missing_results() {
996 let messages = vec![
997 make_tool_response("call_1", "causally early"),
998 make_tool_response("orphan", "orphaned"),
999 make_tool_call("call_1", "read_file"),
1000 make_tool_call("call_2", "read_file"),
1001 make_tool_response("call_1", "valid"),
1002 make_tool_response("call_1", "duplicate"),
1003 ];
1004
1005 let normalized = normalize_history_for_request(&messages);
1006
1007 assert_eq!(normalized.len(), 4);
1008 assert_eq!(normalized[0].role, MessageRole::Assistant);
1009 assert_eq!(normalized[0].tool_calls.as_ref().map(Vec::len), Some(1));
1010 assert_eq!(normalized[1].tool_call_id.as_deref(), Some("call_1"));
1011 assert_eq!(normalized[1].content.as_text(), "valid");
1012 assert_eq!(normalized[2].role, MessageRole::Assistant);
1013 assert_eq!(normalized[3].tool_call_id.as_deref(), Some("call_2"));
1014 assert!(normalized[3].content.as_text().contains("bounded placeholder"));
1015 }
1016
1017 #[test]
1018 fn request_normalization_is_idempotent_and_does_not_mutate_durable_history() {
1019 let messages = vec![
1020 make_tool_call("call_1", "read_file"),
1021 Message::system("intervening".to_string()),
1022 make_tool_response("call_1", "result"),
1023 ];
1024 let durable_before = messages.clone();
1025
1026 assert!(request_history_needs_normalization(&messages));
1027 let normalized = normalize_history_for_request(&messages);
1028 assert!(!request_history_needs_normalization(&normalized));
1029 assert_eq!(normalize_history_for_request(&normalized), normalized);
1030 assert_eq!(messages, durable_before);
1031 }
1032
1033 #[test]
1035 fn test_recover_from_crash() {
1036 let mut messages = vec![
1037 make_tool_call("crashed_call", "dangerous_op"),
1038 make_tool_response("old_call", "stale result"),
1039 ];
1040
1041 recover_history_from_crash(&mut messages);
1042
1043 let report = validate_history_invariants(&messages);
1044 assert!(report.is_valid());
1045 assert!(
1046 messages
1047 .iter()
1048 .any(|msg| { msg.tool_call_id.as_ref().is_some_and(|id| id == "crashed_call") })
1049 );
1050 assert!(
1051 !messages
1052 .iter()
1053 .any(|msg| msg.tool_call_id.as_ref().is_some_and(|id| id == "old_call"))
1054 );
1055 }
1056
1057 #[test]
1059 fn test_validation_report_summary() {
1060 let valid = HistoryValidationReport::default();
1061 assert_eq!(valid.summary(), "History invariants are valid");
1062 assert!(valid.is_valid());
1063
1064 let invalid = HistoryValidationReport {
1065 missing_outputs: vec![
1066 MissingOutput {
1067 call_id: ToolCallId::new("call_1"),
1068 tool_name: "tool_a".into(),
1069 },
1070 MissingOutput {
1071 call_id: ToolCallId::new("call_2"),
1072 tool_name: "tool_b".into(),
1073 },
1074 ],
1075 orphan_outputs: vec![ToolCallId::new("orphan_1")],
1076 };
1077 assert_eq!(invalid.summary(), "2 missing outputs, 1 orphan outputs");
1078 assert!(!invalid.is_valid());
1079 }
1080
1081 #[test]
1083 fn test_multiple_calls_partial_outputs() {
1084 let _messages: Vec<Message> = (1..=3)
1085 .flat_map(|i| {
1086 vec![
1087 make_tool_call(&format!("call_{i}"), &format!("tool_{i}")),
1088 if i != 2 {
1089 make_tool_response(&format!("call_{i}"), &format!("result_{i}"))
1090 } else {
1091 Message::tool_response("placeholder".into(), "".into())
1095 },
1096 ]
1097 })
1098 .collect();
1099 let mut messages = vec![
1101 make_tool_call("call_1", "tool_1"),
1102 make_tool_response("call_1", "result_1"),
1103 make_tool_call("call_2", "tool_2"),
1104 make_tool_call("call_3", "tool_3"),
1105 make_tool_response("call_3", "result_3"),
1106 ];
1107
1108 let report = validate_history_invariants(&messages);
1109 assert!(!report.is_valid());
1110 assert_eq!(report.missing_outputs.len(), 1);
1111 assert_eq!(report.missing_outputs[0].call_id.as_str(), "call_2");
1112
1113 normalize_history(&mut messages);
1114 assert!(validate_history_invariants(&messages).is_valid());
1115 }
1116
1117 #[test]
1119 fn test_output_status_as_str() {
1120 assert_eq!(OutputStatus::Success.as_str(), "success");
1121 assert_eq!(OutputStatus::Failed.as_str(), "failed");
1122 assert_eq!(OutputStatus::Canceled.as_str(), "canceled");
1123 assert_eq!(OutputStatus::Timeout.as_str(), "timeout");
1124 }
1125
1126 #[test]
1128 fn test_find_safe_split_point() {
1129 let messages = vec![
1130 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"), ];
1136 let conversation_len = 5;
1137
1138 let safe = safe_history_split_point(&messages, conversation_len, 3);
1140 assert_eq!(safe, 2, "Should move split to include Call A");
1141
1142 let safe2 = safe_history_split_point(&messages, conversation_len, 4);
1144 assert_eq!(safe2, 4, "Should stay at 4 as it is safe");
1145 }
1146
1147 #[test]
1148 fn test_summarize_list_formatting() {
1149 assert_eq!(summarize_list(&[]), "none");
1150 assert_eq!(summarize_list(&["a".into()]), "a");
1151 assert_eq!(summarize_list(&["a".into(), "b".into()]), "a, b");
1152 let many: Vec<String> = (1..=7).map(|i| format!("item{i}")).collect();
1153 let result = summarize_list(&many);
1154 assert!(result.contains("item1, item2, item3, item4, item5"));
1155 assert!(result.contains("[+2 more]"));
1156 }
1157
1158 fn bulky_tool_history(count: usize, body_chars: usize) -> Vec<Message> {
1159 let mut messages = vec![Message::user("start".to_string())];
1160 for i in 0..count {
1161 let call_id = format!("call_{i}");
1162 messages.push(make_tool_call(&call_id, "read_file"));
1163 messages.push(make_tool_response(&call_id, &"x".repeat(body_chars)));
1164 }
1165 messages.push(Message::assistant("done".to_string()));
1166 messages
1167 }
1168
1169 fn is_cleared_stub(message: &Message) -> bool {
1170 message.content.as_text().contains("\"cleared\":\"tool_result\"")
1171 }
1172
1173 #[test]
1174 fn clear_old_tool_results_noop_below_trigger() {
1175 let messages = bulky_tool_history(3, 200);
1176 let cleared = clear_old_tool_results(&messages, u64::MAX, 1, 1, false);
1177 assert_eq!(cleared.len(), messages.len());
1178 assert!(
1179 cleared
1180 .iter()
1181 .filter(|m| m.role == MessageRole::Tool)
1182 .all(|m| !is_cleared_stub(m))
1183 );
1184 }
1185
1186 #[test]
1187 fn clear_old_tool_results_keeps_newest_and_stubs_all_older() {
1188 let messages = bulky_tool_history(4, 4_000);
1191 let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1192 let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1193 assert_eq!(tool_msgs.len(), 4);
1194 assert!(is_cleared_stub(tool_msgs[0]), "oldest result is cleared");
1195 assert!(is_cleared_stub(tool_msgs[1]));
1196 assert!(is_cleared_stub(tool_msgs[2]));
1197 assert!(!is_cleared_stub(tool_msgs[3]), "newest result stays intact");
1198 for (original, rewritten) in messages.iter().zip(cleared.iter()) {
1200 assert_eq!(original.role, rewritten.role);
1201 assert_eq!(original.tool_call_id, rewritten.tool_call_id);
1202 }
1203 }
1204
1205 #[test]
1206 fn clear_old_tool_results_respects_keep_tool_uses() {
1207 let messages = bulky_tool_history(5, 3_000);
1208 let cleared = clear_old_tool_results(&messages, 1, 3, 1, false);
1209 let tool_msgs: Vec<&Message> = cleared.iter().filter(|m| m.role == MessageRole::Tool).collect();
1210 assert_eq!(tool_msgs.len(), 5);
1211 assert!(!is_cleared_stub(tool_msgs[2]));
1212 assert!(!is_cleared_stub(tool_msgs[3]));
1213 assert!(!is_cleared_stub(tool_msgs[4]));
1214 }
1215
1216 #[test]
1217 fn clear_old_tool_results_optional_clear_tool_inputs() {
1218 let mut messages = bulky_tool_history(3, 3_000);
1219 if let Some(call) = messages
1221 .get_mut(1)
1222 .and_then(|m| m.tool_calls.as_mut())
1223 .and_then(|calls| calls.first_mut())
1224 .and_then(|call| call.function.as_mut())
1225 {
1226 call.arguments = "{\"path\":\"secret-path\"}".to_string();
1227 }
1228
1229 let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1230 let first_call_args = cleared
1231 .get(1)
1232 .and_then(|m| m.tool_calls.as_ref())
1233 .and_then(|calls| calls.first())
1234 .and_then(|call| call.function.as_ref())
1235 .map(|function| function.arguments.as_str())
1236 .unwrap_or_default();
1237 assert!(!first_call_args.contains("secret-path"), "cleared call arguments must drop the original payload");
1238 assert!(
1239 first_call_args.contains("\"cleared\":\"tool_input\""),
1240 "cleared arguments must be a JSON placeholder, got {first_call_args:?}"
1241 );
1242 assert!(
1243 serde_json::from_str::<serde_json::Value>(first_call_args).is_ok(),
1244 "cleared arguments must stay valid JSON for the wire"
1245 );
1246 let last_call_args = cleared
1248 .get(5)
1249 .and_then(|m| m.tool_calls.as_ref())
1250 .and_then(|calls| calls.first())
1251 .and_then(|call| call.function.as_ref())
1252 .map(|function| function.arguments.as_str())
1253 .unwrap_or_default();
1254 assert_eq!(last_call_args, "{}");
1255 }
1256
1257 #[test]
1258 fn clear_old_tool_results_default_config_clears_paired_inputs() {
1259 let default_clear_tool_inputs =
1263 vtcode_config::core::agent::ToolResultClearingConfig::default().clear_tool_inputs;
1264 assert!(default_clear_tool_inputs, "config default must clear tool inputs");
1265
1266 let mut messages = bulky_tool_history(3, 3_000);
1267 if let Some(call) = messages
1268 .get_mut(1)
1269 .and_then(|m| m.tool_calls.as_mut())
1270 .and_then(|calls| calls.first_mut())
1271 .and_then(|call| call.function.as_mut())
1272 {
1273 call.arguments = "{\"input\":\"*** Begin Patch\\n*** End Patch\"}".to_string();
1274 }
1275
1276 let cleared = clear_old_tool_results(&messages, 1, 1, 1, default_clear_tool_inputs);
1277 let cleared_args = cleared
1278 .get(1)
1279 .and_then(|m| m.tool_calls.as_ref())
1280 .and_then(|calls| calls.first())
1281 .and_then(|call| call.function.as_ref())
1282 .map(|function| function.arguments.as_str())
1283 .unwrap_or_default();
1284 assert!(
1285 !cleared_args.contains("Begin Patch"),
1286 "default-on clearing must drop patch bodies, got {cleared_args:?}"
1287 );
1288 assert_eq!(cleared_args, CLEARED_TOOL_INPUT_PLACEHOLDER);
1289
1290 let kept_args = cleared
1291 .get(5)
1292 .and_then(|m| m.tool_calls.as_ref())
1293 .and_then(|calls| calls.first())
1294 .and_then(|call| call.function.as_ref())
1295 .map(|function| function.arguments.as_str())
1296 .unwrap_or_default();
1297 assert_eq!(kept_args, "{}", "kept call arguments must stay intact");
1298 }
1299
1300 #[test]
1301 fn clear_old_tool_results_is_idempotent_on_stubs() {
1302 let messages = bulky_tool_history(4, 3_000);
1303 let once = clear_old_tool_results(&messages, 1, 1, 1_000, false);
1304 let twice = clear_old_tool_results(&once, 1, 1, 1_000, false);
1305 let stubs_once = once.iter().filter(|m| is_cleared_stub(m)).count();
1306 let stubs_twice = twice.iter().filter(|m| is_cleared_stub(m)).count();
1307 assert_eq!(stubs_once, stubs_twice);
1308 }
1309
1310 #[test]
1311 fn clear_old_tool_results_leaves_durable_history_unchanged() {
1312 let messages = bulky_tool_history(3, 2_500);
1313 let snapshot: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1314 let request_messages = clear_old_tool_results(&messages, 1, 1, 1, false);
1315 let after: Vec<String> = messages.iter().map(|m| m.content.as_text().into_owned()).collect();
1316 assert_eq!(snapshot, after, "durable history must not be mutated");
1317 assert!(
1318 request_messages
1319 .iter()
1320 .filter(|m| m.role == MessageRole::Tool)
1321 .any(is_cleared_stub),
1322 "request messages should carry stubs"
1323 );
1324 }
1325
1326 #[test]
1327 fn local_tool_result_clearing_gate_mirrors_native_edits() {
1328 use super::should_apply_local_tool_result_clearing as gate;
1329 assert!(gate("zai", false, true), "non-Anthropic uses local clearing");
1330 assert!(gate("openai", false, true), "OpenAI uses local clearing");
1331 assert!(gate("anthropic", false, true), "Anthropic without edits falls back to local");
1332 assert!(!gate("anthropic", true, true), "Anthropic with context_edits uses native clear_tool_uses only");
1333 assert!(!gate("zai", false, false), "disabled config never clears");
1334 }
1335
1336 #[test]
1337 fn clear_old_tool_results_stub_is_valid_json_for_non_ascii_tool_names() {
1338 let mut messages = bulky_tool_history(3, 2_500);
1339 messages[2].origin_tool = Some("读取文件".to_string());
1341 messages[2].tool_call_id = Some("call_ünïcode".to_string());
1342 let cleared = clear_old_tool_results(&messages, 1, 1, 1, false);
1343 let stub = cleared
1344 .iter()
1345 .filter(|m| is_cleared_stub(m))
1346 .find(|m| m.content.as_text().contains("call_ünïcode"))
1347 .expect("stub for the renamed call");
1348 let parsed: serde_json::Value =
1349 serde_json::from_str(stub.content.as_text().as_ref()).expect("stub must be valid JSON");
1350 assert_eq!(parsed["tool"], "读取文件");
1351 assert_eq!(parsed["tool_call_id"], "call_ünïcode");
1352 }
1353
1354 #[test]
1355 fn clear_old_tool_results_clear_tool_inputs_covers_freeform_text() {
1356 let mut messages = bulky_tool_history(3, 2_500);
1357 if let Some(call) = messages
1359 .get_mut(1)
1360 .and_then(|m| m.tool_calls.as_mut())
1361 .and_then(|calls| calls.first_mut())
1362 {
1363 call.text = Some("raw freeform payload".to_string());
1364 call.thought_signature = Some("sig".to_string());
1365 }
1366 let cleared = clear_old_tool_results(&messages, 1, 1, 1, true);
1367 let call = cleared
1368 .get(1)
1369 .and_then(|m| m.tool_calls.as_ref())
1370 .and_then(|calls| calls.first())
1371 .expect("call preserved");
1372 assert_eq!(call.text.as_deref(), Some(CLEARED_TOOL_INPUT_PLACEHOLDER));
1373 assert!(call.thought_signature.is_none());
1374 }
1375}
1376
1377#[cfg(test)]
1378mod tool_result_clearing_tests;