use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
pub trait ToolSignature: Send + Sync {
fn extract_primary_param(&self, tool: &str, input: &serde_json::Value) -> String {
let _ = (tool, input);
String::new()
}
fn is_recoverable_error(&self, tool: &str, error: &str) -> bool {
let _ = (tool, error);
false
}
fn get_suggestion(&self, tool: &str) -> Option<String> {
let _ = tool;
None
}
fn file_path_for_reset(&self, tool: &str, input: &serde_json::Value) -> Option<String> {
let _ = (tool, input);
None
}
fn is_file_read_tool(&self, tool: &str) -> bool {
let _ = tool;
false
}
fn is_file_edit_tool(&self, tool: &str) -> bool {
let _ = tool;
false
}
fn tool_thresholds(&self) -> HashMap<String, usize> {
HashMap::new()
}
fn normalize_param_for_comparison(&self, _tool: &str, param: &str) -> String {
param.to_string()
}
}
pub struct NoOpToolSignature;
impl ToolSignature for NoOpToolSignature {}
#[derive(Debug, Clone)]
pub struct LoopDetectorConfig {
pub window_size: usize,
pub repetition_threshold: usize,
pub max_tools_per_turn: usize,
pub max_same_file_reads: usize,
pub stop_threshold: usize,
pub tool_thresholds: HashMap<String, usize>,
}
impl Default for LoopDetectorConfig {
fn default() -> Self {
Self {
window_size: 50,
repetition_threshold: 3,
max_tools_per_turn: 9999,
max_same_file_reads: 5,
stop_threshold: 10,
tool_thresholds: HashMap::new(),
}
}
}
impl LoopDetectorConfig {
#[must_use]
pub fn threshold_for_tool(&self, tool_name: &str) -> usize {
self.tool_thresholds
.get(tool_name)
.copied()
.unwrap_or(self.repetition_threshold)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Operation {
pub tool: String,
pub primary_param: String,
pub result_hash: Option<u64>,
}
impl Operation {
pub fn new(tool: impl Into<String>, primary_param: impl Into<String>) -> Self {
Self {
tool: tool.into(),
primary_param: primary_param.into(),
result_hash: None,
}
}
pub fn from_input_with_signature(
tool: &str,
input: &serde_json::Value,
signature: &dyn ToolSignature,
) -> Self {
let primary_param = signature.extract_primary_param(tool, input);
Self::new(tool, primary_param)
}
pub fn from_input_with_result_and_signature(
tool: &str,
input: &serde_json::Value,
result_hash: Option<u64>,
signature: &dyn ToolSignature,
) -> Self {
let primary_param = signature.extract_primary_param(tool, input);
Self {
tool: tool.to_string(),
primary_param,
result_hash,
}
}
#[must_use]
pub fn with_result_hash(mut self, hash: Option<u64>) -> Self {
self.result_hash = hash;
self
}
}
#[must_use]
pub fn hash_result(content: &str) -> Option<u64> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
if content.is_empty() {
return None;
}
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Some(hasher.finish())
}
#[derive(Debug, Clone, Default)]
pub struct LoopStatus {
pub is_looping: bool,
pub repeated_operations: Vec<Operation>,
pub repetition_count: usize,
pub warning: Option<String>,
pub should_stop: bool,
}
pub struct LoopDetector {
operations: Mutex<VecDeque<Operation>>,
config: LoopDetectorConfig,
turn_count: Mutex<usize>,
warned_operations: Mutex<std::collections::HashSet<Operation>>,
signature: Arc<dyn ToolSignature>,
}
impl LoopDetector {
pub fn new(config: LoopDetectorConfig, signature: Arc<dyn ToolSignature>) -> Self {
Self {
operations: Mutex::new(VecDeque::with_capacity(config.window_size)),
config,
turn_count: Mutex::new(0),
warned_operations: Mutex::new(std::collections::HashSet::new()),
signature,
}
}
#[must_use]
pub fn default_detector() -> Self {
Self::new(LoopDetectorConfig::default(), Arc::new(NoOpToolSignature))
}
pub fn new_with_signature(signature: Arc<dyn ToolSignature>) -> Self {
Self::new(LoopDetectorConfig::default(), signature)
}
pub fn signature(&self) -> &dyn ToolSignature {
self.signature.as_ref()
}
pub fn record_from_input(
&self,
tool: &str,
input: &serde_json::Value,
result_hash: Option<u64>,
) {
self.record_from_input_with_error(tool, input, result_hash, None);
}
pub fn record_from_input_with_error(
&self,
tool: &str,
input: &serde_json::Value,
result_hash: Option<u64>,
error: Option<&str>,
) {
let op = Operation::from_input_with_result_and_signature(
tool,
input,
result_hash,
self.signature.as_ref(),
);
let is_recoverable =
error.is_some_and(|err| self.signature.is_recoverable_error(tool, err));
if is_recoverable {
self.clear_warnings_for_recoverable_edit(&op);
}
self.record(op);
}
pub fn record(&self, operation: Operation) {
let should_clear_history = {
let mut clear = false;
if let Ok(mut warned) = self.warned_operations.lock() {
warned.retain(|warned_op| {
let matches = warned_op.tool == operation.tool
&& warned_op.primary_param == operation.primary_param
&& warned_op.result_hash != operation.result_hash;
if matches {
clear = true;
}
!matches
});
}
clear
};
if should_clear_history {
if let Ok(mut ops) = self.operations.lock() {
ops.retain(|op| {
!(op.tool == operation.tool
&& op.primary_param == operation.primary_param
&& op.result_hash != operation.result_hash)
});
}
}
if let Ok(mut ops) = self.operations.lock() {
if ops.len() >= self.config.window_size {
ops.pop_front();
}
ops.push_back(operation);
}
if let Ok(mut count) = self.turn_count.lock() {
*count = count.saturating_add(1);
}
}
fn clear_warnings_for_recoverable_edit(&self, operation: &Operation) {
let current_file = self
.signature
.normalize_param_for_comparison(&operation.tool, &operation.primary_param);
let sig = &self.signature;
if let Ok(mut warned) = self.warned_operations.lock() {
warned.retain(|warned_op| {
if sig.is_file_edit_tool(&warned_op.tool) {
let warned_file = sig
.normalize_param_for_comparison(&warned_op.tool, &warned_op.primary_param);
warned_file != current_file
} else {
true
}
});
}
if let Ok(mut ops) = self.operations.lock() {
ops.retain(|op| {
if sig.is_file_edit_tool(&op.tool) {
let op_file = sig.normalize_param_for_comparison(&op.tool, &op.primary_param);
op_file != current_file
} else {
true
}
});
}
}
pub fn check_loop(&self) -> LoopStatus {
let Ok(ops) = self.operations.lock() else {
return LoopStatus::default();
};
let (repeated_operations, max_repetitions) =
Self::find_repeated(&ops, |tool| self.config.threshold_for_tool(tool));
let is_looping = !repeated_operations.is_empty();
let should_stop =
self.config.stop_threshold > 0 && max_repetitions >= self.config.stop_threshold;
let warning = self.build_warning(
&repeated_operations,
max_repetitions,
is_looping,
should_stop,
);
LoopStatus {
is_looping,
repeated_operations,
repetition_count: max_repetitions,
warning,
should_stop,
}
}
fn count_operations(ops: &VecDeque<Operation>) -> HashMap<Operation, usize> {
let mut counts: HashMap<Operation, usize> = HashMap::new();
for op in ops {
counts
.entry(op.clone())
.and_modify(|c| *c = c.saturating_add(1))
.or_insert(1);
}
counts
}
fn find_repeated(
ops: &VecDeque<Operation>,
threshold: impl Fn(&str) -> usize,
) -> (Vec<Operation>, usize) {
let counts = Self::count_operations(ops);
let mut repeated = Vec::new();
let mut max = 0;
for (op, count) in counts {
let t = threshold(&op.tool);
if count >= t {
if count > max {
max = count;
repeated.clear();
repeated.push(op);
} else if count == max {
repeated.push(op);
}
}
}
repeated.sort_by(|a, b| {
a.tool
.cmp(&b.tool)
.then_with(|| a.primary_param.cmp(&b.primary_param))
});
(repeated, max)
}
fn build_warning(
&self,
repeated_operations: &[Operation],
max_repetitions: usize,
is_looping: bool,
should_stop: bool,
) -> Option<String> {
if !is_looping {
return None;
}
let first_op = repeated_operations.first()?;
let already_warned = self
.warned_operations
.lock()
.is_ok_and(|w| w.contains(first_op));
if already_warned && !should_stop {
return None;
}
if !already_warned {
if let Ok(mut warned) = self.warned_operations.lock() {
warned.insert(first_op.clone());
}
}
let stop_msg = if should_stop {
" STOPPING to prevent infinite loop."
} else {
""
};
let suggestion = self
.signature
.get_suggestion(&first_op.tool)
.unwrap_or_else(|| "Consider a different approach or tool.".to_string());
Some(format!(
"Loop detected: Operation '{}({})' repeated {} times with same result.{} {}",
first_op.tool, first_op.primary_param, max_repetitions, stop_msg, suggestion
))
}
pub fn check_turn_limit(&self) -> bool {
match self.turn_count.lock() {
Ok(count) => *count >= self.config.max_tools_per_turn,
Err(_) => false,
}
}
pub fn turn_count(&self) -> usize {
self.turn_count.lock().map_or(0, |c| *c)
}
pub fn reset_turn(&self) {
if let Ok(mut count) = self.turn_count.lock() {
*count = 0;
}
}
pub fn check_file_reads(&self, file_path: &str) -> bool {
let Ok(ops) = self.operations.lock() else {
return false;
};
let sig = &self.signature;
let normalized_input = sig.normalize_param_for_comparison("", file_path);
let read_count = ops
.iter()
.filter(|o| {
sig.is_file_read_tool(&o.tool)
&& sig.normalize_param_for_comparison(&o.tool, &o.primary_param)
== normalized_input
})
.count();
read_count >= self.config.max_same_file_reads
}
pub fn clear(&self) {
if let Ok(mut ops) = self.operations.lock() {
ops.clear();
}
if let Ok(mut warned) = self.warned_operations.lock() {
warned.clear();
}
}
pub fn reset(&self) {
if let Ok(mut ops) = self.operations.lock() {
ops.clear();
}
if let Ok(mut count) = self.turn_count.lock() {
*count = 0;
}
if let Ok(mut warned) = self.warned_operations.lock() {
warned.clear();
}
}
}
impl Default for LoopDetector {
fn default() -> Self {
Self::default_detector()
}
}
static GLOBAL_DETECTOR: std::sync::OnceLock<Arc<LoopDetector>> = std::sync::OnceLock::new();
pub fn global_detector() -> Arc<LoopDetector> {
Arc::clone(GLOBAL_DETECTOR.get_or_init(|| Arc::new(LoopDetector::default_detector())))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct TestToolSignature;
impl ToolSignature for TestToolSignature {
fn extract_primary_param(&self, tool: &str, input: &serde_json::Value) -> String {
match tool {
"Read" => input
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Glob" => {
let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
if path.is_empty() {
pattern.to_string()
} else {
format!("{path}:{pattern}")
}
}
"Bash" => input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => input.to_string(),
}
}
fn get_suggestion(&self, tool: &str) -> Option<String> {
match tool {
"Bash" => Some("Check the command output or try a different approach.".to_string()),
"Read" => Some("Try using Glob to find files first.".to_string()),
_ => None,
}
}
fn is_file_read_tool(&self, tool: &str) -> bool {
matches!(tool, "Read" | "Bash")
}
fn is_file_edit_tool(&self, tool: &str) -> bool {
tool == "Edit"
}
}
fn test_detector() -> LoopDetector {
LoopDetector::new(LoopDetectorConfig::default(), Arc::new(TestToolSignature))
}
#[test]
fn test_operation_from_input() {
let sig = TestToolSignature;
let op = Operation::from_input_with_signature(
"Read",
&json!({"file_path": "/test/file.txt"}),
&sig,
);
assert_eq!(op.tool, "Read");
assert_eq!(op.primary_param, "/test/file.txt");
}
#[test]
fn test_operation_from_input_glob() {
let sig = TestToolSignature;
let op = Operation::from_input_with_signature("Glob", &json!({"pattern": "*.rs"}), &sig);
assert_eq!(op.tool, "Glob");
assert_eq!(op.primary_param, "*.rs");
let op = Operation::from_input_with_signature(
"Glob",
&json!({"pattern": "**/*.rs", "path": "/src"}),
&sig,
);
assert_eq!(op.tool, "Glob");
assert_eq!(op.primary_param, "/src:**/*.rs");
}
#[test]
fn test_loop_detection() {
let detector = test_detector();
for _ in 0..5 {
detector.record(Operation::new("Read", "/test/file.txt"));
}
let status = detector.check_loop();
assert!(status.is_looping);
assert!(status.repetition_count >= 3);
}
#[test]
fn test_no_loop() {
let detector = test_detector();
detector.record(Operation::new("Read", "/file1.txt"));
detector.record(Operation::new("Read", "/file2.txt"));
detector.record(Operation::new("Bash", "ls -la"));
let status = detector.check_loop();
assert!(!status.is_looping);
}
#[test]
fn test_turn_limit() {
let config = LoopDetectorConfig {
max_tools_per_turn: 5,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
for i in 0..4 {
detector.record(Operation::new("Read", format!("/file{i}.txt")));
assert!(!detector.check_turn_limit());
}
detector.record(Operation::new("Read", "/file5.txt"));
assert!(detector.check_turn_limit());
detector.reset_turn();
assert!(!detector.check_turn_limit());
}
#[test]
fn test_file_read_limit() {
let config = LoopDetectorConfig {
max_same_file_reads: 3,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
for _ in 0..2 {
detector.record(Operation::new("Read", "/test.txt"));
assert!(!detector.check_file_reads("/test.txt"));
}
detector.record(Operation::new("Read", "/test.txt"));
assert!(detector.check_file_reads("/test.txt"));
}
#[test]
fn test_should_stop_below_threshold() {
let config = LoopDetectorConfig {
stop_threshold: 10,
repetition_threshold: 3,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
for _ in 0..5 {
detector.record(Operation::new("Bash", "git status"));
}
let status = detector.check_loop();
assert!(status.is_looping);
assert!(!status.should_stop);
}
#[test]
fn test_should_stop_at_threshold() {
let config = LoopDetectorConfig {
stop_threshold: 10,
repetition_threshold: 3,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
for _ in 0..10 {
detector.record(Operation::new("Bash", "git status"));
}
let status = detector.check_loop();
assert!(status.is_looping);
assert!(status.should_stop);
assert!(status.warning.as_ref().unwrap().contains("STOPPING"));
}
#[test]
fn test_should_stop_disabled() {
let config = LoopDetectorConfig {
stop_threshold: 0,
repetition_threshold: 3,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
for _ in 0..20 {
detector.record(Operation::new("Bash", "git status"));
}
let status = detector.check_loop();
assert!(status.is_looping);
assert!(!status.should_stop);
}
#[test]
fn test_different_operations_no_loop() {
let detector = test_detector();
for i in 0..20 {
detector.record(Operation::new("Bash", format!("git status {i}")));
}
let status = detector.check_loop();
assert!(!status.is_looping);
assert!(!status.should_stop);
}
#[test]
fn test_loop_detection_with_same_results() {
let detector = test_detector();
let result_hash = hash_result("same output");
for _ in 0..5 {
detector.record(Operation::new("Bash", "git status").with_result_hash(result_hash));
}
let status = detector.check_loop();
assert!(status.is_looping);
assert!(status.repetition_count >= 3);
assert!(
status
.warning
.as_ref()
.unwrap()
.contains("with same result")
);
}
#[test]
fn test_loop_detection_with_different_results() {
let detector = test_detector();
for i in 0..5 {
let result_hash = hash_result(&format!("output {i}"));
detector.record(Operation::new("Bash", "git status").with_result_hash(result_hash));
}
let status = detector.check_loop();
assert!(!status.is_looping);
}
#[test]
fn test_record_from_input_with_recoverable_error_clears_warnings() {
use std::sync::Arc;
struct RecoverableEditSig;
impl ToolSignature for RecoverableEditSig {
fn extract_primary_param(&self, tool: &str, input: &serde_json::Value) -> String {
if tool == "Edit" {
input
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
} else {
String::new()
}
}
fn is_recoverable_error(&self, tool: &str, error: &str) -> bool {
tool == "Edit" && error.contains("old text not found")
}
fn is_file_edit_tool(&self, tool: &str) -> bool {
tool == "Edit"
}
}
let detector =
LoopDetector::new(LoopDetectorConfig::default(), Arc::new(RecoverableEditSig));
let hash = hash_result("same output");
for _ in 0..4 {
detector.record(Operation::new("Edit", "/src/main.rs").with_result_hash(hash));
}
let status = detector.check_loop();
assert!(status.is_looping, "should detect loop after repeated edits");
detector.record_from_input_with_error(
"Edit",
&serde_json::json!({"file_path": "/src/main.rs"}),
None,
Some("Error: old text not found at line 5"),
);
let status2 = detector.check_loop();
assert!(
status2.warning.is_none(),
"warning should be cleared after recoverable error, got: {:?}",
status2.warning
);
}
#[test]
fn test_record_from_input_with_non_recoverable_error_keeps_warnings() {
use std::sync::Arc;
struct PartialSig;
impl ToolSignature for PartialSig {
fn is_recoverable_error(&self, _tool: &str, _error: &str) -> bool {
false }
}
let detector = LoopDetector::new(LoopDetectorConfig::default(), Arc::new(PartialSig));
let hash = hash_result("same output");
for _ in 0..4 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash));
}
let status = detector.check_loop();
assert!(status.is_looping);
detector.record_from_input_with_error(
"Bash",
&serde_json::json!({"command": "git status"}),
None,
Some("permission denied"),
);
let status2 = detector.check_loop();
assert!(
status2.is_looping,
"loop should still be detected after non-recoverable error"
);
}
#[test]
fn test_warning_not_repeated_for_same_operation() {
let detector = test_detector();
let result_hash = hash_result("same output");
for _ in 0..5 {
detector.record(Operation::new("Bash", "git status").with_result_hash(result_hash));
}
let status1 = detector.check_loop();
assert!(status1.is_looping);
assert!(status1.warning.is_some());
let status2 = detector.check_loop();
assert!(status2.is_looping);
assert!(status2.warning.is_none());
}
#[test]
fn test_reset_clears_all_state() {
let detector = test_detector();
let hash1 = hash_result("same output");
for _ in 0..5 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status1 = detector.check_loop();
assert!(status1.is_looping);
assert!(status1.repetition_count >= 5);
assert!(status1.warning.is_some());
assert!(detector.turn_count() > 0);
detector.reset();
assert_eq!(detector.turn_count(), 0);
let status2 = detector.check_loop();
assert!(!status2.is_looping);
assert!(status2.warning.is_none());
}
#[test]
fn test_operations_with_none_result_hash() {
let detector = test_detector();
for _ in 0..5 {
detector.record(Operation::new("Bash", "git status"));
}
let status = detector.check_loop();
assert!(status.is_looping);
}
#[test]
fn test_hash_result_empty_string() {
assert_eq!(hash_result(""), None);
}
#[test]
fn test_hash_result_non_empty() {
let hash1 = hash_result("some content");
let hash2 = hash_result("some content");
let hash3 = hash_result("different content");
assert!(hash1.is_some());
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
}
#[test]
fn test_noop_tool_signature() {
let sig = NoOpToolSignature;
assert_eq!(
sig.extract_primary_param("Read", &json!({"file_path": "/test"})),
""
);
assert!(!sig.is_recoverable_error("Edit", "old text not found"));
assert!(sig.get_suggestion("Edit").is_none());
}
#[test]
fn test_record_from_input() {
let detector = test_detector();
detector.record_from_input("Read", &json!({"file_path": "/test.txt"}), None);
assert_eq!(detector.turn_count(), 1);
}
#[test]
fn test_tool_specific_threshold_in_config() {
let mut config = LoopDetectorConfig::default();
assert_eq!(config.threshold_for_tool("Edit"), 3);
assert_eq!(config.threshold_for_tool("Bash"), 3);
config.tool_thresholds.insert("Edit".to_string(), 2);
assert_eq!(config.threshold_for_tool("Edit"), 2);
assert_eq!(config.threshold_for_tool("Bash"), 3);
}
#[test]
fn test_detector_with_custom_signature() {
let detector = LoopDetector::new_with_signature(Arc::new(TestToolSignature));
detector.record(Operation::new("Read", "/test.txt"));
detector.record(Operation::new("Read", "/test.txt"));
detector.record(Operation::new("Read", "/test.txt"));
let status = detector.check_loop();
assert!(status.is_looping);
}
#[test]
fn test_warning_shown_again_after_clear() {
let detector = test_detector();
let hash1 = hash_result("output 1");
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status1 = detector.check_loop();
assert!(status1.warning.is_some());
detector.clear();
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status2 = detector.check_loop();
assert!(status2.warning.is_some());
}
#[test]
fn test_warning_cleared_when_result_changes() {
let detector = test_detector();
let hash1 = hash_result("same output");
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status1 = detector.check_loop();
assert!(status1.warning.is_some());
let hash2 = hash_result("different output - progress!");
detector.record(Operation::new("Bash", "git status").with_result_hash(hash2));
let status_cleared = detector.check_loop();
assert!(
status_cleared.warning.is_none(),
"warning should be cleared when result hash changes"
);
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status2 = detector.check_loop();
assert!(status2.warning.is_some());
}
#[test]
fn test_result_hash_differentiates_operations() {
let detector = test_detector();
for i in 0..5 {
let hash = hash_result(&format!("output {i}"));
detector.record(Operation::new("Bash", "git status").with_result_hash(hash));
}
let status = detector.check_loop();
assert!(!status.is_looping, "Different results should not be a loop");
}
#[test]
fn test_same_operation_same_result_is_loop() {
let detector = test_detector();
let hash = hash_result("same output every time");
for _ in 0..5 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash));
}
let status = detector.check_loop();
assert!(
status.is_looping,
"Same command with same result should be a loop"
);
}
#[test]
fn test_reset_allows_new_warnings_for_same_operations() {
let detector = test_detector();
let hash1 = hash_result("same output");
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status1 = detector.check_loop();
assert!(status1.warning.is_some());
let status2 = detector.check_loop();
assert!(status2.warning.is_none());
detector.reset();
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status3 = detector.check_loop();
assert!(status3.warning.is_some());
}
#[test]
fn test_mixed_same_and_different_results() {
let detector = test_detector();
let hash1 = hash_result("output 1");
let hash2 = hash_result("output 2");
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
detector.record(Operation::new("Bash", "git status").with_result_hash(hash2));
let hash3 = hash_result("same output");
detector.record(Operation::new("Bash", "git status").with_result_hash(hash3));
detector.record(Operation::new("Bash", "git status").with_result_hash(hash3));
detector.record(Operation::new("Bash", "git status").with_result_hash(hash3));
let status = detector.check_loop();
assert!(status.is_looping);
assert_eq!(status.repetition_count, 3);
}
#[test]
fn test_warning_not_cleared_when_result_stays_same() {
let detector = test_detector();
let hash1 = hash_result("same output");
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
}
let status1 = detector.check_loop();
assert!(status1.warning.is_some());
detector.record(Operation::new("Bash", "git status").with_result_hash(hash1));
let status2 = detector.check_loop();
assert!(
status2.is_looping,
"loop should still be detected when result hash stays the same"
);
}
#[test]
fn test_suggestion_from_signature() {
let detector = test_detector();
for _ in 0..3 {
detector.record(Operation::new("Bash", "git status"));
}
let status = detector.check_loop();
assert!(status.warning.is_some());
let warning = status.warning.unwrap();
assert!(
warning.contains("Check the command"),
"Bash warning should contain signature suggestion: {warning}",
);
}
fn make_ops(pairs: &[(&str, &str)]) -> VecDeque<Operation> {
pairs
.iter()
.map(|&(tool, param)| Operation::new(tool, param))
.collect()
}
fn make_ops_hashed(pairs: &[(&str, &str, &str)]) -> VecDeque<Operation> {
pairs
.iter()
.map(|&(tool, param, result)| {
Operation::new(tool, param).with_result_hash(hash_result(result))
})
.collect()
}
#[test]
fn test_count_operations_empty() {
let ops: VecDeque<Operation> = VecDeque::new();
let counts = LoopDetector::count_operations(&ops);
assert!(counts.is_empty());
}
#[test]
fn test_count_operations_single_op() {
let ops = make_ops(&[("Bash", "ls"), ("Bash", "ls"), ("Bash", "ls")]);
let counts = LoopDetector::count_operations(&ops);
assert_eq!(counts.len(), 1);
assert_eq!(counts.get(&Operation::new("Bash", "ls")), Some(&3));
}
#[test]
fn test_count_operations_distinct_ops() {
let ops = make_ops(&[("Bash", "ls"), ("Read", "file.txt"), ("Bash", "ls")]);
let counts = LoopDetector::count_operations(&ops);
assert_eq!(counts.len(), 2);
assert_eq!(counts.get(&Operation::new("Bash", "ls")), Some(&2));
assert_eq!(counts.get(&Operation::new("Read", "file.txt")), Some(&1));
}
#[test]
fn test_count_operations_different_hashes_are_distinct() {
let ops = make_ops_hashed(&[("Bash", "ls", "output_a"), ("Bash", "ls", "output_b")]);
let counts = LoopDetector::count_operations(&ops);
assert_eq!(counts.len(), 2);
}
#[test]
fn test_count_operations_same_hashes_are_grouped() {
let ops = make_ops_hashed(&[
("Bash", "ls", "same_output"),
("Bash", "ls", "same_output"),
("Bash", "ls", "same_output"),
]);
let counts = LoopDetector::count_operations(&ops);
assert_eq!(counts.len(), 1);
let key = Operation::new("Bash", "ls").with_result_hash(hash_result("same_output"));
assert_eq!(counts.get(&key), Some(&3));
}
#[test]
fn test_find_repeated_none() {
let ops = make_ops(&[("Bash", "ls"), ("Read", "f.txt")]);
let (repeated, max) = LoopDetector::find_repeated(&ops, |_tool| 3);
assert!(repeated.is_empty());
assert_eq!(max, 0);
}
#[test]
fn test_find_repeated_single_above_threshold() {
let ops = make_ops(&[("Bash", "ls"); 5]);
let (repeated, max) = LoopDetector::find_repeated(&ops, |_tool| 3);
assert_eq!(max, 5);
assert_eq!(repeated.len(), 1);
assert_eq!(repeated[0], Operation::new("Bash", "ls"));
}
#[test]
fn test_find_repeated_tie_keeps_both() {
let mut ops = VecDeque::new();
for _ in 0..3 {
ops.push_back(Operation::new("Bash", "ls"));
}
for _ in 0..3 {
ops.push_back(Operation::new("Read", "f.txt"));
}
let (repeated, max) = LoopDetector::find_repeated(&ops, |_tool| 3);
assert_eq!(max, 3);
assert_eq!(repeated.len(), 2);
}
#[test]
fn test_find_repeated_per_tool_threshold() {
let mut ops = VecDeque::new();
for _ in 0..2 {
ops.push_back(Operation::new("Bash", "ls"));
}
for _ in 0..5 {
ops.push_back(Operation::new("Read", "f.txt"));
}
let (repeated, max) =
LoopDetector::find_repeated(&ops, |tool| if tool == "Bash" { 3 } else { 4 });
assert_eq!(max, 5);
assert_eq!(repeated.len(), 1);
assert_eq!(repeated[0].tool, "Read");
}
#[test]
fn test_find_repeated_higher_count_wins() {
let mut ops = VecDeque::new();
for _ in 0..5 {
ops.push_back(Operation::new("Bash", "ls"));
}
for _ in 0..3 {
ops.push_back(Operation::new("Read", "f.txt"));
}
let (repeated, max) = LoopDetector::find_repeated(&ops, |_tool| 2);
assert_eq!(max, 5);
assert_eq!(repeated.len(), 1);
assert_eq!(repeated[0].tool, "Bash");
}
#[test]
fn test_build_warning_not_looping() {
let detector = test_detector();
let warning = detector.build_warning(&[], 0, false, false);
assert!(warning.is_none());
}
#[test]
fn test_build_warning_first_warning() {
let detector = test_detector();
let ops = vec![Operation::new("Bash", "ls")];
let warning = detector.build_warning(&ops, 3, true, false);
assert!(warning.is_some());
let msg = warning.unwrap();
assert!(msg.contains("Bash(ls)"));
assert!(msg.contains("3 times"));
assert!(!msg.contains("STOPPING"));
}
#[test]
fn test_build_warning_includes_stop_message() {
let detector = test_detector();
let ops = vec![Operation::new("Bash", "ls")];
let warning = detector.build_warning(&ops, 5, true, true);
assert!(warning.is_some());
let msg = warning.unwrap();
assert!(msg.contains("STOPPING"));
}
#[test]
fn test_build_warning_suppresses_duplicate() {
let detector = test_detector();
let ops = vec![Operation::new("Bash", "ls")];
let w1 = detector.build_warning(&ops, 3, true, false);
assert!(w1.is_some());
let w2 = detector.build_warning(&ops, 3, true, false);
assert!(w2.is_none());
}
#[test]
fn test_build_warning_duplicate_not_suppressed_when_stopping() {
let detector = test_detector();
let ops = vec![Operation::new("Bash", "ls")];
let w1 = detector.build_warning(&ops, 3, true, false);
assert!(w1.is_some());
let w2 = detector.build_warning(&ops, 3, true, true);
assert!(w2.is_some());
assert!(w2.unwrap().contains("STOPPING"));
}
#[test]
fn test_build_warning_includes_suggestion() {
let detector = test_detector();
let ops = vec![Operation::new("Bash", "git status")];
let warning = detector.build_warning(&ops, 3, true, false);
assert!(warning.is_some());
let msg = warning.unwrap();
assert!(
msg.contains("Check the command"),
"Should contain tool signature suggestion: {msg}"
);
}
}