From a1755e51bcbfff7989cf881f4120d2e864e79440 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 14:44:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(session-limits):=20LS2=20=E2=80=94=20adapt?= =?UTF-8?q?er=20Claude=20niveau=201=20(infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/infrastructure/src/session/claude.rs | 192 +++++++++- .../infrastructure/src/session/conformance.rs | 6 +- crates/infrastructure/src/session/mod.rs | 359 +++++++++++++++++- 3 files changed, 545 insertions(+), 12 deletions(-) diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 8b00329..94d08c4 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -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 { // 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 { 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 { + 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 { + 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::() { + return Some(int_epoch_to_ms(i)); + } + if let Ok(f) = t.parse::() { + 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 { + 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::().ok()?, m.parse::().ok()?) + } else if tz.len() == 4 { + (tz[0..2].parse::().ok()?, tz[2..4].parse::().ok()?) + } else { + (tz.parse::().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::().ok()?, 0)); + }; + let sec = sec.parse::().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::().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); diff --git a/crates/infrastructure/src/session/conformance.rs b/crates/infrastructure/src/session/conformance.rs index 416beb4..5fe8d19 100644 --- a/crates/infrastructure/src/session/conformance.rs +++ b/crates/infrastructure/src/session/conformance.rs @@ -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:?}" ); } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 0d6a3b9..09ef248 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -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 = 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 + ); + } }