Skip to main content

voro_core/
cap.rs

1//! Reading a usage cap out of an agent session's own output (DESIGN.md §8).
2//!
3//! Two callers want the same answer from the same text. The reconciler asks it
4//! of a session that has *died*, to record `capped` rather than `failed`; and it
5//! asks it of a session that is still *alive*, because a cap does not kill a
6//! supervisor-owned launch — the session sits waiting for the window to reset,
7//! and without this it rides the running strip looking healthy for hours.
8//!
9//! Everything here is pure: a string in, a reading out. The process that
10//! produces the string — an agent's `logs` verb, or the launch log for an agent
11//! that defines none — lives in the `voro` crate, so this side stays testable
12//! without a terminal or a subprocess.
13//!
14//! The text is a *terminal capture*, not a log: the built-in `claude` spelling
15//! replays a background session's screen, escape sequences and all, so
16//! [`strip_ansi`] runs before any matching. Cursor movement is spatial and
17//! becomes a space; colour is not and vanishes, which is what stops a style
18//! change mid-phrase from splitting the phrase it is styling.
19//!
20//! Matching stays deliberately narrow, as it always has: a missed cap reads as
21//! an ordinary session, which is the failure everyone already lives with, while
22//! a false one would badge healthy work as stuck and teach the operator to
23//! disbelieve the badge. That asymmetry is why [`NOT_CAP_QUALIFIERS`] exists —
24//! the agent says "approaching" and "not your usage limit" in text that
25//! otherwise matches, and both mean the session is working fine.
26//!
27//! A matched cap is then read for one thing beyond its reset time: whether the
28//! agent is *retrying* the rejected request ([`CapReading::retrying`]). A
29//! usage cap never is — Claude Code declines to retry one at all (§8) — but a
30//! plain rate limit is retried, and the agent labels that retry with the API's
31//! own wording, which contains "rate limit". So the text that says a session
32//! is held and the text that says it is mid-turn differ by the retry phrase
33//! alone, and everything else about the two is identical: `blocked` in the
34//! listing, supervisor alive, cap phrase on screen.
35//!
36//! *When* the window reopens has a second source, and a better one. The clock
37//! time on screen is a bare `6:40pm`: no date, so it is read as whichever
38//! occurrence is nearest and is ambiguous by half a day either way. An agent
39//! that can say the same thing as an instant — [`AccountCap`], read through its
40//! `cap` verb — says it exactly, and [`CapWindow`] is where the two, and the
41//! retry above, are resolved into the one answer the badge and the sweep both
42//! read. That reading is about the *account* rather than the session, so it
43//! serves every session asking the same of it; the parse stays for every agent
44//! and every moment that has none.
45
46/// Phrases that mean "held at a usage cap", checked case-insensitively.
47///
48/// The first three are the original list and cover agents that word it
49/// generically. The rest are what Claude Code actually renders, which none of
50/// the first three catch: a five-hour cap says "Session limit reached", the
51/// weekly one "Weekly limit reached", and the per-model and overage ones name
52/// the model or the credit. A cap that says none of these is reported as an
53/// ordinary failure, which is the same landing either way (§8).
54pub const CAP_SIGNATURES: [&str; 8] = [
55    "usage limit",
56    "rate limit",
57    "quota exceeded",
58    "session limit",
59    "weekly limit",
60    "opus limit",
61    "sonnet limit",
62    "credit limit",
63];
64
65/// Phrases that take a matched signature back. Each is something an agent says
66/// in the same breath as a limit while still working normally: a
67/// warning ahead of the cap ("Approaching usage limit", "You've used 80% of
68/// your session limit") or a server-side error explicitly disclaiming one
69/// ("Server is temporarily limiting requests (not your usage limit)").
70const NOT_CAP_QUALIFIERS: [&str; 4] = ["approaching", "% of your", "not your", "close to your"];
71
72/// Phrases that mean the signature after them is not a *report* at all. Every
73/// real limit message ends with the upgrade prompt — `/upgrade to increase your
74/// usage limit.` — which contains a signature of its own and, being last, would
75/// otherwise be the one that decides.
76///
77/// That matters twice over. It is the *only* signature in a genuine cap whose
78/// window holds no reset time, so letting it decide drops the time from every
79/// real cap; and it says nothing about whether the session is held, so a warning
80/// that ever trailed the same prompt would badge as a cap. Both go away once the
81/// prompt is read as the boilerplate it is: skipped when choosing which
82/// signature speaks, rather than negating like a qualifier — a qualifier means
83/// "this one is not a cap", and skipping instead would let a genuine earlier cap
84/// speak past a warning that had since replaced it.
85const MENTION_PREFIXES: [&str; 2] = ["/upgrade", "increase your"];
86
87/// Phrases that mean the agent is *retrying* the request the cap rejected
88/// rather than having ended its turn on it. Read in the same window as the
89/// reset time, since the agent renders both on one line with the retry half
90/// after the label: `Session limit reached · Retrying in 5m (9:50pm) ·
91/// attempt 2/10`.
92const RETRYING: [&str; 1] = ["retrying in"];
93
94/// How much text after a matched signature is read for the reset time that
95/// goes on the badge, and for the retry wording that says the turn is still
96/// running.
97const WINDOW: usize = 200;
98
99/// How much text before a matched signature is read for the qualifiers that
100/// take it back. Deliberately short: every qualifier attaches directly to the
101/// phrase it modifies, so a wider look-back would let an *earlier* warning
102/// speak for a later, genuine cap — which is the one reading that loses a real
103/// cap rather than merely missing an unworded one.
104const QUALIFIER_WINDOW: usize = 32;
105
106/// What a session's output says about a usage cap. Held in memory only: it is a
107/// reading of the current output tail, retaken on the next pass, so it clears
108/// itself once the operator continues the session and new output displaces the
109/// cap message (§8). Nothing about it reaches the database.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub struct CapReading {
112    /// When the window reopens, as minutes past local midnight, if the message
113    /// named a time. Best-effort by design: an unparsed time badges without
114    /// one rather than suppressing the badge.
115    pub reset_minutes: Option<u16>,
116    /// Whether the agent said it is retrying the rejected request rather than
117    /// having ended its turn on it.
118    ///
119    /// This is the one capped shape a nudge must not touch. The session is
120    /// mid-turn: it holds the request itself and will resume when the retry
121    /// lands, so stopping it to send *continue* kills a turn that needed no
122    /// help — and the sweep stops its target unconditionally (§8), so there is
123    /// nothing else between a misread and a killed turn. Both shapes otherwise
124    /// read identically: `blocked` in the agent's listing, supervisor alive,
125    /// the same cap phrase in the output.
126    ///
127    /// The risk this guards is not that a *cap* retries — Claude Code refuses
128    /// to retry a usage-limit rejection at all (§8) — but that a retry reads
129    /// as a cap. A rate limit that carries no usage-limit headers is retried,
130    /// and the agent labels that retry with the API's own message, which says
131    /// "rate limit" in it: a signature this module matches, riding a session
132    /// that is working perfectly well.
133    pub retrying: bool,
134}
135
136impl CapReading {
137    /// The reset time as a 24-hour `21:50`, for the badge.
138    pub fn reset_label(&self) -> Option<String> {
139        self.reset_minutes
140            .map(|m| format!("{:02}:{:02}", m / 60, m % 60))
141    }
142
143    /// Whether the named reset has already gone by, given the local wall clock
144    /// as minutes past midnight — and so whether this session is waiting on a
145    /// human rather than on the clock. A retrying session is never either: it
146    /// is waiting on its own request, and its reset time is when that request
147    /// goes out, not when someone should intervene.
148    ///
149    /// The agent names a bare clock time — `9:50pm`, no date — so the occurrence
150    /// meant is the one *nearest* now, in either direction. Taking the next one
151    /// instead would be self-defeating: a minute after the window reopened,
152    /// "the next 9:50pm" is tomorrow's, and the badge would claim another 24
153    /// hours of waiting exactly when it should be saying the opposite. Half a
154    /// day is the widest a bare clock time can be read unambiguously, and a cap
155    /// window the operator is watching is never further off than that.
156    pub fn reset_passed(&self, now_minutes: u16) -> bool {
157        if self.retrying {
158            return false;
159        }
160        let Some(reset) = self.reset_minutes else {
161            return false;
162        };
163        // Signed distance wrapped into (-720, 720]: negative is behind us.
164        let delta = (i32::from(reset) - i32::from(now_minutes)).rem_euclid(1440);
165        let delta = if delta > 720 { delta - 1440 } else { delta };
166        delta <= 0
167    }
168}
169
170/// How far behind the present a reported reset may fall and still be believed:
171/// a window that reopened while the operator was away is exactly the reading
172/// the sweep is waiting for, so a day of slack costs nothing.
173const EPOCH_BEHIND: i64 = 24 * 60 * 60;
174
175/// How far ahead of the present a reported reset may fall and still be
176/// believed. The longest window an agent bills in is a week, so a month is
177/// generous — the bound is here to refuse a number that is not a timestamp at
178/// all, not to second-guess the agent.
179const EPOCH_AHEAD: i64 = 30 * 24 * 60 * 60;
180
181/// What an agent's `cap` verb says about the *account* it dispatches on: the
182/// instant its usage window reopens (DESIGN.md §8).
183///
184/// This is the same quantity the badge parses off a session's screen, without
185/// the parse. It is a fact the agent reports rather than a heuristic standing
186/// in for one, and it is account-wide, so a single reading answers for every
187/// session on the strip — where the screen reading has to be taken per session,
188/// costing a subprocess each.
189///
190/// The label is the agent's instant in the operator's own timezone, rendered
191/// where the reading is taken because that is off the render path and this
192/// crate has no clock. Absent when it could not be rendered, in which case the
193/// badge shows the cap without a time exactly as an unparsed one does.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct AccountCap {
196    /// When the window reopens, in seconds since the Unix epoch.
197    pub reset_epoch: i64,
198    /// That instant as a local `21:50`, for the badge.
199    pub reset_label: Option<String>,
200}
201
202/// Read an agent's `cap` verb output as the instant its window reopens, or
203/// `None` when it said nothing (DESIGN.md §8).
204///
205/// The verb's contract is one line of shell away from trivial on purpose: print
206/// a Unix epoch when the account is *currently* refused, print nothing
207/// otherwise. Silence is the whole of the negative answer — an account that is
208/// not capped, a verb that failed, an agent that defines none all read the same
209/// and all fall back to the screen parse — which is how every other reading in
210/// this module degrades.
211///
212/// A plausibility band around `now` is the only judgement applied: the output is
213/// under the agent template's control, and a stray number in it must not become
214/// a badge claiming the window reopens in 1970. The *last* plausible number
215/// wins, so a verb that prints a line per window ends with the one it means.
216pub fn parse_reset_epoch(out: &str, now_epoch: i64) -> Option<i64> {
217    out.split(|c: char| !c.is_ascii_digit())
218        .filter_map(|run| run.parse::<i64>().ok())
219        .rfind(|epoch| (now_epoch - EPOCH_BEHIND..=now_epoch + EPOCH_AHEAD).contains(epoch))
220}
221
222/// When a capped session's window reopens and whether it has: the one answer
223/// the badge and the nudge sweep both read, resolved from the two sources that
224/// can give it (DESIGN.md §8).
225///
226/// The account's own reading decides whenever there is one. It is the same
227/// quantity the screen states, minus the ambiguity: a bare `6:40pm` carries no
228/// date, so "has it passed?" is answered by nearest occurrence and is a
229/// half-day guess in both directions, while an instant is simply compared. The
230/// screen parse remains the answer for an agent with no `cap` verb, for an
231/// account that is not itself refused, and for every reading taken before the
232/// verb has run.
233///
234/// One caveat rides the precedence and is worth naming: an account is refused by
235/// *one* window while a session may be held by another — a weekly model limit
236/// behind a five-hour account cap — and the account reading names its own. The
237/// sweep is then early for that session, which §8 already prices as the cheap
238/// failure: the nudge lands, the turn re-caps at once, and the badge returns.
239#[derive(Debug, Clone, PartialEq, Eq, Default)]
240pub struct CapWindow {
241    /// The reset as a local `21:50`, absent when neither source named one.
242    pub label: Option<String>,
243    /// Whether the window has reopened.
244    pub passed: bool,
245    /// Whether anything named a reset at all. A cap with no time is the case
246    /// the operator's own judgement stands in for ([`CapWindow::due`]).
247    pub timed: bool,
248    /// Whether the session is retrying the rejected request rather than sitting
249    /// on it ([`CapReading::retrying`]). Carried through rather than resolved
250    /// away, because it is the one badged shape that wants nothing done about
251    /// it and the badge has to say so.
252    pub retrying: bool,
253}
254
255impl CapWindow {
256    /// Resolve the sources for one session, the account's reading first.
257    ///
258    /// A session that is *retrying* takes neither: the account's instant says
259    /// when the window reopens, and a retrying session is not waiting on the
260    /// window — it is mid-turn on a request of its own, and the time it names
261    /// is when that request goes out (§8). Reading an account instant onto it
262    /// would mark it due the moment the window opened, which is precisely the
263    /// session a nudge must not touch.
264    pub fn resolve(
265        reading: &CapReading,
266        account: Option<&AccountCap>,
267        now_minutes: Option<u16>,
268        now_epoch: Option<i64>,
269    ) -> CapWindow {
270        if reading.retrying {
271            return CapWindow {
272                label: reading.reset_label(),
273                passed: false,
274                timed: reading.reset_minutes.is_some(),
275                retrying: true,
276            };
277        }
278        if let Some(account) = account {
279            return CapWindow {
280                label: account
281                    .reset_label
282                    .clone()
283                    .or_else(|| reading.reset_label()),
284                passed: now_epoch.is_some_and(|now| account.reset_epoch <= now),
285                timed: true,
286                retrying: false,
287            };
288        }
289        CapWindow {
290            label: reading.reset_label(),
291            passed: now_minutes.is_some_and(|now| reading.reset_passed(now)),
292            timed: reading.reset_minutes.is_some(),
293            retrying: false,
294        }
295    }
296
297    /// Whether this session is waiting on a human rather than on the clock —
298    /// what the sweep nudges.
299    ///
300    /// An untimed cap counts, and that is the operator's judgement standing in
301    /// for the clock's: they pressed the key, and a nudge that turns out to be
302    /// early is refused by the agent rather than doing harm (§8). It is also
303    /// the gap the account reading closes — a cap whose time never parsed is
304    /// timed after all once the account has said when — which is what an
305    /// automatic sweep, with no keypress behind it, needs.
306    ///
307    /// A retrying session is never due, whatever it named: it is working, and
308    /// the sweep stops its target before resuming it, so a nudge there ends a
309    /// turn rather than adding one. That answer lives here rather than only in
310    /// the sweep, so a caller reading `due` alone cannot miss it.
311    pub fn due(&self) -> bool {
312        !self.retrying && (!self.timed || self.passed)
313    }
314}
315
316/// Read a session's output tail as a cap reading, or `None` when nothing in it
317/// says the session is capped.
318///
319/// The *last* signature in the text decides, since a session that hit a cap,
320/// was continued, and hit another has the live one at the end — and a warning
321/// earlier in the same tail must not speak for a genuine cap later.
322pub fn read_cap(tail: &str) -> Option<CapReading> {
323    let text = strip_ansi(tail).to_lowercase();
324    let (at, signature) = last_signature(&text)?;
325    if look_back(&text, at, &NOT_CAP_QUALIFIERS) {
326        return None;
327    }
328    let after = &text[at..ceil_boundary(&text, (at + signature.len() + WINDOW).min(text.len()))];
329    Some(CapReading {
330        reset_minutes: parse_clock(after),
331        retrying: RETRYING.iter().any(|p| after.contains(p)),
332    })
333}
334
335/// The position and text of the last cap signature in `text` that reports
336/// something, which must already be lowercased. Signatures the upgrade prompt
337/// merely mentions ([`MENTION_PREFIXES`]) are not candidates.
338fn last_signature(text: &str) -> Option<(usize, &'static str)> {
339    CAP_SIGNATURES
340        .iter()
341        .flat_map(|sig| text.match_indices(sig).map(|(at, _)| (at, *sig)))
342        .filter(|(at, _)| !look_back(text, *at, &MENTION_PREFIXES))
343        .max_by_key(|(at, _)| *at)
344}
345
346/// Whether any of `phrases` appears in the short span of `text` before `at`.
347fn look_back(text: &str, at: usize, phrases: &[&str]) -> bool {
348    let before = &text[floor_boundary(text, at.saturating_sub(QUALIFIER_WINDOW))..at];
349    phrases.iter().any(|p| before.contains(p))
350}
351
352/// Drop terminal escape sequences, keeping the spacing the surviving text had
353/// on screen.
354///
355/// A cursor move stands for the gap between two words, so it becomes a space —
356/// without that, `claude logs` output runs its words together and no phrase
357/// matches. A colour change stands for nothing spatial and is simply dropped,
358/// so a phrase styled halfway through stays one word. Other control characters
359/// (newlines and tabs included) are spacing too.
360pub fn strip_ansi(raw: &str) -> String {
361    let mut out = String::with_capacity(raw.len());
362    let mut chars = raw.chars().peekable();
363    while let Some(c) = chars.next() {
364        if c != '\u{1b}' {
365            out.push(if c.is_control() { ' ' } else { c });
366            continue;
367        }
368        match chars.next() {
369            // CSI: parameters and intermediates, then a final byte in @..~.
370            Some('[') => {
371                let mut final_byte = None;
372                for c in chars.by_ref() {
373                    if ('\u{40}'..='\u{7e}').contains(&c) {
374                        final_byte = Some(c);
375                        break;
376                    }
377                }
378                // `m` is SGR — styling, no position — and only it is spaceless.
379                if final_byte != Some('m') {
380                    out.push(' ');
381                }
382            }
383            // OSC: runs to BEL or to a string terminator.
384            Some(']') => {
385                while let Some(c) = chars.next() {
386                    if c == '\u{7}' {
387                        break;
388                    }
389                    if c == '\u{1b}' {
390                        chars.next();
391                        break;
392                    }
393                }
394            }
395            // A two-character escape, or a stray ESC at the end of the tail.
396            _ => {}
397        }
398    }
399    out
400}
401
402/// Minutes past midnight for the first `9pm` / `9:50pm` clock time in `text`,
403/// which must already be lowercased.
404///
405/// This is the shape Claude Code renders a reset time in when it is less than a
406/// day out, which a cap window the operator is looking at always is. A longer
407/// horizon is spelled with a date (`Aug 14, 9pm`) and the clock half still
408/// reads, which is the right answer for a badge that shows a time and not a
409/// date.
410fn parse_clock(text: &str) -> Option<u16> {
411    let bytes = text.as_bytes();
412    for i in 0..bytes.len().saturating_sub(1) {
413        let meridiem = match (bytes[i], bytes[i + 1]) {
414            (b'a', b'm') => 0,
415            (b'p', b'm') => 12,
416            _ => continue,
417        };
418        // `9pm` must not be read out of `9pmx` or a word ending in `am`.
419        if bytes.get(i + 2).is_some_and(|c| c.is_ascii_alphanumeric()) {
420            continue;
421        }
422        let mut j = i;
423        while j > 0 && bytes[j - 1] == b' ' {
424            j -= 1;
425        }
426        let end = j;
427        while j > 0 && (bytes[j - 1].is_ascii_digit() || bytes[j - 1] == b':') {
428            j -= 1;
429        }
430        let Some(minutes) = clock_minutes(&text[j..end], meridiem) else {
431            continue;
432        };
433        return Some(minutes);
434    }
435    None
436}
437
438/// `9`, `9:50` plus a 0-or-12 hour offset, as minutes past midnight. A 12-hour
439/// clock names noon and midnight as `12`, so that hour wraps to zero before the
440/// offset applies.
441fn clock_minutes(clock: &str, meridiem: u16) -> Option<u16> {
442    let (hour, minute) = match clock.split_once(':') {
443        Some((h, m)) if m.len() == 2 => (h, m.parse::<u16>().ok()?),
444        Some(_) => return None,
445        None => (clock, 0),
446    };
447    let hour: u16 = hour.parse().ok()?;
448    if hour == 0 || hour > 12 || minute > 59 {
449        return None;
450    }
451    Some((hour % 12 + meridiem) * 60 + minute)
452}
453
454/// The largest char boundary at or below `at`, so a window edge never lands
455/// inside the multi-byte glyphs an agent's output is full of.
456fn floor_boundary(text: &str, mut at: usize) -> usize {
457    while at > 0 && !text.is_char_boundary(at) {
458        at -= 1;
459    }
460    at
461}
462
463/// The smallest char boundary at or above `at`.
464fn ceil_boundary(text: &str, mut at: usize) -> usize {
465    while at < text.len() && !text.is_char_boundary(at) {
466        at += 1;
467    }
468    at
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    /// The wording the original three signatures all missed: a five-hour cap
476    /// says "Session limit reached" and nothing about usage, rates or quotas.
477    ///
478    /// The retry half is *binary-derived*, assembled from the agent's own
479    /// template rather than seen on a session — and taking it for an
480    /// observation is what sent a reviewer looking for a cap that retries.
481    /// What the template shows is real enough: the agent renders exactly this
482    /// line for a retried rate limit, reset time in parentheses and attempt
483    /// counter behind it. What it does not show is a cap reaching it, because
484    /// a usage limit is not among the errors the agent retries (§8). So the
485    /// string stands as the shape [`CapReading::retrying`] must recognise, and
486    /// stands for nothing about whether a *cap* wears it.
487    #[test]
488    fn a_five_hour_cap_reads_as_capped() {
489        let reading = read_cap("Session limit reached · Retrying in 5m (9:50pm) · attempt 2/10")
490            .expect("a cap");
491        assert_eq!(reading.reset_label().as_deref(), Some("21:50"));
492        assert!(reading.retrying);
493    }
494
495    /// The wording an actual five-hour cap turned out to use, captured from
496    /// three live sessions on 2026-08-13 — the first real cap Voro has seen,
497    /// every earlier case having been read out of the agent's own binary.
498    ///
499    /// The upgrade prompt riding along behind it is the whole point: it carries
500    /// a signature of its own, it is last, and its window holds no time, so
501    /// before it was read as boilerplate every genuine cap badged without the
502    /// reset time it had actually named.
503    #[test]
504    fn the_real_cap_message_reads_with_its_reset_time() {
505        let reading = read_cap(
506            "You've hit your session limit · resets 6:40pm (Europe/London)\n\
507             /upgrade to increase your usage limit.",
508        )
509        .expect("a cap");
510        assert_eq!(reading.reset_label().as_deref(), Some("18:40"));
511        // The half that matters for the sweep: the real thing names no retry,
512        // so the turn is over and the session is waiting on a human.
513        assert!(!reading.retrying);
514    }
515
516    /// A retry the operator's sessions really do hit, captured from a `--bg`
517    /// session on 2026-08-13 while a sibling sat at the cap above. It is the
518    /// live proof that a backgrounded session reaches the retry banner — and
519    /// that the banner alone is not a cap: an overload names no limit, so
520    /// nothing here matches and no badge is owed.
521    #[test]
522    fn an_overload_retry_is_not_a_cap() {
523        assert_eq!(
524            read_cap(
525                "529 Overloaded · Retrying in 3s · attempt 3/10\n\
526                 If it persists, check https://status.claude.com."
527            ),
528            None
529        );
530    }
531
532    /// The case [`CapReading::retrying`] is built for: a rate limit the agent
533    /// *does* retry, labelled with the API's own wording. It matches on "rate
534    /// limit" like any cap, it badges like any cap — and the session behind it
535    /// is mid-turn, so the sweep must let it alone.
536    #[test]
537    fn a_retried_rate_limit_reads_as_retrying() {
538        let reading = read_cap(
539            "429 Number of requests has exceeded your rate limit · Retrying in 30s · attempt 3/10",
540        )
541        .expect("a cap");
542        assert!(reading.retrying);
543        // And it is never due a nudge, whatever the clock says, because the
544        // time it names is when its own request goes out.
545        let timed = read_cap("Session limit reached · Retrying in 5m (9:50pm) · attempt 2/10")
546            .expect("a cap");
547        assert_eq!(timed.reset_minutes, Some(21 * 60 + 50));
548        assert!(!timed.reset_passed(22 * 60 + 50));
549    }
550
551    /// The real warning short of that cap, captured from a session that went on
552    /// working — and which must stay unbadged even though the same upgrade
553    /// prompt can follow it.
554    #[test]
555    fn the_real_warning_short_of_the_cap_is_not_capped() {
556        assert_eq!(
557            read_cap(
558                "You've used 98% of your session limit · resets 6:40pm (Europe/London)\n\
559                 /upgrade to keep using Claude Code"
560            ),
561            None
562        );
563        assert_eq!(
564            read_cap(
565                "You've used 99% of your session limit · resets 6:40pm (Europe/London)\n\
566                 /upgrade to increase your usage limit."
567            ),
568            None
569        );
570    }
571
572    /// The other wordings the agent uses for the same condition.
573    #[test]
574    fn every_cap_wording_reads_as_capped() {
575        for text in [
576            "You've hit your session limit · resets 9pm",
577            "Weekly limit reached",
578            "Opus limit reached · resets Aug 14, 9pm",
579            "Usage credit limit reached",
580            "usage limit reached — check plan",
581            "rate limited — wait and retry",
582            "sorry, hit the 5-hour usage limit — try again later",
583            "quota exceeded",
584        ] {
585            assert!(read_cap(text).is_some(), "{text}");
586        }
587    }
588
589    /// Ordinary output is not a cap, however much it talks about limits.
590    #[test]
591    fn ordinary_output_is_not_capped() {
592        for text in [
593            "",
594            "running tests",
595            "Context limit reached · /compact",
596            "Concurrent subagent limit reached. You can run 10 subagents at once.",
597            "error: recursion limit reached",
598        ] {
599            assert_eq!(read_cap(text), None, "{text}");
600        }
601    }
602
603    /// The qualifiers that take a match back. Each of these appears while the
604    /// session is working perfectly well, and badging it would be a lie the
605    /// operator learns to ignore the badge over.
606    #[test]
607    fn a_warning_short_of_the_cap_is_not_capped() {
608        for text in [
609            "Approaching usage limit · resets 9pm",
610            "You've used 80% of your session limit · resets 9pm",
611            "Server is temporarily limiting requests (not your usage limit)",
612            "You're close to your usage limit",
613        ] {
614            assert_eq!(read_cap(text), None, "{text}");
615        }
616    }
617
618    /// A warning earlier in the same tail does not speak for a real cap later:
619    /// the last signature decides, so a session that was warned and then
620    /// genuinely capped still badges.
621    #[test]
622    fn the_last_signature_decides() {
623        let tail = "Approaching usage limit · resets 9pm\n\
624                    ... work continues ...\n\
625                    Session limit reached · Retrying in 5m (9:50pm)";
626        assert_eq!(
627            read_cap(tail).expect("a cap").reset_label().as_deref(),
628            Some("21:50")
629        );
630
631        // And the other order: a real cap the operator has since cleared, with
632        // only a warning left at the end, is no longer capped.
633        let tail = "Session limit reached\n... continued ...\nApproaching usage limit";
634        assert_eq!(read_cap(tail), None);
635    }
636
637    /// A cap with no time still badges — the time is the optional half.
638    #[test]
639    fn a_cap_without_a_time_reads_without_one() {
640        let reading = read_cap("Weekly limit reached").expect("a cap");
641        assert_eq!(reading.reset_minutes, None);
642        assert_eq!(reading.reset_label(), None);
643        assert!(!reading.reset_passed(600));
644    }
645
646    #[test]
647    fn clock_times_parse_to_minutes_past_midnight() {
648        for (text, minutes) in [
649            ("9pm", 21 * 60),
650            ("9:50pm", 21 * 60 + 50),
651            ("9am", 9 * 60),
652            ("12am", 0),
653            ("12:30am", 30),
654            ("12pm", 12 * 60),
655            ("12:30pm", 12 * 60 + 30),
656            ("1:05am", 65),
657        ] {
658            assert_eq!(
659                read_cap(&format!("Session limit reached · resets {text}"))
660                    .expect("a cap")
661                    .reset_minutes,
662                Some(minutes),
663                "{text}"
664            );
665        }
666    }
667
668    /// Nothing that only looks like a clock time is read as one.
669    #[test]
670    fn non_times_are_not_read_as_times() {
671        for text in [
672            "Session limit reached",
673            "Session limit reached · stream",
674            "Session limit reached · 25pm",
675            "Session limit reached · 0am",
676            "Session limit reached · 9:5pm",
677            "Session limit reached · 9:75pm",
678            "Session limit reached · 9pmx",
679        ] {
680            assert_eq!(read_cap(text).expect("a cap").reset_minutes, None, "{text}");
681        }
682    }
683
684    /// Nearest-occurrence, in both directions: a reset an hour out is still
685    /// ahead, one an hour back has gone by, and the answer holds across
686    /// midnight where a naive comparison flips.
687    #[test]
688    fn a_reset_is_passed_by_nearest_occurrence() {
689        let at = |m| CapReading {
690            reset_minutes: Some(m),
691            retrying: false,
692        };
693        // 21:50 reset, read at 20:50 — an hour to go.
694        assert!(!at(1310).reset_passed(1250));
695        // ...and at 22:50, an hour after it opened.
696        assert!(at(1310).reset_passed(1370));
697        // The reset instant itself counts as passed.
698        assert!(at(1310).reset_passed(1310));
699        // 00:30 reset read at 23:30 is half an hour ahead, not 23 behind.
700        assert!(!at(30).reset_passed(1410));
701        // 23:30 reset read at 00:30 is half an hour behind, not 23 ahead.
702        assert!(at(1410).reset_passed(30));
703    }
704
705    /// The `claude logs` shape: a terminal capture whose words are separated by
706    /// cursor-column moves and whose phrases are broken up by colour changes.
707    /// Reading it as a plain string finds nothing at all.
708    #[test]
709    fn a_terminal_capture_reads_as_its_rendered_text() {
710        let capture = "\u{1b}[?25l\u{1b}[H\u{1b}[38;2;215;119;87mSession\u{1b}[8G\u{1b}[1mlimit\
711                       \u{1b}[39m\u{1b}[14Greached\u{1b}[22G·\u{1b}[24Gresets\u{1b}[31G9:50pm\
712                       \u{1b}[39m\u{1b}[?25h";
713        let reading = read_cap(capture).expect("a cap");
714        assert_eq!(reading.reset_label().as_deref(), Some("21:50"));
715    }
716
717    /// The two halves of the stripping rule, stated on their own: a cursor move
718    /// is a gap and a colour change is not.
719    #[test]
720    fn stripping_keeps_spacing_but_not_styling() {
721        assert_eq!(
722            strip_ansi("and\u{1b}[97Gthe\u{1b}[101Gform"),
723            "and the form"
724        );
725        assert_eq!(
726            strip_ansi("ses\u{1b}[1msion \u{1b}[38;5;153mlimit"),
727            "session limit"
728        );
729        assert_eq!(strip_ansi("a\nb\tc"), "a b c");
730        assert_eq!(strip_ansi("\u{1b}]0;a title\u{7}kept"), "kept");
731        assert_eq!(strip_ansi("plain"), "plain");
732    }
733
734    /// A day in seconds, for writing the epoch tests in units a reader can
735    /// hold.
736    const DAY: i64 = 24 * 60 * 60;
737
738    /// The `cap` verb's own output, which is one number: the instant the
739    /// account's window reopens, exactly as the agent reported it.
740    #[test]
741    fn the_cap_verb_reads_as_an_instant() {
742        let now = 1_786_722_000;
743        assert_eq!(parse_reset_epoch("1786758000\n", now), Some(1_786_758_000));
744        // Whitespace and a trailing newline are the shell's, not the agent's.
745        assert_eq!(
746            parse_reset_epoch("  1786758000  ", now),
747            Some(1_786_758_000)
748        );
749        // Silence is the negative answer: not capped, verb failed, no verb.
750        assert_eq!(parse_reset_epoch("", now), None);
751        assert_eq!(parse_reset_epoch("\n", now), None);
752    }
753
754    /// A number that cannot be a reset is not one. The verb's output is
755    /// whatever an agent template prints, so a stray count or id must not badge
756    /// a session with a window that reopens in 1970 — or in 2031.
757    #[test]
758    fn only_a_plausible_instant_is_believed() {
759        let now = 1_786_722_000;
760        assert_eq!(parse_reset_epoch("42", now), None);
761        assert_eq!(parse_reset_epoch("0", now), None);
762        assert_eq!(parse_reset_epoch(&(now + 400 * DAY).to_string(), now), None);
763        assert_eq!(parse_reset_epoch(&(now - 3 * DAY).to_string(), now), None);
764        // The bounds themselves, since a window that reopened while the
765        // operator slept is precisely the reading the sweep waits for.
766        assert_eq!(
767            parse_reset_epoch(&(now - DAY).to_string(), now),
768            Some(now - DAY)
769        );
770        assert_eq!(
771            parse_reset_epoch(&(now + 7 * DAY).to_string(), now),
772            Some(now + 7 * DAY)
773        );
774    }
775
776    /// A verb that prints more than one line ends with the one it means.
777    #[test]
778    fn the_last_plausible_instant_wins() {
779        let now = 1_786_722_000;
780        let out = format!("{}\n{}\n", now + 60, now + 3600);
781        assert_eq!(parse_reset_epoch(&out, now), Some(now + 3600));
782        // And an implausible number after a good one does not displace it.
783        assert_eq!(
784            parse_reset_epoch(&format!("{}\nattempt 2\n", now + 60), now),
785            Some(now + 60)
786        );
787    }
788
789    /// The precedence proper: the account's instant answers for a session
790    /// whose screen named a time, and it answers exactly — 21:50 read a minute
791    /// later has passed, where the parse would have to guess by nearest
792    /// occurrence.
793    #[test]
794    fn the_accounts_instant_decides_over_the_parsed_clock() {
795        let now = 1_786_722_000;
796        let reading = read_cap("Session limit reached · resets 9:50pm").expect("a cap");
797        let account = AccountCap {
798            reset_epoch: now + 3600,
799            reset_label: Some("21:50".into()),
800        };
801        let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), Some(now));
802        assert_eq!(window.label.as_deref(), Some("21:50"));
803        assert!(!window.passed);
804        assert!(!window.due());
805
806        let account = AccountCap {
807            reset_epoch: now - 60,
808            ..account
809        };
810        let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), Some(now));
811        assert!(window.passed);
812        assert!(window.due());
813    }
814
815    /// The gap the account reading closes, and the reason #437 wants it: a cap
816    /// whose time never parsed is not timed at all, so the sweep can only fire
817    /// on the operator's say-so. With the account's instant it is timed, and a
818    /// clock can decide.
819    #[test]
820    fn an_untimed_cap_is_timed_by_the_account() {
821        let now = 1_786_722_000;
822        let reading = read_cap("Weekly limit reached").expect("a cap");
823        let bare = CapWindow::resolve(&reading, None, Some(12 * 60), Some(now));
824        assert!(!bare.timed);
825        assert_eq!(bare.label, None);
826        assert!(bare.due(), "an untimed cap is the operator's call");
827
828        let account = AccountCap {
829            reset_epoch: now + 3600,
830            reset_label: Some("09:00".into()),
831        };
832        let timed = CapWindow::resolve(&reading, Some(&account), Some(12 * 60), Some(now));
833        assert!(timed.timed);
834        assert_eq!(timed.label.as_deref(), Some("09:00"));
835        assert!(!timed.due(), "the window is known to be shut");
836    }
837
838    /// A retrying session takes no account instant, whatever the account says.
839    /// It is not waiting on the window — it is mid-turn on its own request — so
840    /// an instant that has passed must not mark it due: that is the one badged
841    /// shape a nudge would interrupt rather than help (§8).
842    #[test]
843    fn a_retrying_session_takes_no_instant_and_is_never_due() {
844        let now = 1_786_722_000;
845        let reading = read_cap(
846            "429 Number of requests has exceeded your rate limit · Retrying in 30s · attempt 3/10",
847        )
848        .expect("a cap");
849        assert!(reading.retrying);
850        let account = AccountCap {
851            reset_epoch: now - 3600,
852            reset_label: Some("21:50".into()),
853        };
854        let window = CapWindow::resolve(&reading, Some(&account), Some(12 * 60), Some(now));
855        assert!(window.retrying);
856        assert!(!window.passed, "the window's instant does not speak for it");
857        assert!(!window.due());
858        // And the badge still shows what the session itself named, if anything.
859        let timed = read_cap("Session limit reached · Retrying in 5m (9:50pm) · attempt 2/10")
860            .expect("a cap");
861        let window = CapWindow::resolve(&timed, Some(&account), Some(12 * 60), Some(now));
862        assert_eq!(window.label.as_deref(), Some("21:50"));
863        assert!(!window.due());
864    }
865
866    /// With no account reading the badge is exactly what it was: the screen
867    /// parse, judged by nearest occurrence against the local clock.
868    #[test]
869    fn without_an_account_reading_the_parse_still_answers() {
870        let reading = read_cap("Session limit reached · resets 9:50pm").expect("a cap");
871        let ahead = CapWindow::resolve(&reading, None, Some(20 * 60 + 50), Some(0));
872        assert_eq!(ahead.label.as_deref(), Some("21:50"));
873        assert!(!ahead.passed);
874        assert!(ahead.timed);
875        assert!(CapWindow::resolve(&reading, None, Some(22 * 60 + 50), Some(0)).passed);
876    }
877
878    /// An account reading Voro could not render a label for still decides
879    /// whether the window is open — the instant is the load-bearing half, and
880    /// the session's own time fills the badge behind it.
881    #[test]
882    fn an_unrendered_instant_still_decides() {
883        let now = 1_786_722_000;
884        let reading = read_cap("Session limit reached · resets 9:50pm").expect("a cap");
885        let account = AccountCap {
886            reset_epoch: now - 60,
887            reset_label: None,
888        };
889        let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), Some(now));
890        assert_eq!(window.label.as_deref(), Some("21:50"));
891        assert!(window.passed);
892
893        // And with no clock to compare against, nothing claims it has passed.
894        let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), None);
895        assert!(!window.passed);
896        assert!(window.timed);
897    }
898
899    /// A signature landing at the very edge of the text windows the qualifier
900    /// check over multi-byte output without panicking on a char boundary.
901    #[test]
902    fn windows_never_split_a_multibyte_glyph() {
903        let padding = "·".repeat(400);
904        assert!(read_cap(&format!("{padding}session limit reached{padding}")).is_some());
905        assert!(read_cap("session limit").is_some());
906    }
907}