1use std::collections::VecDeque;
7use std::env;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, RwLock};
10use std::time::{Duration, SystemTime};
11
12use serde_json::{Value, json};
13
14use crate::config::constants::{defaults, tools};
15use crate::tools::continuation::read_chunk_progress_from_result;
16use crate::tools::tool_intent;
17
18#[derive(Debug, Clone)]
20pub struct HarnessContextSnapshot {
21 pub session_id: String,
22 pub task_id: Option<String>,
23}
24
25impl HarnessContextSnapshot {
26 pub fn new(session_id: String, task_id: Option<String>) -> Self {
28 Self {
29 session_id,
30 task_id,
31 }
32 }
33
34 pub fn to_json(&self) -> Value {
36 json!({
37 "session_id": self.session_id,
38 "task_id": self.task_id,
39 })
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct ToolExecutionRecord {
46 pub tool_name: String,
47 pub requested_name: String,
48 pub is_mcp: bool,
49 pub mcp_provider: Option<String>,
50 pub args: Value,
51 pub result: Result<Value, String>,
52 pub timestamp: SystemTime,
53 pub success: bool,
54 pub context: HarnessContextSnapshot,
55 pub timeout_category: Option<String>,
56 pub base_timeout_ms: Option<u64>,
57 pub adaptive_timeout_ms: Option<u64>,
58 pub effective_timeout_ms: Option<u64>,
59 pub circuit_breaker: bool,
60 pub attempt: u32,
61 pub retry_after_ms: Option<u64>,
62 pub circuit_breaker_state: Option<String>,
63}
64
65impl ToolExecutionRecord {
66 #[expect(clippy::too_many_arguments)]
68 #[cold]
69 pub fn failure(
70 tool_name: String,
71 requested_name: String,
72 is_mcp: bool,
73 mcp_provider: Option<String>,
74 args: Value,
75 error_msg: String,
76 context: HarnessContextSnapshot,
77 timeout_category: Option<String>,
78 base_timeout_ms: Option<u64>,
79 adaptive_timeout_ms: Option<u64>,
80 effective_timeout_ms: Option<u64>,
81 circuit_breaker: bool,
82 ) -> Self {
83 Self {
84 tool_name,
85 requested_name,
86 is_mcp,
87 mcp_provider,
88 args,
89 result: Err(error_msg),
90 timestamp: SystemTime::now(),
91 success: false,
92 context,
93 timeout_category,
94 base_timeout_ms,
95 adaptive_timeout_ms,
96 effective_timeout_ms,
97 circuit_breaker,
98 attempt: 1,
99 retry_after_ms: None,
100 circuit_breaker_state: None,
101 }
102 }
103
104 #[expect(clippy::too_many_arguments)]
106 #[inline]
107 pub fn success(
108 tool_name: String,
109 requested_name: String,
110 is_mcp: bool,
111 mcp_provider: Option<String>,
112 args: Value,
113 result: Value,
114 context: HarnessContextSnapshot,
115 timeout_category: Option<String>,
116 base_timeout_ms: Option<u64>,
117 adaptive_timeout_ms: Option<u64>,
118 effective_timeout_ms: Option<u64>,
119 circuit_breaker: bool,
120 ) -> Self {
121 Self {
122 tool_name,
123 requested_name,
124 is_mcp,
125 mcp_provider,
126 args,
127 result: Ok(result),
128 timestamp: SystemTime::now(),
129 success: true,
130 context,
131 timeout_category,
132 base_timeout_ms,
133 adaptive_timeout_ms,
134 effective_timeout_ms,
135 circuit_breaker,
136 attempt: 1,
137 retry_after_ms: None,
138 circuit_breaker_state: None,
139 }
140 }
141
142 #[inline]
143 pub fn with_attempt(mut self, attempt: u32) -> Self {
144 self.attempt = attempt.max(1);
145 self
146 }
147
148 #[inline]
149 pub fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
150 self.retry_after_ms =
151 retry_after.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64);
152 self
153 }
154
155 #[inline]
156 pub fn with_circuit_breaker_state(mut self, state: impl Into<String>) -> Self {
157 self.circuit_breaker_state = Some(state.into());
158 self
159 }
160}
161
162const DEFAULT_LOOP_DETECT_WINDOW: usize = 5;
164const MIN_READONLY_IDENTICAL_LIMIT: usize = 2;
170
171fn spool_path_exists(result: &Value) -> bool {
172 let Some(spool_path) = result.get("spool_path").and_then(|v| v.as_str()) else {
173 return true;
174 };
175
176 let path = Path::new(spool_path);
177 if path.is_absolute() {
178 return path.exists();
179 }
180
181 path.exists()
182 || env::current_dir()
183 .ok()
184 .is_some_and(|cwd| cwd.join(path).exists())
185}
186
187fn read_file_path_from_args(args: &Value) -> Option<&str> {
188 let obj = args.as_object()?;
189 for key in ["path", "file_path", "filepath", "target_path", "file"] {
190 if let Some(path) = obj.get(key).and_then(|v| v.as_str()) {
191 let trimmed = path.trim();
192 if !trimmed.is_empty() {
193 return Some(trimmed);
194 }
195 }
196 }
197 None
198}
199
200fn normalize_tool_name_for_match(name: &str) -> String {
201 let normalized = name.trim().to_ascii_lowercase().replace(' ', "_");
202 tool_intent::canonical_unified_exec_tool_name(&normalized)
203 .unwrap_or(&normalized)
204 .to_string()
205}
206
207fn is_read_file_tool_name(name: &str) -> bool {
208 let normalized = normalize_tool_name_for_match(name);
209 normalized == tools::READ_FILE || normalized.ends_with(".read_file")
210}
211
212fn is_unified_file_tool_name(name: &str) -> bool {
213 let normalized = normalize_tool_name_for_match(name);
214 normalized == tools::UNIFIED_FILE || normalized.ends_with(".unified_file")
215}
216
217fn tool_name_matches(name: &str, expected: &str) -> bool {
218 let normalized = normalize_tool_name_for_match(name);
219 normalized == expected || normalized.ends_with(&format!(".{expected}"))
220}
221
222fn is_read_style_tool_call(tool_name: &str, args: &Value) -> bool {
223 if tool_name_matches(tool_name, tools::READ_FILE) {
224 return true;
225 }
226 if is_unified_file_tool_name(tool_name) {
227 return tool_intent::unified_file_action_is(args, "read");
228 }
229 false
230}
231
232fn normalize_path_for_match(path: &str) -> String {
233 path.trim()
234 .replace('\\', "/")
235 .trim_start_matches("./")
236 .to_string()
237}
238
239fn to_absolute_path(path: &str) -> Option<PathBuf> {
240 let trimmed = path.trim();
241 if trimmed.is_empty() {
242 return None;
243 }
244 let raw = Path::new(trimmed);
245 if raw.is_absolute() {
246 return Some(raw.to_path_buf());
247 }
248 env::current_dir().ok().map(|cwd| cwd.join(raw))
249}
250
251fn paths_match(record_path: &str, expected_path: &str) -> bool {
252 let lhs = normalize_path_for_match(record_path);
253 let rhs = normalize_path_for_match(expected_path);
254 if lhs == rhs {
255 return true;
256 }
257 if lhs.ends_with(&format!("/{rhs}")) || rhs.ends_with(&format!("/{lhs}")) {
258 return true;
259 }
260
261 match (
262 to_absolute_path(record_path),
263 to_absolute_path(expected_path),
264 ) {
265 (Some(abs_lhs), Some(abs_rhs)) => abs_lhs == abs_rhs,
266 _ => false,
267 }
268}
269
270fn is_read_file_style_record(record: &ToolExecutionRecord) -> bool {
271 if is_read_file_tool_name(&record.tool_name) {
272 return true;
273 }
274
275 if !is_unified_file_tool_name(&record.tool_name) {
276 return false;
277 }
278
279 tool_intent::unified_file_action_is(&record.args, "read")
280}
281
282#[derive(Clone)]
284pub struct ToolExecutionHistory {
285 records: Arc<RwLock<VecDeque<ToolExecutionRecord>>>,
286 max_records: usize,
287 detect_window: Arc<std::sync::atomic::AtomicUsize>,
288 identical_limit: Arc<std::sync::atomic::AtomicUsize>,
289 rate_limit_per_minute: Arc<std::sync::atomic::AtomicUsize>,
290}
291
292impl ToolExecutionHistory {
293 pub fn new(max_records: usize) -> Self {
295 Self {
296 records: Arc::new(RwLock::new(VecDeque::new())),
297 max_records,
298 detect_window: Arc::new(std::sync::atomic::AtomicUsize::new(
299 DEFAULT_LOOP_DETECT_WINDOW,
300 )),
301 identical_limit: Arc::new(std::sync::atomic::AtomicUsize::new(
302 defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS,
303 )),
304 rate_limit_per_minute: Arc::new(std::sync::atomic::AtomicUsize::new(
305 crate::tools::rate_limit_config::tool_calls_per_minute_from_env().unwrap_or(0),
306 )),
307 }
308 }
309
310 pub fn add_record(&self, record: ToolExecutionRecord) {
312 let Ok(mut records) = self.records.write() else {
313 return;
314 };
315 records.push_back(record);
316 while records.len() > self.max_records {
317 records.pop_front();
318 }
319 }
320
321 pub fn set_loop_detection_limits(&self, detect_window: usize, identical_limit: usize) {
323 self.detect_window
324 .store(detect_window.max(1), std::sync::atomic::Ordering::Relaxed);
325 self.identical_limit
326 .store(identical_limit, std::sync::atomic::Ordering::Relaxed);
327 }
328
329 pub fn set_rate_limit_per_minute(&self, limit: Option<usize>) {
331 self.rate_limit_per_minute.store(
332 limit.filter(|v| *v > 0).unwrap_or(0),
333 std::sync::atomic::Ordering::Relaxed,
334 );
335 }
336
337 pub fn get_recent_records(&self, count: usize) -> Vec<ToolExecutionRecord> {
339 let Ok(records) = self.records.read() else {
340 return Vec::new();
341 };
342 let records_len = records.len();
343 let start = records_len.saturating_sub(count);
344 records.iter().skip(start).cloned().collect()
345 }
346
347 pub fn get_recent_failures(&self, count: usize) -> Vec<ToolExecutionRecord> {
349 let Ok(records) = self.records.read() else {
350 return Vec::new();
351 };
352 let mut failures: Vec<ToolExecutionRecord> = records
353 .iter()
354 .rev()
355 .filter(|r| !r.success)
356 .take(count)
357 .cloned()
358 .collect();
359 failures.reverse();
360 failures
361 }
362
363 pub fn find_recent_spooled_result(
365 &self,
366 tool_name: &str,
367 args: &Value,
368 max_age: Duration,
369 ) -> Option<Value> {
370 let records = self.records.read().ok()?;
371 let now = SystemTime::now();
372
373 for record in records.iter().rev() {
374 if record.tool_name != tool_name || !record.success || record.args != *args {
375 continue;
376 }
377
378 let age_ok = match now.duration_since(record.timestamp) {
379 Ok(age) => age <= max_age,
380 Err(_) => false,
381 };
382 if !age_ok {
383 continue;
384 }
385
386 if let Ok(result) = &record.result
387 && result.get("spool_path").and_then(|v| v.as_str()).is_some()
388 && spool_path_exists(result)
389 {
390 return Some(result.clone());
391 }
392 }
393 None
394 }
395
396 pub fn find_recent_successful_result(
398 &self,
399 tool_name: &str,
400 args: &Value,
401 max_age: Duration,
402 ) -> Option<Value> {
403 let records = self.records.read().ok()?;
404 let now = SystemTime::now();
405
406 for record in records.iter().rev() {
407 if record.tool_name != tool_name || !record.success || record.args != *args {
408 continue;
409 }
410
411 let age_ok = match now.duration_since(record.timestamp) {
412 Ok(age) => age <= max_age,
413 Err(_) => false,
414 };
415 if !age_ok {
416 continue;
417 }
418
419 if let Ok(result) = &record.result {
420 if result.get("spool_path").and_then(|v| v.as_str()).is_some() {
421 let Some(spool_path) = result.get("spool_path").and_then(|v| v.as_str()) else {
422 continue;
423 };
424 if !Path::new(spool_path).exists() {
425 continue;
426 }
427 }
428 return Some(result.clone());
429 }
430 }
431
432 None
433 }
434
435 pub fn find_recent_read_file_spool_progress(
442 &self,
443 path: &str,
444 max_age: Duration,
445 ) -> Option<(usize, usize)> {
446 let records = self.records.read().ok()?;
447 let now = SystemTime::now();
448 let expected_path = path.trim();
449
450 for record in records.iter().rev() {
451 if !record.success || !is_read_file_style_record(record) {
452 continue;
453 }
454
455 let Some(record_path) = read_file_path_from_args(&record.args) else {
456 continue;
457 };
458 if !paths_match(record_path, expected_path) {
459 continue;
460 }
461
462 let age_ok = match now.duration_since(record.timestamp) {
463 Ok(age) => age <= max_age,
464 Err(_) => false,
465 };
466 if !age_ok {
467 continue;
468 }
469
470 let Ok(result) = &record.result else {
471 continue;
472 };
473 let chunked = result
474 .get("spool_chunked")
475 .and_then(|v| v.as_bool())
476 .unwrap_or(false);
477 let has_more = result
478 .get("has_more")
479 .and_then(|v| v.as_bool())
480 .unwrap_or(false);
481 if !(chunked && has_more) {
482 continue;
483 }
484
485 if let Some(progress) = read_chunk_progress_from_result(result) {
486 return Some(progress);
487 }
488 }
489 None
490 }
491
492 pub fn clear(&self) {
494 if let Ok(mut records) = self.records.write() {
495 records.clear();
496 }
497 }
498
499 pub fn len(&self) -> usize {
501 self.records.read().ok().map(|r| r.len()).unwrap_or(0)
502 }
503
504 pub fn is_empty(&self) -> bool {
506 self.len() == 0
507 }
508
509 pub fn loop_limit(&self) -> usize {
511 self.identical_limit
512 .load(std::sync::atomic::Ordering::Relaxed)
513 }
514
515 pub fn loop_limit_for(&self, tool_name: &str, args: &Value) -> usize {
517 self.effective_identical_limit_for_call(tool_name, args)
518 }
519
520 pub fn rate_limit_per_minute(&self) -> Option<usize> {
522 let val = self
523 .rate_limit_per_minute
524 .load(std::sync::atomic::Ordering::Relaxed);
525 (val != 0).then_some(val)
526 }
527
528 fn effective_identical_limit_for_call(&self, tool_name: &str, args: &Value) -> usize {
529 let base_limit = self
530 .identical_limit
531 .load(std::sync::atomic::Ordering::Relaxed);
532 if is_read_style_tool_call(tool_name, args)
533 || tool_name_matches(tool_name, tools::UNIFIED_SEARCH)
534 {
535 base_limit.max(MIN_READONLY_IDENTICAL_LIMIT)
536 } else {
537 base_limit
538 }
539 }
540
541 pub fn calls_in_window(&self, window: Duration) -> usize {
543 let cutoff = SystemTime::now()
544 .checked_sub(window)
545 .unwrap_or(SystemTime::UNIX_EPOCH);
546
547 let Ok(records) = self.records.read() else {
548 return 0;
549 };
550 records
551 .iter()
552 .rev()
553 .take_while(|record| record.timestamp >= cutoff)
554 .count()
555 }
556
557 pub fn detect_loop(&self, tool_name: &str, args: &Value) -> (bool, usize, String) {
561 let limit = self.effective_identical_limit_for_call(tool_name, args);
562 if limit == 0 {
563 return (false, 0, String::new());
564 }
565
566 let detect_window = self
567 .detect_window
568 .load(std::sync::atomic::Ordering::Relaxed);
569 let window = detect_window.max(limit.saturating_mul(2)).max(1);
570
571 let Ok(records) = self.records.read() else {
572 return (false, 0, String::new());
573 };
574 let recent: Vec<&ToolExecutionRecord> = records.iter().rev().take(window).collect();
575
576 if recent.is_empty() {
577 return (false, 0, String::new());
578 }
579
580 let mut identical_count = 0;
583 for record in &recent {
584 if record.tool_name == tool_name && record.args == *args && record.success {
585 identical_count += 1;
586 }
587 }
588
589 let is_loop = identical_count >= limit;
590 (is_loop, identical_count, tool_name.to_string())
591 }
592}
593
594impl Default for ToolExecutionHistory {
595 fn default() -> Self {
596 Self::new(100)
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use serde_json::json;
604 use tempfile::tempdir;
605
606 fn make_snapshot() -> HarnessContextSnapshot {
607 HarnessContextSnapshot::new("session_test".to_string(), None)
608 }
609
610 #[test]
611 fn finds_recent_spooled_result() {
612 let history = ToolExecutionHistory::new(10);
613 let args = json!({"command": "git diff"});
614 let temp = tempdir().unwrap();
615 let spool_path = temp.path().join("spooled-output.txt");
616 std::fs::write(&spool_path, "diff output").unwrap();
617 let result = json!({
618 "spool_path": spool_path,
619 "success": true
620 });
621
622 history.add_record(ToolExecutionRecord::success(
623 "run_pty_cmd".to_string(),
624 "run_pty_cmd".to_string(),
625 false,
626 None,
627 args.clone(),
628 result.clone(),
629 make_snapshot(),
630 None,
631 None,
632 None,
633 None,
634 false,
635 ));
636
637 let found =
638 history.find_recent_spooled_result("run_pty_cmd", &args, Duration::from_secs(60));
639 assert_eq!(found, Some(result));
640 }
641
642 #[test]
643 fn ignores_non_spooled_or_stale_results() {
644 let history = ToolExecutionHistory::new(10);
645 let args = json!({"path": "README.md"});
646
647 let mut record = ToolExecutionRecord::success(
648 "read_file".to_string(),
649 "read_file".to_string(),
650 false,
651 None,
652 args.clone(),
653 json!({"content": "small"}),
654 make_snapshot(),
655 None,
656 None,
657 None,
658 None,
659 false,
660 );
661 record.timestamp = SystemTime::UNIX_EPOCH;
662 history.add_record(record);
663
664 let found = history.find_recent_spooled_result("read_file", &args, Duration::from_secs(60));
665 assert!(found.is_none());
666 }
667
668 #[test]
669 fn ignores_spooled_result_when_spool_file_is_missing() {
670 let history = ToolExecutionHistory::new(10);
671 let args = json!({"command": "cargo clippy"});
672 let missing_spool_path = tempdir().unwrap().path().join("missing_spool.txt");
673 let result = json!({
674 "spool_path": missing_spool_path,
675 "success": true
676 });
677
678 history.add_record(ToolExecutionRecord::success(
679 "run_pty_cmd".to_string(),
680 "run_pty_cmd".to_string(),
681 false,
682 None,
683 args.clone(),
684 result,
685 make_snapshot(),
686 None,
687 None,
688 None,
689 None,
690 false,
691 ));
692
693 let found =
694 history.find_recent_spooled_result("run_pty_cmd", &args, Duration::from_secs(60));
695 assert!(found.is_none());
696 }
697
698 #[test]
699 fn find_recent_successful_result_skips_missing_spool_file() {
700 let history = ToolExecutionHistory::new(10);
701 let args = json!({"command": "cargo clippy"});
702 let missing_spool_path = tempdir().unwrap().path().join("missing_spool.txt");
703 let result = json!({
704 "spool_path": missing_spool_path,
705 "success": true
706 });
707
708 history.add_record(ToolExecutionRecord::success(
709 "run_pty_cmd".to_string(),
710 "run_pty_cmd".to_string(),
711 false,
712 None,
713 args.clone(),
714 result,
715 make_snapshot(),
716 None,
717 None,
718 None,
719 None,
720 false,
721 ));
722
723 let found =
724 history.find_recent_successful_result("run_pty_cmd", &args, Duration::from_secs(60));
725 assert!(found.is_none());
726 }
727
728 #[test]
729 fn len_tracks_records_and_clear() {
730 let history = ToolExecutionHistory::new(10);
731 assert_eq!(history.len(), 0);
732 assert!(history.is_empty());
733
734 history.add_record(ToolExecutionRecord::success(
735 "read_file".to_string(),
736 "read_file".to_string(),
737 false,
738 None,
739 json!({"path": "README.md"}),
740 json!({"success": true}),
741 make_snapshot(),
742 None,
743 None,
744 None,
745 None,
746 false,
747 ));
748
749 assert_eq!(history.len(), 1);
750 assert!(!history.is_empty());
751
752 history.clear();
753 assert_eq!(history.len(), 0);
754 assert!(history.is_empty());
755 }
756
757 #[test]
758 fn finds_recent_read_file_spool_progress() {
759 let history = ToolExecutionHistory::new(10);
760 let args = json!({"path": ".vtcode/context/tool_outputs/unified_exec_123.txt"});
761 let result = json!({
762 "success": true,
763 "spool_chunked": true,
764 "has_more": true,
765 "next_read_args": {
766 "path": ".vtcode/context/tool_outputs/unified_exec_123.txt",
767 "offset": 41,
768 "limit": 40
769 }
770 });
771
772 history.add_record(ToolExecutionRecord::success(
773 "read_file".to_string(),
774 "read_file".to_string(),
775 false,
776 None,
777 args,
778 result,
779 make_snapshot(),
780 None,
781 None,
782 None,
783 None,
784 false,
785 ));
786
787 let found = history.find_recent_read_file_spool_progress(
788 ".vtcode/context/tool_outputs/unified_exec_123.txt",
789 Duration::from_secs(60),
790 );
791 assert_eq!(found, Some((41, 40)));
792 }
793
794 #[test]
795 fn finds_recent_unified_file_read_spool_progress() {
796 let history = ToolExecutionHistory::new(10);
797 let args = json!({
798 "action": "read",
799 "path": ".vtcode/context/tool_outputs/unified_exec_456.txt"
800 });
801 let result = json!({
802 "success": true,
803 "spool_chunked": true,
804 "has_more": true,
805 "next_read_args": {
806 "path": ".vtcode/context/tool_outputs/unified_exec_456.txt",
807 "offset": 81,
808 "limit": 40
809 }
810 });
811
812 history.add_record(ToolExecutionRecord::success(
813 "unified_file".to_string(),
814 "unified_file".to_string(),
815 false,
816 None,
817 args,
818 result,
819 make_snapshot(),
820 None,
821 None,
822 None,
823 None,
824 false,
825 ));
826
827 let found = history.find_recent_read_file_spool_progress(
828 ".vtcode/context/tool_outputs/unified_exec_456.txt",
829 Duration::from_secs(60),
830 );
831 assert_eq!(found, Some((81, 40)));
832 }
833
834 #[test]
835 fn matches_read_file_alias_name_and_abs_relative_spool_path() {
836 let history = ToolExecutionHistory::new(10);
837 let rel_path = ".vtcode/context/tool_outputs/unified_exec_789.txt";
838 let abs_path = env::current_dir().unwrap().join(rel_path);
839 let args = json!({
840 "path": abs_path,
841 "offset": 1,
842 "limit": 40
843 });
844 let result = json!({
845 "success": true,
846 "spool_chunked": true,
847 "has_more": true,
848 "next_read_args": {
849 "path": rel_path,
850 "offset": 41,
851 "limit": 40
852 }
853 });
854
855 history.add_record(ToolExecutionRecord::success(
856 "Read file".to_string(),
857 "Read file".to_string(),
858 false,
859 None,
860 args,
861 result,
862 make_snapshot(),
863 None,
864 None,
865 None,
866 None,
867 false,
868 ));
869
870 let found = history.find_recent_read_file_spool_progress(rel_path, Duration::from_secs(60));
871 assert_eq!(found, Some((41, 40)));
872 }
873
874 #[test]
875 fn matches_prefixed_read_file_tool_name() {
876 let history = ToolExecutionHistory::new(10);
877 let path = ".vtcode/context/tool_outputs/unified_exec_prefixed.txt";
878 let args = json!({ "path": path });
879 let result = json!({
880 "success": true,
881 "spool_chunked": true,
882 "has_more": true,
883 "next_read_args": {
884 "path": path,
885 "offset": 121,
886 "limit": 40
887 }
888 });
889
890 history.add_record(ToolExecutionRecord::success(
891 "repo_browser.read_file".to_string(),
892 "repo_browser.read_file".to_string(),
893 false,
894 None,
895 args,
896 result,
897 make_snapshot(),
898 None,
899 None,
900 None,
901 None,
902 false,
903 ));
904
905 let found = history.find_recent_read_file_spool_progress(path, Duration::from_secs(60));
906 assert_eq!(found, Some((121, 40)));
907 }
908
909 #[test]
910 fn ignores_read_file_spool_progress_without_canonical_args() {
911 let history = ToolExecutionHistory::new(10);
912 let path = ".vtcode/context/tool_outputs/unified_exec_legacy.txt";
913 let args = json!({"path": path});
914 let result = json!({
915 "success": true,
916 "spool_chunked": true,
917 "has_more": true,
918 "next_offset": 33,
919 "chunk_limit": 32
920 });
921
922 history.add_record(ToolExecutionRecord::success(
923 "read_file".to_string(),
924 "read_file".to_string(),
925 false,
926 None,
927 args,
928 result,
929 make_snapshot(),
930 None,
931 None,
932 None,
933 None,
934 false,
935 ));
936
937 let found = history.find_recent_read_file_spool_progress(path, Duration::from_secs(60));
938 assert_eq!(found, None);
939 }
940
941 #[test]
942 fn readonly_unified_file_calls_use_lower_identical_limit() {
943 let history = ToolExecutionHistory::new(10);
944 history.set_loop_detection_limits(5, 2);
945
946 let args = json!({
947 "action": "read",
948 "path": "vtcode-core/src/core/agent/runner/tests.rs"
949 });
950
951 assert_eq!(history.loop_limit_for("unified_file", &args), 2);
952 }
953
954 #[test]
955 fn unified_search_exact_repeat_is_detected_after_two_successes() {
956 let history = ToolExecutionHistory::new(10);
957 history.set_loop_detection_limits(5, 2);
958
959 let args = json!({
960 "action": "grep",
961 "pattern": "exec_only_policy",
962 "path": "vtcode-core/src/core/agent/runner/tests.rs"
963 });
964
965 for _ in 0..2 {
966 history.add_record(ToolExecutionRecord::success(
967 "unified_search".to_string(),
968 "unified_search".to_string(),
969 false,
970 None,
971 args.clone(),
972 json!({"matches": []}),
973 make_snapshot(),
974 None,
975 None,
976 None,
977 None,
978 false,
979 ));
980 }
981
982 let (is_loop, repeat_count, tool_name) = history.detect_loop("unified_search", &args);
983 assert!(is_loop);
984 assert_eq!(repeat_count, 2);
985 assert_eq!(tool_name, "unified_search");
986 }
987}