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