malvin 0.2.5

Non-interactive research and coding agent
use crate::acp::{
    AgentRetryOutcome, agent_string_is_cannot_use_model,
    agent_string_is_openrouter_billing_failure, agent_string_is_upgrade_plan,
    agent_string_is_usage_limit, plan_agent_retry, retries_noun, upgrade_plan_stream_from_buffer,
};
use crate::support_paths::DEFAULT_MAX_ACP_RETRIES;
use std::time::Duration;

const TEST_MAX_ATTEMPTS: u32 = DEFAULT_MAX_ACP_RETRIES;

fn openrouter_billing_failure_substring_is_detected_case_insensitively() {
    assert!(agent_string_is_openrouter_billing_failure(
        "OpenRouter billing/credit failure (402): no credits"
    ));
    assert!(!agent_string_is_openrouter_billing_failure("timed out"));
}

fn openrouter_billing_errors_do_not_retry_even_with_high_max() {
    let msg = "mini HTTP failed after 1 transport attempts (limit 3): OpenRouter billing/credit failure (402): no credits";
    let err = plan_agent_retry(msg, 1, 9999).expect_err("billing must fail fast");
    assert_eq!(err.message, msg);
}

fn insufficient_credits_provider_phrasing_fails_fast() {
    let msg = "mini HTTP failed after 1 transport attempts (limit 3): Provider: Insufficient credits. Add more using https://openrouter.ai/settings/credits";
    assert!(agent_string_is_openrouter_billing_failure(msg));
    let err = plan_agent_retry(msg, 1, 9999).expect_err("insufficient credits must fail fast");
    assert_eq!(err.message, msg);
}

fn upgrade_plan_substring_is_detected_case_insensitively() {
    assert!(agent_string_is_upgrade_plan(
        "Error: Upgrade Your Plan To Continue"
    ));
    assert!(!agent_string_is_upgrade_plan("timed out"));
}

fn upgrade_plan_errors_do_not_retry() {
    let msg = "billing: upgrade your plan to continue";
    let err = plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS).expect_err("upgrade plan must fail fast");
    assert_eq!(err.message, msg);
}

fn upgrade_plan_stream_from_buffer_tracks_split_coalesce() {
    assert!(!upgrade_plan_stream_from_buffer("Upgrade your"));
    assert!(upgrade_plan_stream_from_buffer(
        "Upgrade your plan to continue"
    ));
}

fn cannot_use_model_errors_do_not_retry() {
    let msg = "Error: Cannot use this model with that provider";
    assert!(agent_string_is_cannot_use_model(msg));
    let err =
        plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS).expect_err("invalid model must fail fast");
    assert_eq!(err.message, msg);
}

fn usage_limit_substring_is_detected_case_insensitively() {
    assert!(agent_string_is_usage_limit("You've hit your usage limit"));
    assert!(agent_string_is_usage_limit(
        "Error: YOU'VE HIT YOUR USAGE LIMIT\nSwitch to a different model"
    ));
    assert!(!agent_string_is_usage_limit("timed out"));
    assert!(!agent_string_is_usage_limit("usage is fine"));
}

fn usage_limit_errors_do_not_retry_even_with_high_max() {
    let msg =
        "You've hit your usage limit\nYou've saved $2502 on API model usage this month with Ultra.";
    let err = plan_agent_retry(msg, 1, 9999).expect_err("usage limit must fail fast");
    assert_eq!(err.message, msg);
}

fn cannot_use_model_fails_fast_even_when_error_also_looks_retriable() {
    let msg = "rpc [unavailable]: Cannot use this model";
    let err = plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS)
        .expect_err("model error must beat retriable match");
    assert_eq!(err.message, msg);
}

fn transient_errors_retry_with_backoff() {
    for msg in [
        "request timed out",
        "DEADLINE EXCEEDED",
        "WritableIterable is closed",
        "child process is zombie",
        "session/new failed",
        "rpc [unavailable]",
    ] {
        assert!(
            matches!(
                plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS).unwrap(),
                AgentRetryOutcome::Sleep(_)
            ),
            "{msg}"
        );
    }
}

fn unknown_errors_retry_with_backoff() {
    for msg in [
        "acp child process appears hung",
        "invalid json",
        "failed to spawn agent acp: No such file",
    ] {
        assert!(
            matches!(
                plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS).unwrap(),
                AgentRetryOutcome::Sleep(_)
            ),
            "{msg}"
        );
    }
}

fn assert_retriable_sleep_secs(attempt: u32, expected_secs: u64) {
    let out = plan_agent_retry("timed out", attempt, TEST_MAX_ATTEMPTS).unwrap();
    match out {
        AgentRetryOutcome::Sleep(d) => assert_eq!(d, Duration::from_secs(expected_secs)),
        AgentRetryOutcome::StopRetrying => {
            panic!("expected Sleep({expected_secs}s), got StopRetrying")
        }
    }
}

fn retriable_first_attempt_sleeps_one_second() {
    assert_retriable_sleep_secs(1, 1);
}

fn retriable_second_attempt_sleeps_three_seconds() {
    assert_retriable_sleep_secs(2, 3);
}

fn retriable_exhausts_after_max_agent_attempts() {
    let out = plan_agent_retry("timed out", TEST_MAX_ATTEMPTS, TEST_MAX_ATTEMPTS).unwrap();
    assert!(matches!(out, AgentRetryOutcome::StopRetrying), "{out:?}");
}

fn retriable_exhausts_after_custom_max_attempts() {
    let custom_max = 5_u32;
    let out = plan_agent_retry("timed out", custom_max, custom_max).unwrap();
    assert!(matches!(out, AgentRetryOutcome::StopRetrying), "{out:?}");
    assert!(matches!(
        plan_agent_retry("timed out", custom_max - 1, custom_max).unwrap(),
        AgentRetryOutcome::Sleep(_)
    ));
}

fn slot_restore_error_stops_retrying_without_sleep() {
    let msg = "malvin_checks restore: disk full";
    let out = plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS).unwrap();
    assert!(matches!(out, AgentRetryOutcome::StopRetrying), "{out:?}");
}

fn restore_failure_stops_retrying_without_sleep() {
    let msg = "prompt failed; workspace session restore failed (restore): disk full";
    let out = plan_agent_retry(msg, 1, TEST_MAX_ATTEMPTS).unwrap();
    assert!(matches!(out, AgentRetryOutcome::StopRetrying), "{out:?}");
}

fn retries_noun_singular_and_plural() {
    assert_eq!(retries_noun(1), "retry");
    assert_eq!(retries_noun(2), "retries");
}

#[test]
fn kiss_bundled_acp_reader_tests_retry_policy() {
    openrouter_billing_failure_substring_is_detected_case_insensitively();
    openrouter_billing_errors_do_not_retry_even_with_high_max();
    insufficient_credits_provider_phrasing_fails_fast();
    upgrade_plan_substring_is_detected_case_insensitively();
    upgrade_plan_errors_do_not_retry();
    upgrade_plan_stream_from_buffer_tracks_split_coalesce();
    cannot_use_model_errors_do_not_retry();
    usage_limit_substring_is_detected_case_insensitively();
    usage_limit_errors_do_not_retry_even_with_high_max();
    cannot_use_model_fails_fast_even_when_error_also_looks_retriable();
    transient_errors_retry_with_backoff();
    unknown_errors_retry_with_backoff();
    retriable_first_attempt_sleeps_one_second();
    retriable_second_attempt_sleeps_three_seconds();
    retriable_exhausts_after_max_agent_attempts();
    retriable_exhausts_after_custom_max_attempts();
    slot_restore_error_stops_retrying_without_sleep();
    restore_failure_stops_retrying_without_sleep();
    retries_noun_singular_and_plural();
}