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:
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user