tool-loop-guard 0.1.0

Detect when an LLM agent gets stuck calling the same tool with the same args. Sliding-window guard that raises on repeated (tool_name, args) pairs. Custom key function support, zero heavy deps.
Documentation
//! End-to-end behavior tests, mirroring tool-loop-guard's Python suite.

use serde_json::{json, Value};
use tool_loop_guard::{default_key_fn, ConfigError, LoopDetectedError, LoopGuard};

// ---- key function ---------------------------------------------------------

#[test]
fn default_key_includes_tool_name() {
    let args = json!({"q": "x"});
    let a = default_key_fn("search", &args);
    let b = default_key_fn("fetch", &args);
    assert_ne!(a, b);
}

#[test]
fn default_key_canonicalizes_arg_order() {
    let a = default_key_fn("t", &json!({"a": 1, "b": 2}));
    let b = default_key_fn("t", &json!({"b": 2, "a": 1}));
    assert_eq!(a, b);
}

#[test]
fn default_key_null_args_is_empty_object() {
    let a = default_key_fn("t", &Value::Null);
    let b = default_key_fn("t", &json!({}));
    assert_eq!(a, b);
    assert_eq!(a.1, "{}");
}

#[test]
fn default_key_nested_canonicalization() {
    // Sorting must recurse into nested objects.
    let a = default_key_fn("t", &json!({"o": {"a": 1, "b": 2}, "p": [3, {"d": 4, "c": 5}]}));
    let b = default_key_fn("t", &json!({"p": [3, {"c": 5, "d": 4}], "o": {"b": 2, "a": 1}}));
    assert_eq!(a, b);
}

// ---- happy path ----------------------------------------------------------

#[test]
fn records_under_threshold_does_not_raise() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let args = json!({"q": "x"});
    guard.record("search", &args).unwrap();
    guard.record("search", &args).unwrap();
    assert_eq!(guard.len(), 2);
}

#[test]
fn third_match_raises_loop_detected() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let args = json!({"q": "x"});
    guard.record("search", &args).unwrap();
    guard.record("search", &args).unwrap();
    let err = guard.record("search", &args).unwrap_err();
    assert_eq!(err.tool_name, "search");
    assert_eq!(err.count, 3);
    assert_eq!(err.window, 4);
    assert_eq!(err.threshold, 3);
}

#[test]
fn different_args_do_not_count() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    guard.record("search", &json!({"q": "a"})).unwrap();
    guard.record("search", &json!({"q": "b"})).unwrap();
    guard.record("search", &json!({"q": "c"})).unwrap();
}

#[test]
fn different_tools_do_not_count() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let args = json!({"q": "x"});
    guard.record("search", &args).unwrap();
    guard.record("fetch", &args).unwrap();
    guard.record("write", &args).unwrap();
}

// ---- window eviction ------------------------------------------------------

#[test]
fn eviction_keeps_unrelated_loops_safe() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let q = json!({"q": "x"});
    let empty = json!({});
    guard.record("search", &q).unwrap();
    guard.record("fill_a", &empty).unwrap();
    guard.record("fill_b", &empty).unwrap();
    guard.record("fill_c", &empty).unwrap();
    guard.record("search", &q).unwrap();
    assert_eq!(guard.len(), 4);
}

#[test]
fn old_match_evicted_before_threshold() {
    let mut guard = LoopGuard::with_capacity(3, 2);
    let q = json!({"q": "x"});
    guard.record("search", &q).unwrap();
    guard.record("filler", &json!({"k": 1})).unwrap();
    guard.record("filler", &json!({"k": 2})).unwrap();
    // The first search is the oldest; this append evicts it before counting.
    guard.record("search", &q).unwrap();
    assert_eq!(guard.len(), 3);
}

// ---- would_raise ----------------------------------------------------------

#[test]
fn would_raise_predicts_next_record_outcome() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let x = json!({"q": "x"});
    let y = json!({"q": "y"});
    guard.record("search", &x).unwrap();
    guard.record("search", &x).unwrap();
    assert!(guard.would_raise("search", &x));
    assert!(!guard.would_raise("search", &y));
}

#[test]
fn would_raise_does_not_mutate_buffer() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let x = json!({"q": "x"});
    guard.record("search", &x).unwrap();
    guard.record("search", &x).unwrap();
    let len_before = guard.len();
    let _ = guard.would_raise("search", &x);
    let _ = guard.would_raise("search", &json!({"q": "y"}));
    assert_eq!(guard.len(), len_before);
}

#[test]
fn would_raise_accounts_for_full_window_eviction() {
    let mut guard = LoopGuard::with_capacity(3, 2);
    guard.record("a", &Value::Null).unwrap();
    guard.record("b", &Value::Null).unwrap();
    guard.record("c", &Value::Null).unwrap();
    assert!(!guard.would_raise("fresh", &Value::Null));
}

// ---- reset ---------------------------------------------------------------

#[test]
fn reset_clears_buffer() {
    let mut guard = LoopGuard::with_capacity(4, 3);
    let x = json!({"q": "x"});
    guard.record("search", &x).unwrap();
    guard.record("search", &x).unwrap();
    guard.reset();
    assert_eq!(guard.len(), 0);
    assert!(guard.is_empty());
    // Safe to record again, under threshold.
    guard.record("search", &x).unwrap();
    guard.record("search", &x).unwrap();
}

// ---- recent_keys ----------------------------------------------------------

#[test]
fn recent_keys_returns_oldest_first() {
    let mut guard = LoopGuard::with_capacity(4, 4);
    guard.record("a", &Value::Null).unwrap();
    guard.record("b", &Value::Null).unwrap();
    guard.record("c", &Value::Null).unwrap();
    let names: Vec<&str> = guard.recent_keys().iter().map(|k| k.0.as_str()).collect();
    assert_eq!(names, vec!["a", "b", "c"]);
}

// ---- custom key_fn -------------------------------------------------------

#[test]
fn custom_key_fn_can_ignore_noisy_field() {
    let stable_key = |tool_name: &str, args: &Value| {
        let cleaned = if let Some(map) = args.as_object() {
            let mut clone = map.clone();
            clone.remove("request_id");
            Value::Object(clone)
        } else {
            args.clone()
        };
        default_key_fn(tool_name, &cleaned)
    };
    let mut guard = LoopGuard::with_key_fn(4, 3, stable_key);
    guard
        .record("search", &json!({"q": "x", "request_id": "r1"}))
        .unwrap();
    guard
        .record("search", &json!({"q": "x", "request_id": "r2"}))
        .unwrap();
    let err = guard
        .record("search", &json!({"q": "x", "request_id": "r3"}))
        .unwrap_err();
    assert_eq!(err.count, 3);
}

// ---- argument validation -------------------------------------------------

#[test]
fn invalid_window_rejected() {
    for &w in &[0usize, 1usize] {
        assert!(matches!(
            LoopGuard::try_new(w, 2),
            Err(ConfigError::WindowTooSmall { .. })
        ));
    }
}

#[test]
fn invalid_threshold_rejected() {
    for &t in &[0usize, 1usize] {
        assert!(matches!(
            LoopGuard::try_new(5, t),
            Err(ConfigError::ThresholdTooSmall { .. })
        ));
    }
}

#[test]
fn threshold_larger_than_window_rejected() {
    let err = LoopGuard::try_new(3, 4).unwrap_err();
    assert!(matches!(
        err,
        ConfigError::ThresholdExceedsWindow {
            window: 3,
            threshold: 4
        }
    ));
}

#[test]
#[should_panic(expected = "invalid LoopGuard configuration")]
fn with_capacity_panics_on_invalid_window() {
    let _ = LoopGuard::with_capacity(1, 2);
}

// ---- error info ----------------------------------------------------------

#[test]
fn loop_detected_error_carries_full_context() {
    let mut guard = LoopGuard::with_capacity(3, 2);
    let args = json!({"q": "x"});
    guard.record("search", &args).unwrap();
    let err = guard.record("search", &args).unwrap_err();
    assert_eq!(err.tool_name, "search");
    assert_eq!(err.tool_args, args);
    assert_eq!(err.count, 2);
    assert_eq!(err.window, 3);
    assert_eq!(err.threshold, 2);
}

#[test]
fn loop_detected_error_display_includes_tool_name() {
    let err = LoopDetectedError {
        tool_name: "search".into(),
        tool_args: json!({"q": "x"}),
        count: 3,
        window: 5,
        threshold: 3,
    };
    let s = err.to_string();
    assert!(s.contains("search"), "missing tool name in: {s}");
    assert!(s.contains("threshold=3"), "missing threshold in: {s}");
}

#[test]
fn loop_detected_error_is_std_error() {
    fn assert_error<E: std::error::Error>() {}
    assert_error::<LoopDetectedError>();
}