ai-dispatch 10.39.0

Multi-AI CLI team orchestrator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
// Tests for what ends a rate-limit hold: a stated time, a person, or a short
// cooldown. Every refusal string below is verbatim captured CLI output — the
// fixtures under tests/fixtures/ are copies of live ~/.aid markers.

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
}

/// Hold tests pin marker classification. An empty aidbar cache so a live
/// snapshot cannot release an aged Windowed marker.
fn isolate_cache(temp: &tempfile::TempDir) -> crate::live_quota::CacheDirGuard {
    let cache = temp.path().join("aidbar");
    std::fs::create_dir_all(&cache).expect("cache");
    crate::live_quota::CacheDirGuard::set(&cache)
}

/// Write a probe snapshot so `stored_hold` sees a live quota source for this
/// route. The window sits at 100% so `snapshot_overrides` does not release the
/// hold — the probe exists to keep Windowed classification, not to clear it.
fn write_probe_held(cache_dir: &std::path::Path, provider: &str) {
    std::fs::write(
        cache_dir.join(format!("{provider}.json")),
        format!(
            r#"{{"ok":true,"snapshot":{{"provider":"{provider}","windows":[{{"label":"window","used_percent":100.0,"resets_at":"2099-01-01T00:00:00Z","group":null}}],"fetched_at":"2099-01-01T00:00:00Z"}}}}"#
        ),
    )
    .expect("cache");
}

/// The refusal aid could not classify at all: a bare status code caught on
/// stderr, matching no signature. It writes a marker with no recovery time and
/// no manual hold, and that must expire on its own.
///
/// Treating "no stated recovery time" as "never recovers" blackholed a route
/// until someone ran `aid config clear-limit` — the same defect as an outage
/// going unrecorded, pointing the other way.
#[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),
        "Transient cooldown is not Held — scoring and dispatch stay open"
    );

    // Age the marker past the cooldown window: the route is tried again.
    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"
    );
}

/// Windowed grok is not released by elapsed time — only a dated snapshot or
/// `aid config clear-limit` ends it. A probe is written so the route keeps its
/// Windowed class; the probe's window sits at 100% so it does not release.
#[test]
fn a_windowed_grok_hold_is_not_released_by_elapsed_time() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());
    let _cache = isolate_cache(&temp);
    write_probe_held(&temp.path().join("aidbar"), "grok");

    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, "Windowed is not a person hold");

    age_marker(&marker_path(&AgentKind::Grok, None), RATE_LIMIT_WINDOW_SECS * 100);
    assert!(
        is_rate_limited(&AgentKind::Grok, None),
        "a Windowed hold must survive any amount of elapsed time"
    );
    assert!(dispatch_blocking_hold(&AgentKind::Grok, None).is_some());

    assert!(clear_rate_limit(&AgentKind::Grok, None));
    assert!(!is_rate_limited(&AgentKind::Grok, None));
}

/// Every refusal we classify as needing a person, with the message that made us
/// classify it that way. Each holds with no recovery time rather than being
/// given one that later expires.
#[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::Copilot, "You have exceeded your monthly quota"),
        (
            AgentKind::Copilot,
            "You've reached your premium request limit for this billing cycle.",
        ),
        (
            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);
    }
}

/// The clock-ended class keeps its bounded cooldown. Holding these for a person
/// would strand a route that comes back by itself.
#[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);
    }
}

/// A stated reset time outranks the signature's class default: a refusal that
/// does name its reset date is held to that date, not to a person.
#[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");
}

/// A recovery phrase we cannot parse is not a permanent hold either. Writing
/// "tomorrow morning" into the field and then failing to read it back must fall
/// to the bounded cooldown, not to "out forever".
#[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),
        "unparseable recovery is Transient, and Transient is not Held"
    );
    age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
    assert!(
        !is_rate_limited(&AgentKind::Qwen, None),
        "an unreadable time must expire, not become permanent"
    );
}

/// The live markers copied from ~/.aid on 2026-08-07. Each states its reset time
/// in its own provider's phrasing, and every one of those must still read as out —
/// this fix must not shorten a hold that exists today.
///
/// The stated time is rebuilt one day from now before each marker is written.
/// The phrasing each provider uses is the part under test —
/// `parse_recovery_datetime` has to read `Aug 11th, 2026 2:23 PM` and
/// `Aug 08, 2026 12:39 AM` alike — so the fixture's formatting is preserved.
///
/// The marker is then aged past the transient cooldown. Without that, a `recovery_at`
/// this parser failed to read would fall through to `StoredHold::Transient`, and a
/// freshly written file is inside its window, so the assertion would pass without the
/// stated time doing any work at all.
#[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"
        );
    }
}

/// oz's live marker had a reset time in the past when it was captured. `aid config
/// agents` printed it as the current status because the renderer never asked
/// whether the hold was over.
#[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"
    );
}

/// A marker written before the hold classes existed has no `hold:` line. It must
/// not be read as permanent — that is the blackhole this fix removes.
#[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));
}

/// Group markers are held by the same three classes as agent markers, so a
/// spent cursor premium pool survives while `auto` is untouched.
#[test]
fn a_group_marker_holds_for_a_person_too() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());
    let _cache = isolate_cache(&temp);
    write_probe_held(&temp.path().join("aidbar"), "cursor");

    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, "cursor premium is Windowed");
    let end = format_hold_end(&cursor, None, &holds[0].1);
    assert!(
        end.contains("aid config clear-limit cursor"),
        "Windowed group hold must still name the clear command, got {end:?}"
    );
    assert!(!end.contains("cooling down"), "got {end:?}");

    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());
}

/// The live call site, not just the grouping helper. `classify_line` in the
/// cursor adapter has no model in hand, so it marked the agent — which made
/// `is_rate_limited(Cursor, None)` true and took `auto` out with the pool that ran
/// out. The message names the tier; the marking must follow it.
#[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"
    );
}

/// End to end on the channel it actually arrives on. cursor's premium refusal is
/// on stderr and nowhere else — no error envelope in the stream carries it — so
/// this is the coverage that decides whether `Channel::CliStderr` earns its
/// place in the enumeration. If it ever stops passing, the answer is not to
/// widen the stream channel but to say so.
#[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));
}

/// The complement: a cursor refusal that names no tier is still an agent-level
/// fact and must not be quietly narrowed to one group.
#[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"));
}

/// The live call site with no model in hand. The 402 names the standard
/// pool; Core must stay dispatchable.
#[test]
fn a_droid_standard_402_with_no_model_holds_only_the_standard_pool() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let body = r#"{"type":"error","source":"agent_loop","message":"402 {\"detail\":\"You've reached your weekly standard usage limit (resets in 2 days).\nSwitch to Droid Core or enable Extra Usage to continue.\",\"status\":402,\"title\":\"Payment Required\",\"displayToUser\":true}"}"#;
    mark_rate_limited_for_message(&AgentKind::Droid, None, body);

    assert!(is_group_rate_limited(&AgentKind::Droid, None, "standard"));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "core"));
    assert!(!is_rate_limited(&AgentKind::Droid, None));
    assert!(
        dispatch_blocking_hold(&AgentKind::Droid, None).is_none(),
        "aid run must still dispatch droid — on Core"
    );
}

#[test]
fn a_droid_core_402_with_no_model_holds_only_the_core_pool() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let body = r#"402 {"detail":"You've reached your weekly Droid Core usage limit (resets in 2 days).\nReload Extra Usage credits or wait for your limits to reset.","status":402,"title":"Payment Required","displayToUser":true}"#;
    mark_rate_limited_for_message(&AgentKind::Droid, None, body);

    assert!(is_group_rate_limited(&AgentKind::Droid, None, "core"));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "standard"));
    assert!(!is_rate_limited(&AgentKind::Droid, None));
    assert!(
        dispatch_blocking_hold(&AgentKind::Droid, None).is_none(),
        "aid run must still dispatch droid — on standard"
    );
}

#[test]
fn a_droid_refusal_naming_no_tier_still_marks_the_agent() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited_for_message(
        &AgentKind::Droid,
        None,
        "402 payment required: reload your tokens",
    );

    assert!(is_rate_limited(&AgentKind::Droid, None));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "standard"));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "core"));
}

/// Watcher, lifecycle, and stream-completion pass `requested_model`. The
/// refusal names the spent tier; a dispatched opus must not change that.
#[test]
fn a_droid_standard_402_with_a_model_in_hand_holds_only_the_standard_pool() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let body = r#"{"type":"error","source":"agent_loop","message":"402 {\"detail\":\"You've reached your weekly standard usage limit (resets in 2 days).\nSwitch to Droid Core or enable Extra Usage to continue.\",\"status\":402,\"title\":\"Payment Required\",\"displayToUser\":true}"}"#;
    mark_rate_limited_for_model(&AgentKind::Droid, None, Some("claude-opus-5"), body);

    assert!(is_group_rate_limited(&AgentKind::Droid, None, "standard"));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "core"));
    assert!(!is_rate_limited(&AgentKind::Droid, None));
    assert!(
        dispatch_blocking_hold(&AgentKind::Droid, None).is_none(),
        "aid run must still dispatch droid — on Core"
    );
}

/// Same write site, Core model in hand: the refusal still names standard.
#[test]
fn a_droid_standard_402_with_a_core_model_in_hand_still_holds_only_standard() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let body = r#"{"type":"error","source":"agent_loop","message":"402 {\"detail\":\"You've reached your weekly standard usage limit (resets in 2 days).\nSwitch to Droid Core or enable Extra Usage to continue.\",\"status\":402,\"title\":\"Payment Required\",\"displayToUser\":true}"}"#;
    mark_rate_limited_for_model(&AgentKind::Droid, None, Some("glm-5.2"), body);

    assert!(is_group_rate_limited(&AgentKind::Droid, None, "standard"));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "core"));
    assert!(!is_rate_limited(&AgentKind::Droid, None));
}

/// The live with-model path. `reload your tokens` names no tier and is
/// NeedsHuman — a purchase ends it. Marking only `standard` would keep
/// dispatching Core against an account that cannot serve.
#[test]
fn a_droid_reload_tokens_402_with_a_model_in_hand_marks_the_agent() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited_for_model(
        &AgentKind::Droid,
        None,
        Some("claude-opus-5"),
        "402 payment required: reload your tokens",
    );

    assert!(is_rate_limited(&AgentKind::Droid, None));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "standard"));
    assert!(!is_group_rate_limited(&AgentKind::Droid, None, "core"));
    assert!(dispatch_blocking_hold(&AgentKind::Droid, None).is_some());
    assert!(
        dispatch_blocking_hold_for_model(&AgentKind::Droid, None, Some("glm-5.2")).is_none(),
        "Core group is not held; the agent-wide marker is what stops dispatch"
    );
}

/// `aid run` used to divert only when a recovery time was present. A Windowed
/// 402 states none, so dispatch must still stop and name the dated-snapshot way
/// out rather than inventing a clock or saying "cooling down". A probe is
/// written so the route keeps its Windowed class — see the probe-less case below.
#[test]
fn a_windowed_hold_blocks_dispatch_and_names_the_way_out() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());
    let _cache = isolate_cache(&temp);
    write_probe_held(&temp.path().join("aidbar"), "grok");

    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 Windowed hold must block");
    assert_eq!(
        hold,
        "until a dated grok snapshot with headroom (or `aid config clear-limit grok`)"
    );
    assert!(!hold.contains("cooling down"));

    assert!(clear_rate_limit(&AgentKind::Grok, None));
    assert!(dispatch_blocking_hold(&AgentKind::Grok, None).is_none());
}

/// A probe-less agent's Windowed refusal must state the human-cleared end, not
/// promise a dated snapshot that can never arrive. grok's 402 is Windowed by
/// signature (aidbar is assumed to map the billing period to a dated resets_at),
/// but when no probe exists the recovery condition is unobservable, so the hold
/// is human-cleared and `aid agent quota` / dispatch must say so plainly.
#[test]
fn a_probeless_windowed_refusal_states_a_human_clear_not_a_missing_snapshot() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());
    let _cache = isolate_cache(&temp);

    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("the refusal must still block dispatch");
    assert!(
        !hold.contains("dated grok snapshot"),
        "a probe-less route must not promise a snapshot; got {hold:?}"
    );
    assert!(
        hold.contains("aid config clear-limit grok"),
        "the stated end must name the human-clear command; got {hold:?}"
    );

    let info = get_rate_limit_info(&AgentKind::Grok, None).expect("marker written");
    assert!(
        info.needs_human,
        "a probe-less Windowed refusal is human-cleared, not windowed: {info:?}"
    );
    let end = format_hold_end(&AgentKind::Grok, None, &info);
    assert!(
        !end.contains("dated grok snapshot"),
        "quota display must not promise a snapshot for a probe-less route: {end:?}"
    );
    assert!(
        end.contains("aid config clear-limit grok"),
        "quota display must name the human-clear command: {end:?}"
    );
}

/// The other direction of the same gate: a stated time still blocks while it is
/// in the future, and stops blocking once it has passed. oz's live marker is
/// fourteen hours stale and kept diverting runs off a route that was back.
#[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"
    );
}

/// A bounded cooldown is not a dispatch gate. Moving the caller off the agent
/// they asked for costs more than the few minutes left on a transient 429, and
/// gating on it was never the previous behaviour either.
#[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),
        "Transient is not Held, so scoring no longer applies -10"
    );
    assert!(dispatch_blocking_hold(&AgentKind::Claude, None).is_none());
}

/// Markers already on disk when this version lands carry no `hold:` line. The
/// two human-ended ones state no reset time either, so the cooldown would
/// release them within five minutes and hand work back to a spent allowance.
/// Their stored refusal text is the same evidence write-time classification
/// uses, so it is re-read rather than the file rewritten.
#[test]
fn a_legacy_marker_is_reclassified_from_the_refusal_it_stored() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());
    let _cache = isolate_cache(&temp);
    write_probe_held(&temp.path().join("aidbar"), "grok");

    for (agent, fixture, human) in [
        // "recovery_at: " empty, message a mid-token JSON fragment that still
        // carries "You have exceeded your monthly quota".
        (AgentKind::Copilot, "rate-limit-copilot", true),
        // "recovery_at: " empty, refusal on a later line of a multi-line message.
        (AgentKind::Grok, "rate-limit-grok", false),
    ] {
        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} must not expire on a timer"
        );
        let info = get_rate_limit_info(&agent, None).expect("marker present");
        assert_eq!(
            info.needs_human, human,
            "{fixture} must report which kind of hold it is under"
        );
        assert!(dispatch_blocking_hold(&agent, None).is_some(), "{fixture} must divert dispatch");
        if !human {
            assert!(
                !format_hold_end(&agent, None, &info).contains("cooling down"),
                "{fixture} Windowed must not print cooling down"
            );
        }
    }
}

/// The same rule must not turn every legacy marker permanent. A stored message
/// matching no signature is still transient and still expires — that is the
/// blackhole this whole change exists to avoid.
#[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);
}

/// A marker records what *one* provider said, so a needle another provider owns
/// is not evidence about this one. `~/.aid/rate-limit-claude` was written on
/// 2026-08-07 from an agent's own message quoting this crate's signature table,
/// and the stored text contained `"insufficient balance"` — opencode's refusal,
/// which held claude open until someone cleared it.
///
/// The scoping is what does the work here, not the wording: the identical text
/// in opencode's own marker is still a human-ended hold, as the second half
/// asserts. The write side can no longer produce such a marker at all
/// (`quota_channel`); this covers the ones already on disk.
#[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}"))
}

/// Backdate a marker's mtime so the bounded cooldown can be exercised without
/// sleeping. The transient class measures from the write time.
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}"));
}

/// Shift a fixture's stated time relative to now while preserving its provider
/// formatting. The message body remains the captured evidence under test.
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",
        },
    }
}