feat(session-limits): LS2 — adapter Claude niveau 1 (infra)

Câble la détection de limite de session dans l'adapter Claude :
- claude.rs : parse_event émet ReplyEvent::RateLimited ; nouvelle fonction
  pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4.
- mod.rs : 26 nouveaux tests QA (#[cfg(test)]) + 2 tests existants alignés
  sur le nouveau contrat.
- conformance.rs : RateLimited ajouté aux événements non terminaux autorisés.

`cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:44:53 +02:00
parent 0bf1eb3b11
commit a1755e51bc
3 changed files with 545 additions and 12 deletions

View File

@ -51,7 +51,11 @@ pub struct ParsedLine {
/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un
/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré).
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant).
/// ⇒ [`ReplyEvent::RateLimited`] (ARCHITECTURE §21, niveau 1 structuré) : l'heure
/// de reset est extraite de `rate_limit_info` par [`parse_reset_ms`] et normalisée
/// en époche-ms. Sans `rate_limit_info` exploitable ⇒ `RateLimited{None}` (limite
/// détectée, heure inconnue ⇒ filet humain en aval). **Non terminal** (comme le
/// `Heartbeat`) : il s'intercale, le flux continue jusqu'au `Final` ou la clôture.
/// - `{"type":"assistant","message":{"role":"assistant","content":[
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
/// ], …},"session_id":"…","parent_tool_use_id":null}`
@ -85,9 +89,14 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
// init/handshake : on capte le session_id ET on émet un battement de cœur
// (preuve de vivacité non terminale : la CLI a démarré et répond).
Some("system") => vec![ReplyEvent::Heartbeat],
// Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒
// battement de cœur (readiness/heartbeat lot 1), plus ignoré.
Some("rate_limit_event") => vec![ReplyEvent::Heartbeat],
// Limite de session/débit (ARCHITECTURE §21, niveau 1) : on lit l'heure de
// reset dans `rate_limit_info` (au lieu de la jeter) et on émet un
// `RateLimited{resets_at_ms}` **non terminal**. Robuste : absence/illisibilité
// de `rate_limit_info` ⇒ `RateLimited{None}` (jamais d'erreur), filet humain.
Some("rate_limit_event") => {
let resets_at_ms = value.get("rate_limit_info").and_then(parse_reset_ms);
vec![ReplyEvent::RateLimited { resets_at_ms }]
}
Some("assistant") => assistant_events(&value),
Some("result") => value
.get("result")
@ -139,6 +148,173 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
events
}
/// **Extrait l'heure de reset d'une limite de débit** depuis l'objet
/// `rate_limit_info` d'un `rate_limit_event` Claude, **normalisée en époche-ms**
/// (ARCHITECTURE §21, niveau 1 structuré). Fonction **pure** (aucune I/O, aucun
/// `now`), isolée du reste de l'adapter pour être testable sans process.
///
/// # Spike §21.10-1 — format réel non garanti, parsing défensif
///
/// Le schéma exact du champ de reset n'est **pas** documenté de façon stable. On
/// gère donc défensivement plusieurs **noms de champ** plausibles, dans l'ordre :
/// `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`. La première clé présente
/// gagne. Sa valeur est convertie en époche-ms selon son type ([`value_to_epoch_ms`]) :
///
/// - **entier/float epoch en secondes** (magnitude `< 10^12`) ⇒ `× 1000` ;
/// - **entier/float epoch en millisecondes** (magnitude `≥ 10^12`) ⇒ tel quel ;
/// - **chaîne** : d'abord tentée comme entier/float epoch (même heuristique), sinon
/// parsée comme **ISO-8601 / RFC3339** ([`parse_rfc3339_to_ms`]).
///
/// L'heuristique secondes-vs-ms repose sur le **seuil de magnitude** [`EPOCH_MS_THRESHOLD`]
/// (`10^12`) : `10^12 ms ≈ 2001-09`, `10^12 s ≈ an 33658` — toute date plausible
/// (1970…) tombe sans ambiguïté du bon côté.
///
/// # Hypothèses & limites assumées
///
/// - Un champ **relatif** (`retryAfter` / `retry_after`, en secondes) **n'est pas**
/// exploité ici : le convertir en instant absolu exigerait `now`, or cette fonction
/// est **pure et sans horloge** (cf. `Clock` confiné à l'application). Il est donc
/// **ignoré** (documenté) ⇒ `None` ⇒ `RateLimited{None}` ⇒ filet humain. Une
/// résolution `now + delta` pourra être ajoutée côté appelant/application (LS4) qui,
/// lui, détient `Clock`.
/// - Une chaîne ISO **sans fuseau** est traitée comme **UTC** (best-effort), le reset
/// Claude étant une heure absolue. La gestion d'heure murale locale relève du
/// niveau 2 (LS5, §21.10-2), pas d'ici.
///
/// Retourne `None` (jamais d'erreur) si aucune clé connue n'est présente ou si la
/// valeur est inexploitable — l'appelant émet alors `RateLimited{None}`.
#[must_use]
pub fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64> {
const RESET_KEYS: [&str; 5] = ["resetsAt", "resets_at", "reset_at", "resetAt", "reset"];
let raw = RESET_KEYS.iter().find_map(|k| rate_limit_info.get(*k))?;
value_to_epoch_ms(raw)
}
/// Seuil de magnitude départageant un epoch en **secondes** d'un epoch en
/// **millisecondes** : `10^12 ms ≈ 2001-09-09`, `10^12 s ≈ an 33658`. Tout epoch
/// plausible (≥ 1970) de magnitude `≥ 10^12` est donc déjà des millisecondes ;
/// en-dessous, ce sont des secondes.
const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000;
/// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement.
fn value_to_epoch_ms(v: &Value) -> Option<i64> {
if let Some(i) = v.as_i64() {
return Some(int_epoch_to_ms(i));
}
if let Some(u) = v.as_u64() {
return Some(int_epoch_to_ms(i64::try_from(u).ok()?));
}
if let Some(f) = v.as_f64() {
return Some(float_epoch_to_ms(f));
}
if let Some(s) = v.as_str() {
let t = s.trim();
if let Ok(i) = t.parse::<i64>() {
return Some(int_epoch_to_ms(i));
}
if let Ok(f) = t.parse::<f64>() {
return Some(float_epoch_to_ms(f));
}
return parse_rfc3339_to_ms(t);
}
None
}
/// Applique l'heuristique secondes-vs-ms à un epoch **entier**.
const fn int_epoch_to_ms(n: i64) -> i64 {
if n.abs() >= EPOCH_MS_THRESHOLD {
n
} else {
n * 1000
}
}
/// Idem pour un epoch **flottant** (fraction de seconde préservée → ms).
fn float_epoch_to_ms(f: f64) -> i64 {
if f.abs() >= EPOCH_MS_THRESHOLD as f64 {
f as i64
} else {
(f * 1000.0) as i64
}
}
/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en
/// époche-ms. Pur, dépendance-zéro (aucune crate de date). Best-effort : retourne
/// `None` sur toute forme non reconnue. Sans désignateur de fuseau ⇒ traité **UTC**.
fn parse_rfc3339_to_ms(s: &str) -> Option<i64> {
let (date, rest) = s.split_once(['T', 't', ' '])?;
let mut dp = date.split('-');
let year: i64 = dp.next()?.parse().ok()?;
let month: i64 = dp.next()?.parse().ok()?;
let day: i64 = dp.next()?.parse().ok()?;
if dp.next().is_some() {
return None;
}
let (time, tz_offset_secs) = split_tz(rest)?;
let mut tp = time.split(':');
let hour: i64 = tp.next()?.parse().ok()?;
let minute: i64 = tp.next()?.parse().ok()?;
let (second, millis) = split_seconds_frac(tp.next().unwrap_or("0"))?;
if tp.next().is_some() {
return None;
}
let days = days_from_civil(year, month, day);
let epoch_secs = days * 86_400 + hour * 3_600 + minute * 60 + second - tz_offset_secs;
Some(epoch_secs * 1000 + millis)
}
/// Sépare la partie heure de son **désignateur de fuseau** et renvoie l'offset en
/// secondes (à **soustraire** de l'heure locale pour obtenir l'UTC). `Z`/`z` ⇒ 0 ;
/// `±hh:mm` ou `±hhmm` ⇒ offset signé ; aucun désignateur ⇒ 0 (UTC best-effort).
fn split_tz(rest: &str) -> Option<(&str, i64)> {
if let Some(stripped) = rest.strip_suffix(['Z', 'z']) {
return Some((stripped, 0));
}
if let Some(pos) = rest.rfind(['+', '-']) {
let (time, tz) = rest.split_at(pos);
let sign = if tz.starts_with('-') { -1 } else { 1 };
let tz = &tz[1..];
let (h, m) = if let Some((h, m)) = tz.split_once(':') {
(h.parse::<i64>().ok()?, m.parse::<i64>().ok()?)
} else if tz.len() == 4 {
(tz[0..2].parse::<i64>().ok()?, tz[2..4].parse::<i64>().ok()?)
} else {
(tz.parse::<i64>().ok()?, 0)
};
return Some((time, sign * (h * 3_600 + m * 60)));
}
Some((rest, 0))
}
/// Sépare `SS` ou `SS.fff…` en `(secondes, millisecondes)`. La fraction est tronquée
/// /complétée à **3 chiffres** (précision ms).
fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
let Some((sec, frac)) = s.split_once('.') else {
return Some((s.parse::<i64>().ok()?, 0));
};
let sec = sec.parse::<i64>().ok()?;
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
while d3.len() < 3 {
d3.push('0');
}
let millis = d3.parse::<i64>().ok()?;
Some((sec, millis))
}
/// Jours depuis l'époque Unix (1970-01-01) pour une date civile proleptique
/// grégorienne. Algorithme de Howard Hinnant (`days_from_civil`), exact et
/// dépendance-zéro ; gère bissextiles et siècles.
const fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// Adapter de session structurée Claude.
///
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la
@ -241,9 +417,11 @@ impl AgentSession for ClaudeSdkSession {
captured_id = Some(id);
}
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
// Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès
// qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…)
// ne le suive dans le flux.
// Le `Final` est le **seul** événement terminal (contrat de port, §21-T4) :
// on arrête d'émettre dès qu'on l'a vu. Un `RateLimited` (comme un
// `Heartbeat`) est **non terminal** : il s'intercale et ne rompt JAMAIS la
// boucle — un tour limité clos sans `Final` reste une fin gracieuse (le
// traitement « limité » est en aval, application lot LS4).
for event in parsed.events {
let is_final = matches!(event, ReplyEvent::Final { .. });
events.push(event);

View File

@ -210,7 +210,8 @@ pub(crate) mod harness {
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
}
// Les événements avant le Final ne sont que des deltas / activités / heartbeats
// (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1).
// / rate-limited (tous **non terminaux** ; le heartbeat est une preuve de
// vivacité, lot 1 ; le RateLimited s'intercale comme un heartbeat, §21-T4).
for e in &events[..events.len() - 1] {
assert!(
matches!(
@ -218,8 +219,9 @@ pub(crate) mod harness {
ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. }
| ReplyEvent::Heartbeat
| ReplyEvent::RateLimited { .. }
),
"avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}"
"avant le Final, seuls deltas/activités/heartbeats/rate-limited sont permis, vu: {e:?}"
);
}

View File

@ -122,14 +122,20 @@ mod tests {
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
}
/// §21 (LS2) : un `rate_limit_event` n'est PLUS un heartbeat — il porte désormais
/// un [`ReplyEvent::RateLimited`] (niveau 1 structuré). Sans heure de reset
/// exploitable dans `rate_limit_info` ⇒ `RateLimited{None}` (filet humain en aval).
/// Le `session_id` reste capté.
#[test]
fn claude_parse_rate_limit_event_is_heartbeat() {
fn claude_parse_rate_limit_event_without_reset_is_rate_limited_none() {
let parsed = claude::parse_event(
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
)
.expect("parse ok");
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
assert_eq!(
parsed.events,
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
);
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
}
@ -1286,4 +1292,351 @@ mod tests {
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
// =====================================================================
// LS2 — adapter Claude niveau 1 (§21) : `parse_reset_ms` (parseur ISO-8601
// À LA MAIN + heuristique secondes/ms + days_from_civil) et le mapping
// `parse_event` du `rate_limit_event` vers `ReplyEvent::RateLimited`, plus
// la NON-TERMINALITÉ (T4). Tout passe par les fonctions pures (jamais de
// process) sauf le test de séquence via `send()` (FakeCli).
// =====================================================================
use serde_json::json;
use super::claude::parse_reset_ms;
// ---- parse_reset_ms : noms de champ reconnus + priorité ----------------
/// Les cinq noms de champ plausibles sont chacun reconnus (valeur en secondes
/// ⇒ ×1000). Couvre `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`.
#[test]
fn parse_reset_ms_recognises_every_field_name() {
for key in ["resetsAt", "resets_at", "reset_at", "resetAt", "reset"] {
let info = json!({ key: 1_700_000_000_i64 });
assert_eq!(
parse_reset_ms(&info),
Some(1_700_000_000_000),
"le champ `{key}` doit être reconnu (epoch secondes ×1000)"
);
}
}
/// Priorité : si plusieurs clés sont présentes, la PREMIÈRE de l'ordre
/// (`resetsAt` avant `reset`) gagne.
#[test]
fn parse_reset_ms_first_known_key_wins() {
// resetsAt (priorité 1) = 1_700_000_000 s ; reset (priorité 5) = 5 s.
let info = json!({ "reset": 5, "resetsAt": 1_700_000_000_i64 });
assert_eq!(
parse_reset_ms(&info),
Some(1_700_000_000_000),
"resetsAt prime sur reset"
);
}
// ---- parse_reset_ms : heuristique secondes vs millisecondes ------------
#[test]
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
// < 10^12 ⇒ secondes ⇒ ×1000.
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
}
#[test]
fn parse_reset_ms_integer_millis_are_kept_as_is() {
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
}
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
/// ⇒ millisecondes (tel quel).
#[test]
fn parse_reset_ms_threshold_boundary() {
// 10^12 - 1 ⇒ secondes ⇒ ×1000.
assert_eq!(
parse_reset_ms(&json!({ "reset": 999_999_999_999_i64 })),
Some(999_999_999_999_000)
);
// 10^12 pile ⇒ millisecondes ⇒ tel quel (la borne est inclusive côté ms).
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_000_000_000_000_i64 })),
Some(1_000_000_000_000)
);
}
// ---- parse_reset_ms : floats ------------------------------------------
#[test]
fn parse_reset_ms_float_seconds_preserve_fraction() {
// 1_700_000_000.5 s < 10^12 ⇒ ×1000 = 1_700_000_000_500 ms.
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_700_000_000.5_f64 })),
Some(1_700_000_000_500)
);
}
#[test]
fn parse_reset_ms_float_millis_kept_as_is() {
// 1.7e12 ≥ 10^12 ⇒ déjà ms ⇒ tronqué tel quel.
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_700_000_000_000.0_f64 })),
Some(1_700_000_000_000)
);
}
// ---- parse_reset_ms : chaînes numériques (même heuristique) ------------
#[test]
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
}
#[test]
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
}
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
/// `...Z` (UTC) : un instant rond connu. `2023-11-14T22:13:20Z` correspond à
/// l'epoch 1_700_000_000 s ⇒ 1_700_000_000_000 ms. Recoupe le parseur ISO
/// maison (days_from_civil + math d'heure) contre l'heuristique secondes.
#[test]
fn parse_reset_ms_iso_utc_z() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11-14T22:13:20Z" })),
Some(1_700_000_000_000)
);
}
/// Offset `+hh:mm` : `2023-11-14T23:13:20+01:00` est le MÊME instant que
/// `22:13:20Z` ⇒ doit donner exactement le même epoch-ms (offset soustrait).
#[test]
fn parse_reset_ms_iso_positive_offset_converts_to_utc() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11-14T23:13:20+01:00" })),
Some(1_700_000_000_000),
"+01:00 ⇒ on soustrait 1h pour revenir à l'UTC"
);
}
/// Offset `-hh:mm` : `2023-11-14T21:13:20-01:00` est aussi `22:13:20Z`.
#[test]
fn parse_reset_ms_iso_negative_offset_converts_to_utc() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11-14T21:13:20-01:00" })),
Some(1_700_000_000_000),
"-01:00 ⇒ on ajoute 1h pour revenir à l'UTC"
);
}
/// Offset compact `±hhmm` (sans `:`) supporté par `split_tz`.
#[test]
fn parse_reset_ms_iso_compact_offset() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11-14T23:13:20+0100" })),
Some(1_700_000_000_000)
);
}
/// Fraction de seconde `.fff` : tronquée/complétée à 3 chiffres (précision ms).
#[test]
fn parse_reset_ms_iso_fraction_padded_and_truncated() {
// `.5` ⇒ "500" ms.
assert_eq!(
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.5Z" })),
Some(500)
);
// `.123456` ⇒ tronqué à "123" ms.
assert_eq!(
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.123456Z" })),
Some(123)
);
// `.7` ⇒ complété à "700" ms.
assert_eq!(
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00.7Z" })),
Some(700)
);
}
// ---- parse_reset_ms : robustesse (jamais de panic, jamais d'erreur) ----
#[test]
fn parse_reset_ms_unknown_key_yields_none() {
// Aucune clé connue ⇒ None.
assert_eq!(parse_reset_ms(&json!({ "retryAfter": 60 })), None);
assert_eq!(parse_reset_ms(&json!({})), None);
}
#[test]
fn parse_reset_ms_non_numeric_garbage_yields_none() {
// Valeurs inexploitables (booléen, null, tableau, objet, chaîne pourrie) ⇒ None.
assert_eq!(parse_reset_ms(&json!({ "reset": true })), None);
assert_eq!(parse_reset_ms(&json!({ "reset": null })), None);
assert_eq!(parse_reset_ms(&json!({ "reset": [1, 2, 3] })), None);
assert_eq!(parse_reset_ms(&json!({ "reset": { "nested": 1 } })), None);
assert_eq!(parse_reset_ms(&json!({ "reset": "pas une date" })), None);
}
/// Formes ISO **structurellement** malformées ⇒ None (pas de panic). NB : le
/// parseur maison ne valide PAS les plages (un mois 13 / jour 99 calcule une
/// valeur sans erreur) ; ce qui produit `None`, c'est l'ABSENCE de séparateur
/// `T`, une composante non numérique, ou un nombre de composantes invalide.
#[test]
fn parse_reset_ms_invalid_iso_string_yields_none() {
// Pas de séparateur de date/heure.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
// Année non numérique.
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
// Composante de date manquante (pas de jour).
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
// Trop de composantes de date.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
// Minute manquante dans l'heure.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
}
// ---- days_from_civil & bissextiles (via le parseur ISO) ----------------
/// Référence absolue : l'époque Unix elle-même. `1970-01-01T00:00:00Z` ⇒ 0 ms
/// (days_from_civil(1970,1,1) == 0).
#[test]
fn parse_reset_ms_unix_epoch_is_zero() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "1970-01-01T00:00:00Z" })),
Some(0)
);
}
/// Année bissextile : le 29 février 2024 existe et donne l'epoch attendu.
/// `2024-02-29T00:00:00Z` = 1_709_164_800 s = 1_709_164_800_000 ms (calculé à la
/// main : 2024-01-01 = 1_704_067_200 ; +31j (janvier) ; +28j pour atteindre le 29).
#[test]
fn parse_reset_ms_leap_day_2024_02_29() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "2024-02-29T00:00:00Z" })),
Some(1_709_164_800_000)
);
}
/// Date post-2001 connue, recoupée indépendamment : `2021-01-01T00:00:00Z`
/// = 1_609_459_200 s = 1_609_459_200_000 ms.
#[test]
fn parse_reset_ms_known_post_2001_date() {
assert_eq!(
parse_reset_ms(&json!({ "reset": "2021-01-01T00:00:00Z" })),
Some(1_609_459_200_000)
);
}
// ---- parse_event : mapping rate_limit_event ----------------------------
/// `rate_limit_event` avec `rate_limit_info.resetsAt` exploitable ⇒
/// `RateLimited{Some(...)}` (l'heure de reset est extraite et normalisée).
#[test]
fn parse_event_rate_limit_with_reset_yields_rate_limited_some() {
let parsed = claude::parse_event(
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"c"}"#,
)
.expect("parse ok");
assert_eq!(
parsed.events,
vec![ReplyEvent::RateLimited {
resets_at_ms: Some(1_700_000_000_000)
}]
);
assert_eq!(parsed.session_id.as_deref(), Some("c"));
}
/// `rate_limit_event` SANS `rate_limit_info` exploitable ⇒ `RateLimited{None}`
/// (et surtout PAS un `Heartbeat` : c'est le changement §21/LS2).
#[test]
fn parse_event_rate_limit_without_info_is_rate_limited_none_not_heartbeat() {
// rate_limit_info absent.
let absent = claude::parse_event(r#"{"type":"rate_limit_event","session_id":"c"}"#)
.expect("parse ok");
assert_eq!(
absent.events,
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
);
assert_ne!(absent.events, vec![ReplyEvent::Heartbeat]);
// rate_limit_info présent mais sans clé de reset connue.
let no_key = claude::parse_event(
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"c"}"#,
)
.expect("parse ok");
assert_eq!(
no_key.events,
vec![ReplyEvent::RateLimited { resets_at_ms: None }]
);
}
// ---- Non-terminalité (T4) : RateLimited n'interrompt PAS ----------------
/// Au niveau séquence de `parse_event` : `rate_limit_event` puis `result` ⇒ la
/// concaténation des events est `[RateLimited, Final]` — le RateLimited s'intercale
/// et seul le Final clôt.
#[test]
fn parse_event_sequence_rate_limited_then_final_is_not_interrupted() {
let rl = claude::parse_event(
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"c"}"#,
)
.expect("parse ok");
let fin = claude::parse_event(
r#"{"type":"result","subtype":"success","result":"fini","session_id":"c"}"#,
)
.expect("parse ok");
let mut seq = rl.events;
seq.extend(fin.events);
assert_eq!(
seq,
vec![
ReplyEvent::RateLimited {
resets_at_ms: Some(1_700_000_000_000)
},
ReplyEvent::Final {
content: "fini".to_owned()
},
]
);
}
/// Au niveau `send()` (FakeCli) : init → rate_limit_event → assistant → result ⇒
/// le flux émis est `[Heartbeat, RateLimited, TextDelta, Final]`. Le RateLimited
/// NE rompt PAS la boucle d'émission (T4) ; seul le Final clôt — on le PROUVE
/// bout-en-bout, pas seulement au niveau parse.
#[tokio::test]
async fn send_emits_rate_limited_intercalated_only_final_closes() {
let fake = FakeCli::printing(&[
r#"{"type":"system","subtype":"init","session_id":"rl-1","cwd":"/tmp","tools":[]}"#,
r#"{"type":"rate_limit_event","rate_limit_info":{"resetsAt":1700000000},"session_id":"rl-1"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,
vec![
ReplyEvent::Heartbeat,
ReplyEvent::RateLimited {
resets_at_ms: Some(1_700_000_000_000)
},
ReplyEvent::TextDelta { text: "ap".into() },
ReplyEvent::Final {
content: "ok".into()
},
],
"RateLimited s'intercale (non terminal), seul Final clôt"
);
// Exactement un Final, en dernière position.
assert_eq!(
events
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count(),
1
);
}
}