malvin 0.2.9

Non-interactive research and coding agent
use super::backend_error_stop::{
    LOCAL_BACKEND_GPU_MEMORY_HINT, backend_error_stop, error_suggests_local_gpu_memory,
};
use super::backend_error_tracker::{
    BackendErrorTracker, LOCAL_MAX_BACKEND_ERRORS, format_local_backend_retry_cap_message,
};
use super::test_support::test_io;

fn client_for(model: &str) -> super::SdkClient {
    let model = crate::model_id::parse_model_id(model).expect("model");
    super::SdkClient::with_max_retries(model, test_io(), 3)
}

#[test]
fn tracker_counts_differing_errors_toward_local_cap() {
    let mut tracker = BackendErrorTracker::with_limit(
        super::backend_error_tracker::AcpRetryCount::at_least_one(3).as_consecutive_limit(),
    );
    for i in 1..LOCAL_MAX_BACKEND_ERRORS {
        assert!(!tracker.record_error(&format!("error {i}")));
        assert_eq!(tracker.local_retry_cap_errors(), None);
    }
    assert!(!tracker.record_error("error last"));
    assert_eq!(
        tracker.local_retry_cap_errors(),
        Some(LOCAL_MAX_BACKEND_ERRORS)
    );
    tracker.record_success();
    assert_eq!(tracker.local_retry_cap_errors(), None);
}

#[test]
fn local_client_stops_on_differing_errors_at_cap_with_hint() {
    let mut client = client_for("rpi:ollama/tiny");
    for i in 1..LOCAL_MAX_BACKEND_ERRORS {
        assert!(backend_error_stop(&mut client, &format!("Compute error {i}")).is_none());
    }
    let stop = backend_error_stop(&mut client, "Compute error last").expect("local cap stops");
    assert_eq!(stop.fault, crate::acp::AgentFault::BackendRetryLimit);
    assert!(!stop.requires_coder_session_teardown());
    assert!(stop.message.contains("without a successful turn"), "{stop}");
    assert!(
        stop.message.contains(LOCAL_BACKEND_GPU_MEMORY_HINT),
        "{stop}"
    );
}

#[test]
fn cloud_client_has_no_local_cap_or_hint() {
    let mut client = client_for("rpi:openai/gpt-4o");
    for i in 1..=LOCAL_MAX_BACKEND_ERRORS * 2 {
        assert!(backend_error_stop(&mut client, &format!("Compute error {i}")).is_none());
    }
    for _ in 1..3 {
        assert!(backend_error_stop(&mut client, "Compute error").is_none());
    }
    let stop = backend_error_stop(&mut client, "Compute error").expect("same-error stop");
    assert!(stop.message.contains("repeated 3 times in a row"), "{stop}");
    assert!(
        !stop.message.contains(LOCAL_BACKEND_GPU_MEMORY_HINT),
        "{stop}"
    );
}

#[test]
fn local_same_error_stop_carries_hint_only_for_matching_errors() {
    let mut client = client_for("rpi:ollama/tiny");
    for _ in 1..3 {
        assert!(backend_error_stop(&mut client, "HTTP 500").is_none());
    }
    let stop = backend_error_stop(&mut client, "HTTP 500").expect("same-error stop");
    assert!(
        !stop.message.contains(LOCAL_BACKEND_GPU_MEMORY_HINT),
        "{stop}"
    );
}

#[test]
fn gpu_memory_needles_match_reported_errors() {
    assert!(error_suggests_local_gpu_memory("Compute error"));
    assert!(error_suggests_local_gpu_memory(
        "OpenAI API protocol error (HTTP 200): unexpected Content-Type application/x-ndjson (expected text/event-stream)"
    ));
    assert!(!error_suggests_local_gpu_memory(
        "HTTP 500 internal server error"
    ));
}

#[test]
fn local_retry_cap_message_names_limits() {
    let msg = format_local_backend_retry_cap_message("rpi", "boom", 10);
    assert!(msg.contains("failed 10 times"), "{msg}");
    assert!(msg.contains("300 s"), "{msg}");
    assert!(msg.ends_with("Last error:\nboom"), "{msg}");
}