Skip to main content

vtcode_core/tools/registry/
execution_history.rs

1//! Tool execution history and records.
2//!
3//! This module provides thread-safe recording and querying of tool executions,
4//! including loop detection and rate limiting.
5
6use 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/// Snapshot of harness context for execution records.
19#[derive(Debug, Clone)]
20pub struct HarnessContextSnapshot {
21    pub session_id: String,
22    pub task_id: Option<String>,
23}
24
25impl HarnessContextSnapshot {
26    /// Create a new harness context snapshot.
27    pub fn new(session_id: String, task_id: Option<String>) -> Self {
28        Self {
29            session_id,
30            task_id,
31        }
32    }
33
34    /// Serialize snapshot for middleware/telemetry consumers without cloning callers.
35    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/// Record of a single tool execution for diagnostics.
44#[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    /// Create a new failed execution record.
67    #[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    /// Create a new successful execution record.
105    #[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
162/// Default window size for loop detection.
163const DEFAULT_LOOP_DETECT_WINDOW: usize = 5;
164/// Minimum limit for identical readonly operations.
165///
166/// Read/search calls are cheap to reuse but can become stale across unrelated
167/// turns, so keep the threshold low enough to stop obvious churn without
168/// blocking a single intentional reread.
169const 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/// Thread-safe execution history for recording tool executions.
283#[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    /// Create a new execution history with a maximum record count.
294    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    /// Add a record to the history.
311    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    /// Set loop detection parameters.
322    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    /// Set the rate limit for tool executions per minute.
330    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    /// Get the most recent records.
338    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    /// Get recent failures in chronological order.
348    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    /// Find the most recent spooled output for a tool call with identical args.
364    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    /// Find the most recent successful output for a tool call with identical args.
397    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    /// Find continuation info from a recent chunked file-read call for the same path.
436    ///
437    /// Supports both `read_file` and `unified_file` read action records.
438    ///
439    /// Returns `(next_offset, chunk_limit)` when the recent call indicates more chunks are
440    /// available (`spool_chunked=true`, `has_more=true`).
441    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    /// Clear all records.
493    pub fn clear(&self) {
494        if let Ok(mut records) = self.records.write() {
495            records.clear();
496        }
497    }
498
499    /// Total number of execution records currently stored.
500    pub fn len(&self) -> usize {
501        self.records.read().ok().map(|r| r.len()).unwrap_or(0)
502    }
503
504    /// Whether no execution records are currently stored.
505    pub fn is_empty(&self) -> bool {
506        self.len() == 0
507    }
508
509    /// Get the current loop limit.
510    pub fn loop_limit(&self) -> usize {
511        self.identical_limit
512            .load(std::sync::atomic::Ordering::Relaxed)
513    }
514
515    /// Get the effective loop limit for a specific tool.
516    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    /// Get the rate limit per minute if configured.
521    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    /// Count calls within a time window.
542    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    /// Detect if the agent is stuck in a loop.
558    ///
559    /// Returns (is_loop, repeat_count, tool_name) if a loop is detected.
560    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        // Count how many of the recent calls match this exact tool + args combo
581        // CRITICAL FIX: Only count SUCCESSFUL calls to avoid cascade blocking
582        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}