//! [`ClaudeSdkSession`] — adapter structuré Claude (ARCHITECTURE §17.2, spike **S1**). //! //! Pilote `claude` en mode non-interactif structuré et traduit son flux **JSONL** //! (`--output-format stream-json`, un objet JSON par ligne) vers le contrat de port //! universel [`ReplyEvent`]. Aucun détail Claude (`stream-json`, `session_id`, //! `--resume`) ne franchit la frontière domaine. //! //! # Séparation parsing / machinerie (CRUCIAL — §17.2) //! //! Le **parsing du format Claude est ISOLÉ** dans la fonction pure [`parse_event`]. //! La machinerie de process (spawn, pipes, drain) vit dans [`super::process`] et //! ignore tout du JSON. Le **spike S1 est résolu** : le schéma réel est vérifié //! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte //! le format**, pas la machinerie ni le reste de l'adapter. use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::process::{run_turn, SpawnLine}; /// Résultat du parsing d'une ligne : **zéro ou plusieurs** événements à émettre et/ou /// un `session_id` capté (init/result). Permet à [`parse_event`] de rester **pure** /// (aucun effet de bord) tout en remontant les deux informations. /// /// Le champ `events` est un **vecteur** : une seule ligne `assistant` peut porter /// **plusieurs** blocs (`content[]`) et donc produire **plusieurs** [`ReplyEvent`]. #[derive(Debug, Default, PartialEq, Eq)] pub struct ParsedLine { /// Événements universels à émettre (dans l'ordre), vide pour une ligne de contrôle. pub events: Vec, /// `session_id` Claude capté sur cette ligne (id de conversation pour la reprise). pub session_id: Option, } /// **Parse une ligne du flux `stream-json` de Claude** vers le contrat universel. /// /// # Format RÉEL vérifié 2026-06-09 (spike S1 résolu) /// /// Commande : `claude -p "" --output-format stream-json --verbose` ; /// reprise : `claude --resume -p … --output-format stream-json --verbose`. /// /// Le flux est du **JSONL** (un objet JSON par ligne). Types réels : /// /// - `{"type":"system","subtype":"init","session_id":"","cwd":…,"tools":…,…}` /// ⇒ 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::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}` /// ⇒ **chaque** bloc `text` ⇒ [`ReplyEvent::TextDelta`] ; **chaque** bloc `tool_use` /// ⇒ [`ReplyEvent::ToolActivity`] (`label` = `name`). Une ligne `assistant` peut /// donc produire **plusieurs** événements (contenu multi-blocs). /// - `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"","num_turns":…,…}` /// ⇒ [`ReplyEvent::Final`] (`content` = `result`) et confirme le `session_id`. /// /// Une ligne **vide** est ignorée (`ParsedLine` par défaut). Un objet **inconnu** /// (type non reconnu) est ignoré sans erreur (robustesse : la CLI peut émettre des /// événements de contrôle non pertinents). Seul un JSON **illisible** ⇒ `Decode`. /// /// # Errors /// [`AgentSessionError::Decode`] si la ligne n'est pas un JSON valide. On ne propage /// **jamais** le JSON brut : seul un message de diagnostic court est inclus. pub fn parse_event(line: &str) -> Result { let trimmed = line.trim(); if trimmed.is_empty() { return Ok(ParsedLine::default()); } let value: Value = serde_json::from_str(trimmed) .map_err(|e| AgentSessionError::Decode(format!("ligne JSON illisible: {e}")))?; let session_id = value .get("session_id") .and_then(Value::as_str) .map(str::to_owned); let events = match value.get("type").and_then(Value::as_str) { // 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], // 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") .and_then(Value::as_str) .map(|content| { vec![ReplyEvent::Final { content: content.to_owned(), }] }) .unwrap_or_default(), _ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse). }; Ok(ParsedLine { events, session_id }) } /// Itère **TOUS** les blocs de contenu d'un message `assistant`, dans l'ordre : /// chaque `text` ⇒ `TextDelta`, chaque `tool_use` ⇒ `ToolActivity`. Le `content` /// est un **tableau** : un message multi-blocs produit donc plusieurs événements. fn assistant_events(value: &Value) -> Vec { let Some(content) = value .get("message") .and_then(|m| m.get("content")) .and_then(Value::as_array) else { return Vec::new(); }; let mut events = Vec::new(); for block in content { match block.get("type").and_then(Value::as_str) { Some("text") => { if let Some(text) = block.get("text").and_then(Value::as_str) { events.push(ReplyEvent::TextDelta { text: text.to_owned(), }); } } Some("tool_use") => { let label = block .get("name") .and_then(Value::as_str) .unwrap_or("outil") .to_owned(); events.push(ReplyEvent::ToolActivity { label }); } _ => {} } } 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) } /// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement. /// /// La conversion **générique** (heuristique secondes-vs-ms, parseur ISO-8601, algo /// jour-civil) est factorisée dans [`crate::timeparse`] et **partagée** avec le /// détecteur de niveau 2 (`ratelimit`) — pas de duplication (§21.2-T2). Seule /// l'extraction depuis un `serde_json::Value` (typage int/float/str) reste ici. fn value_to_epoch_ms(v: &Value) -> Option { if let Some(i) = v.as_i64() { return Some(crate::timeparse::int_epoch_to_ms(i)); } if let Some(u) = v.as_u64() { return Some(crate::timeparse::int_epoch_to_ms(i64::try_from(u).ok()?)); } if let Some(f) = v.as_f64() { return Some(crate::timeparse::float_epoch_to_ms(f)); } if let Some(s) = v.as_str() { return crate::timeparse::parse_absolute_ms(s); } None } /// Adapter de session structurée Claude. /// /// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la /// CLI en passant le prompt, et — dès qu'un `session_id` a été capté — le flag de /// reprise pour rester sur la **même** conversation. Le `session_id` Claude est /// exposé via [`conversation_id`](AgentSession::conversation_id) (pivot de reprise /// model-agnostic, persisté sur la cellule). pub struct ClaudeSdkSession { /// Id de session IdeA (mappe la cellule/agent). id: SessionId, /// Binaire à lancer (`claude` en prod, fake CLI en test). command: String, /// Répertoire de travail (run dir isolé §14.1). cwd: String, /// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant. conversation_id: Mutex>, /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). sandbox: Option, /// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas /// de sandboxing même si un plan est présent (cohérent avec le chemin PTY). sandbox_enforcer: Option>, } impl ClaudeSdkSession { /// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable /// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan:: /// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` / /// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None` /// ⇒ chemin natif inchangé. #[must_use] pub fn new( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, sandbox: Option, sandbox_enforcer: Option>, ) -> Self { Self { id, command: command.into(), cwd: cwd.into(), conversation_id: Mutex::new(seed_conversation_id), sandbox, sandbox_enforcer, } } /// Compose la ligne de commande d'un tour selon l'état de conversation. /// /// Format RÉEL vérifié 2026-06-09 : /// - Conversation neuve : `claude -p --output-format stream-json --verbose`. /// - Reprise (id connu) : `claude --resume -p --output-format /// stream-json --verbose`. /// /// Le flag `--verbose` est **requis** : sans lui, `--output-format stream-json` /// n'émet pas le flux JSONL ligne-à-ligne attendu par le parser. fn build_spawn_line(&self, prompt: &str) -> SpawnLine { let mut args = Vec::new(); if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() { args.push("--resume".to_owned()); args.push(id.clone()); } args.push("-p".to_owned()); args.push(prompt.to_owned()); args.push("--output-format".to_owned()); args.push("stream-json".to_owned()); args.push("--verbose".to_owned()); SpawnLine { command: self.command.clone(), args, cwd: self.cwd.clone(), env: Vec::new(), stdin: None, sandbox: self.sandbox.clone(), } } } #[async_trait] impl AgentSession for ClaudeSdkSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { self.conversation_id.lock().expect("mutex sain").clone() } async fn send(&self, prompt: &str) -> Result { self.send_inner(prompt, None).await } async fn send_with_tap( &self, prompt: &str, tap: std::sync::mpsc::Sender, ) -> Result { self.send_inner(prompt, Some(tap)).await } async fn shutdown(&self) -> Result<(), AgentSessionError> { // Incarnation « un run par tour » : aucun process long ne survit entre les // tours, donc `shutdown` est intrinsèquement idempotent et sans effet. Ok(()) } } impl ClaudeSdkSession { async fn send_inner( &self, prompt: &str, tap: Option>, ) -> Result { let spec = self.build_spawn_line(prompt); let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| { let (line_tx, line_rx) = std::sync::mpsc::channel::(); let parser = std::thread::spawn(move || { for line in line_rx { if let Ok(parsed) = parse_event(&line) { for event in parsed.events { if let ReplyEvent::TextDelta { text } = event { let _ = event_tap.send(ReplyEvent::Announcement { text }); } } } } }); (Some(line_tx), Some(parser)) }); let raw_result = run_turn(&spec, None, self.sandbox_enforcer.as_ref(), line_tap).await; if let Some(parser) = parser_thread { let _ = parser.join(); } let raw_lines = raw_result?; let mut events = Vec::new(); let mut captured_id = None; 'lines: for line in &raw_lines { let parsed = parse_event(line)?; if let Some(id) = parsed.session_id { captured_id = Some(id); } // Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements. // 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); if is_final { break 'lines; } } } // Persiste le session_id capté (pivot de reprise) avant de rendre le flux. if let Some(id) = captured_id { *self.conversation_id.lock().expect("mutex sain") = Some(id); } Ok(Box::new(events.into_iter())) } }