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