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}