use super::*;
use chrono::{Datelike, Duration, Local, Timelike};
use crate::types::AgentKind;
fn isolated() -> tempfile::TempDir {
let temp = tempfile::tempdir().expect("temp dir");
std::fs::create_dir_all(temp.path().join(".aid")).expect("aid dir");
temp
}
#[test]
fn a_transient_refusal_with_no_stated_time_expires_on_its_own() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
mark_rate_limited(&AgentKind::Claude, None, "HTTP 429 Too Many Requests");
let info = get_rate_limit_info(&AgentKind::Claude, None).expect("marker written");
assert_eq!(info.recovery_at, None, "no time was stated, so none is invented");
assert!(!info.needs_human, "a bare 429 does not need a person");
assert!(is_rate_limited(&AgentKind::Claude, None), "still inside the cooldown");
age_marker(&marker_path(&AgentKind::Claude, None), RATE_LIMIT_WINDOW_SECS + 60);
assert!(
!is_rate_limited(&AgentKind::Claude, None),
"a transient refusal must not hold a route open indefinitely"
);
}
#[test]
fn a_refusal_that_needs_a_person_is_not_released_by_time() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
mark_rate_limited(&AgentKind::Grok, None, "API error (status 402 Payment Required): Grok Build usage balance exhausted");
let info = get_rate_limit_info(&AgentKind::Grok, None).expect("marker written");
assert_eq!(info.recovery_at, None, "no invented reset time");
assert!(info.needs_human);
age_marker(&marker_path(&AgentKind::Grok, None), RATE_LIMIT_WINDOW_SECS * 100);
assert!(
is_rate_limited(&AgentKind::Grok, None),
"a spent balance must survive any amount of elapsed time"
);
assert!(clear_rate_limit(&AgentKind::Grok, None));
assert!(!is_rate_limited(&AgentKind::Grok, None));
}
#[test]
fn every_human_ended_refusal_holds_without_an_invented_reset_time() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
for (agent, message) in [
(
AgentKind::Cursor,
"ActionRequiredError: Increase limits for faster responses You're out of usage. \
Switch to Auto, or ask your admin to increase your limit to continue.",
),
(AgentKind::Copilot, "You have exceeded your monthly quota"),
(
AgentKind::Copilot,
"You've reached your premium request limit for this billing cycle.",
),
(
AgentKind::Grok,
"API error (status 402 Payment Required): Grok Build usage balance exhausted",
),
(
AgentKind::OpenCode,
"Insufficient balance. Manage your billing here: https://opencode.ai/",
),
(AgentKind::Droid, "402 payment required: reload your tokens"),
(
AgentKind::Gemini,
"IneligibleTierError: This client is no longer supported for Gemini Code \
Assist for individuals",
),
] {
clear_rate_limit(&agent, None);
mark_rate_limited(&agent, None, message);
let info = get_rate_limit_info(&agent, None).expect("marker written");
assert_eq!(
info.recovery_at, None,
"{agent:?} must not be given a reset time it never stated: {message}"
);
assert!(info.needs_human, "{agent:?} must be held for a person: {message}");
age_marker(&marker_path(&agent, None), RATE_LIMIT_WINDOW_SECS * 100);
assert!(is_rate_limited(&agent, None), "{agent:?} hold must survive elapsed time");
clear_rate_limit(&agent, None);
}
}
#[test]
fn clock_ended_refusals_still_get_a_recovery_time() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
for (agent, message) in [
(AgentKind::Qwen, "Quota exhausted: Your token-plan 5-hour quota has been exhausted."),
(AgentKind::Oz, "Error: Quota limit reached."),
(AgentKind::Cursor, "quota exceeded for this workspace"),
(
AgentKind::Antigravity,
"Individual quota reached. Please upgrade your subscription to increase \
your limits. Resets in 59m21s.",
),
(
AgentKind::Droid,
"402 You've reached your weekly standard usage limit (resets in 1 day).",
),
] {
clear_rate_limit(&agent, None);
mark_rate_limited(&agent, None, message);
let info = get_rate_limit_info(&agent, None).expect("marker written");
assert!(
info.recovery_at.is_some(),
"{agent:?} recovers on a clock and must say when: {message}"
);
assert!(!info.needs_human, "{agent:?} must not wait for a person: {message}");
clear_rate_limit(&agent, None);
}
}
#[test]
fn a_stated_reset_time_wins_over_the_class_default() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
mark_rate_limited(&AgentKind::Droid, None, "402 payment required: reload your tokens (resets in 2 hours)");
let info = get_rate_limit_info(&AgentKind::Droid, None).expect("marker written");
assert!(info.recovery_at.is_some(), "the stated time must be recorded");
assert!(!info.needs_human, "a stated time is not a human hold");
}
#[test]
fn an_unparseable_recovery_phrase_falls_back_to_the_cooldown() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let path = marker_path(&AgentKind::Qwen, None);
std::fs::write(&path, "recovery_at: tomorrow morning\nmessage: out of quota\n")
.expect("write marker");
assert!(is_rate_limited(&AgentKind::Qwen, None), "fresh marker still holds");
age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
assert!(
!is_rate_limited(&AgentKind::Qwen, None),
"an unreadable time must expire, not become permanent"
);
}
#[test]
fn live_markers_with_a_future_reset_time_still_read_as_out() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
for (agent, fixture) in [
(AgentKind::Codex, "rate-limit-codex"),
(AgentKind::Qwen, "rate-limit-qwen"),
(AgentKind::Droid, "rate-limit-droid"),
(AgentKind::OpenCode, "rate-limit-opencode"),
] {
let content = with_relative_recovery_time(&read_fixture(fixture), Duration::days(1));
let path = marker_path(&agent, None);
std::fs::write(&path, &content).expect("write marker");
age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
assert!(
is_rate_limited(&agent, None),
"{fixture} states a future reset time and must still hold"
);
}
}
#[test]
fn a_marker_whose_stated_time_has_passed_reads_as_recovered() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let content = with_relative_recovery_time(&read_fixture("rate-limit-oz"), Duration::minutes(-1));
std::fs::write(marker_path(&AgentKind::Oz, None), content)
.expect("write marker");
assert!(
!is_rate_limited(&AgentKind::Oz, None),
"a reset time in the past means the route is available again"
);
}
#[test]
fn a_marker_without_a_hold_field_is_not_permanent() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let path = marker_path(&AgentKind::Copilot, None);
std::fs::write(&path, "recovery_at: \nmessage: some old refusal\n").expect("write marker");
age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
assert!(!is_rate_limited(&AgentKind::Copilot, None));
}
#[test]
fn a_group_marker_holds_for_a_person_too() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let cursor = AgentKind::Cursor;
mark_group_rate_limited(&cursor, None, "premium", "ActionRequiredError: Increase limits for faster responses You're out of usage. \
Switch to Auto, or ask your admin to increase your limit to continue.");
age_marker(&group_marker_path(&cursor, None, "premium"), RATE_LIMIT_WINDOW_SECS * 100);
assert!(is_group_rate_limited(&cursor, None, "premium"));
assert!(!is_group_rate_limited(&cursor, None, "auto"), "auto keeps serving");
assert!(!is_rate_limited(&cursor, None), "the agent itself is not written off");
let holds = active_group_holds(&cursor, None);
assert_eq!(holds.len(), 1);
assert_eq!(holds[0].0, "premium");
assert!(holds[0].1.needs_human);
assert!(
format_hold_end(&cursor, None, &holds[0].1).contains("aid config clear-limit cursor"),
"manual group hold must name the clear command"
);
assert!(clear_all_rate_limits_for_agent(&cursor, None));
assert!(!is_group_rate_limited(&cursor, None, "premium"));
assert!(active_group_holds(&cursor, None).is_empty());
}
#[test]
fn a_cursor_premium_refusal_with_no_model_in_hand_holds_only_the_premium_pool() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let cursor = AgentKind::Cursor;
mark_rate_limited_for_message(&cursor, None, "ActionRequiredError: Increase limits for faster responses You're out of usage. \
Switch to Auto, or ask your admin to increase your limit to continue.");
assert!(is_group_rate_limited(&cursor, None, "premium"), "the spent pool is held");
assert!(!is_group_rate_limited(&cursor, None, "auto"), "auto keeps serving");
assert!(!is_rate_limited(&cursor, None), "the agent as a whole is not written off");
assert!(
dispatch_blocking_hold(&cursor, None).is_none(),
"aid run must still dispatch cursor — on auto"
);
}
#[test]
fn cursors_premium_refusal_is_read_off_the_stderr_channel() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let stderr = "ActionRequiredError: Increase limits for faster responses You're out of usage. \
Switch to Auto, or ask your admin to increase your limit to continue.";
let refusal = refusal_on_channel(
stderr,
AgentKind::Cursor,
crate::quota_channel::Channel::CliStderr,
)
.expect("cursor's premium refusal must be readable on stderr");
mark_rate_limited_for_message(&AgentKind::Cursor, None, &refusal);
assert!(is_group_rate_limited(&AgentKind::Cursor, None, "premium"));
assert!(!is_group_rate_limited(&AgentKind::Cursor, None, "auto"));
assert!(!is_rate_limited(&AgentKind::Cursor, None));
}
#[test]
fn a_cursor_refusal_naming_no_tier_still_marks_the_agent() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let cursor = AgentKind::Cursor;
mark_rate_limited_for_message(&cursor, None, "Quota exceeded for this workspace");
assert!(is_rate_limited(&cursor, None));
assert!(!is_group_rate_limited(&cursor, None, "premium"));
}
#[test]
fn a_human_ended_hold_blocks_dispatch_and_names_the_way_out() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
mark_rate_limited(&AgentKind::Grok, None, "API error (status 402 Payment Required): Grok Build usage balance exhausted");
let hold = dispatch_blocking_hold(&AgentKind::Grok, None).expect("a spent balance must block");
assert_eq!(hold, "until cleared with `aid config clear-limit grok`");
assert!(clear_rate_limit(&AgentKind::Grok, None));
assert!(dispatch_blocking_hold(&AgentKind::Grok, None).is_none());
}
#[test]
fn a_stated_time_blocks_dispatch_only_until_it_passes() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let content = with_relative_recovery_time(&read_fixture("rate-limit-codex"), Duration::days(1));
let stated = content
.lines()
.find_map(|line| line.strip_prefix("recovery_at: "))
.map(str::to_string)
.expect("fixture must state a recovery time");
std::fs::write(marker_path(&AgentKind::Codex, None), content)
.expect("write marker");
assert_eq!(
dispatch_blocking_hold(&AgentKind::Codex, None),
Some(format!("until {stated}")),
"the provider's own phrasing of the time is quoted back"
);
let content = with_relative_recovery_time(&read_fixture("rate-limit-oz"), Duration::minutes(-1));
std::fs::write(marker_path(&AgentKind::Oz, None), content).expect("write marker");
assert!(
dispatch_blocking_hold(&AgentKind::Oz, None).is_none(),
"a reset time in the past must not divert a run"
);
}
#[test]
fn a_transient_cooldown_does_not_divert_dispatch() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
mark_rate_limited(&AgentKind::Claude, None, "HTTP 429 Too Many Requests");
assert!(is_rate_limited(&AgentKind::Claude, None), "still cooling down");
assert!(dispatch_blocking_hold(&AgentKind::Claude, None).is_none());
}
#[test]
fn a_legacy_marker_is_reclassified_from_the_refusal_it_stored() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
for (agent, fixture) in [
(AgentKind::Copilot, "rate-limit-copilot"),
(AgentKind::Grok, "rate-limit-grok"),
] {
let path = marker_path(&agent, None);
std::fs::write(&path, read_fixture(fixture)).expect("write marker");
age_marker(&path, RATE_LIMIT_WINDOW_SECS * 100);
assert!(
is_rate_limited(&agent, None),
"{fixture} states a refusal only a person ends and must not expire on a timer"
);
assert!(
get_rate_limit_info(&agent, None).expect("marker present").needs_human,
"{fixture} must report which kind of hold it is under"
);
assert!(dispatch_blocking_hold(&agent, None).is_some(), "{fixture} must divert dispatch");
}
}
#[test]
fn a_legacy_marker_with_an_unrecognised_refusal_still_expires() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let path = marker_path(&AgentKind::Claude, None);
std::fs::write(&path, "recovery_at: \nmessage: 429 Too Many Requests\n")
.expect("write marker");
age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
assert!(!is_rate_limited(&AgentKind::Claude, None));
assert!(!get_rate_limit_info(&AgentKind::Claude, None).expect("marker present").needs_human);
}
#[test]
fn a_stored_refusal_only_speaks_for_the_agent_whose_marker_it_is() {
let temp = isolated();
let _guard = crate::paths::AidHomeGuard::set(temp.path());
let stored = "recovery_at: \nmessage: QuotaSignature { needle: \"insufficient balance\", \
recovery: QuotaRecovery::NeedsHuman }\n";
let claude = marker_path(&AgentKind::Claude, None);
std::fs::write(&claude, stored).expect("write marker");
age_marker(&claude, RATE_LIMIT_WINDOW_SECS + 60);
assert!(
!is_rate_limited(&AgentKind::Claude, None),
"another provider's needle must not hold claude open until someone clears it"
);
let opencode = marker_path(&AgentKind::OpenCode, None);
std::fs::write(&opencode, stored).expect("write marker");
age_marker(&opencode, RATE_LIMIT_WINDOW_SECS + 60);
assert!(
is_rate_limited(&AgentKind::OpenCode, None),
"opencode's own refusal must still hold, or this became a denylist"
);
}
fn read_fixture(name: &str) -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name);
std::fs::read_to_string(&path).unwrap_or_else(|err| panic!("read {path:?}: {err}"))
}
fn age_marker(path: &std::path::Path, seconds: u64) {
let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds);
let file = std::fs::File::options()
.write(true)
.open(path)
.unwrap_or_else(|err| panic!("open {path:?}: {err}"));
file.set_modified(when)
.unwrap_or_else(|err| panic!("set mtime on {path:?}: {err}"));
}
fn with_relative_recovery_time(content: &str, delta: Duration) -> String {
let stated = content
.lines()
.find_map(|line| line.strip_prefix("recovery_at: "))
.filter(|value| !value.is_empty())
.expect("fixture must state a recovery time");
let template_tokens: Vec<_> = stated.split_whitespace().collect();
let at = Local::now().naive_local() + delta;
let day_token = template_tokens[1];
let day = if day_token.starts_with('0') {
format!("{:02}", at.day())
} else {
at.day().to_string()
};
let suffix = if day_token.ends_with("st,") || day_token.ends_with("nd,")
|| day_token.ends_with("rd,") || day_token.ends_with("th,")
{
ordinal_suffix(at.day()).to_string()
} else {
String::new()
};
let hour = if template_tokens[3].starts_with('0') {
format!("{:02}", at.hour12().1)
} else {
at.hour12().1.to_string()
};
let replacement = format!(
"{} {}{}, {} {}:{:02} {}",
at.format("%b"), day, suffix, at.year(), hour, at.minute(), at.format("%p")
);
content.replacen(
&format!("recovery_at: {stated}"),
&format!("recovery_at: {replacement}"),
1,
)
}
fn ordinal_suffix(day: u32) -> &'static str {
match day % 100 {
11..=13 => "th",
_ => match day % 10 {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
},
}
}