diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 4942bf2..6ffeb15 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -9,9 +9,9 @@ //! //! 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. Quand le **spike S1** aura confirmé le schéma réel auprès de -//! l'utilisateur, **seule [`parse_event`] (et la composition de la commande) devra -//! changer**, pas la machinerie ni le reste de l'adapter. +//! 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::Mutex; @@ -23,39 +23,43 @@ use domain::SessionId; use super::process::{run_turn, SpawnLine}; -/// Résultat du parsing d'une ligne : un événement à émettre (le cas échéant) et/ou +/// 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énement universel à émettre, ou `None` (ligne de contrôle, ex. `init`). - pub event: Option, + /// É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. /// -/// # Schéma SUPPOSÉ — à confirmer au spike S1 (réf. doc API Claude / Agent SDK) +/// # Format RÉEL vérifié 2026-06-09 (spike S1 résolu) /// -/// Le flux est du **JSONL** (un objet JSON par ligne). Schéma présumé : +/// Commande : `claude -p "" --output-format stream-json --verbose` ; +/// reprise : `claude --resume -p … --output-format stream-json --verbose`. /// -/// - `{"type":"system","subtype":"init","session_id":"", …}` +/// 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), **aucun** /// événement émis. +/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}` +/// ⇒ **ignoré** (comme tout `type` inconnu). /// - `{"type":"assistant","message":{"role":"assistant","content":[ /// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …} -/// ]}, "session_id":"…"}` -/// ⇒ chaque bloc `text` ⇒ [`ReplyEvent::TextDelta`] ; chaque bloc `tool_use` -/// ⇒ [`ReplyEvent::ToolActivity`] (`label` = `name`). -/// - `{"type":"result","subtype":"success","result":"","session_id":"", …}` +/// ], …},"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`. /// -/// > NOTE S1 : ce schéma est **présumé**. Les noms exacts (`assistant` vs -/// > `content_block_delta`, structure de `content`, sous-type de `result`) seront -/// > vérifiés au spike. Le contrat de sortie ([`ReplyEvent`]) ne bougera pas — seule -/// > cette fonction changera. -/// /// 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`. @@ -76,36 +80,41 @@ pub fn parse_event(line: &str) -> Result { .and_then(Value::as_str) .map(str::to_owned); - let event = match value.get("type").and_then(Value::as_str) { - Some("system") => None, // init/handshake : on ne capte que le session_id. - Some("assistant") => first_assistant_event(&value), - Some("result") => { - value - .get("result") - .and_then(Value::as_str) - .map(|content| ReplyEvent::Final { + let events = match value.get("type").and_then(Value::as_str) { + Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id. + Some("assistant") => assistant_events(&value), + Some("result") => value + .get("result") + .and_then(Value::as_str) + .map(|content| { + vec![ReplyEvent::Final { content: content.to_owned(), - }) - } - _ => None, // type inconnu / non pertinent : ignoré (robustesse). + }] + }) + .unwrap_or_default(), + _ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré. }; - Ok(ParsedLine { event, session_id }) + Ok(ParsedLine { events, session_id }) } -/// Extrait le **premier** bloc de contenu pertinent d'un message `assistant` : -/// un `text` ⇒ `TextDelta`, un `tool_use` ⇒ `ToolActivity`. (Un message porte en -/// pratique un bloc ; on prend le premier pertinent — robuste si la forme évolue.) -fn first_assistant_event(value: &Value) -> Option { - let content = value +/// 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)?; + .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) { - return Some(ReplyEvent::TextDelta { + events.push(ReplyEvent::TextDelta { text: text.to_owned(), }); } @@ -116,12 +125,12 @@ fn first_assistant_event(value: &Value) -> Option { .and_then(Value::as_str) .unwrap_or("outil") .to_owned(); - return Some(ReplyEvent::ToolActivity { label }); + events.push(ReplyEvent::ToolActivity { label }); } _ => {} } } - None + events } /// Adapter de session structurée Claude. @@ -163,12 +172,13 @@ impl ClaudeSdkSession { /// Compose la ligne de commande d'un tour selon l'état de conversation. /// - /// - Conversation neuve : `claude -p --output-format stream-json`. + /// 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`. + /// stream-json --verbose`. /// - /// > NOTE S1 : flags exacts (`-p`, `--resume`, `--output-format stream-json`, - /// > éventuel `--input-format stream-json`) à confirmer au spike. + /// 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() { @@ -179,6 +189,7 @@ impl ClaudeSdkSession { 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, @@ -210,9 +221,8 @@ impl AgentSession for ClaudeSdkSession { if let Some(id) = parsed.session_id { captured_id = Some(id); } - if let Some(event) = parsed.event { - events.push(event); - } + // Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements. + events.extend(parsed.events); } // Persiste le session_id capté (pivot de reprise) avant de rendre le flux. if let Some(id) = captured_id { diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index caa275b..a358be0 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -1,16 +1,14 @@ //! [`CodexExecSession`] — adapter structuré Codex (ARCHITECTURE §17.2, spike **S2**). //! -//! Pilote `codex exec` en mode non-interactif et traduit sa sortie structurée vers -//! le contrat universel [`ReplyEvent`]. **Le format de Codex est BEAUCOUP plus -//! incertain que celui de Claude** : tout le format présumé est ISOLÉ dans -//! [`parse_event`], clairement marqué « SUPPOSÉ — à confirmer S2 ». +//! Pilote `codex exec --json` en mode non-interactif et traduit sa sortie structurée +//! vers le contrat universel [`ReplyEvent`]. Le **spike S2 est résolu** : le format +//! réel est vérifié (2026-06-09) et ISOLÉ dans [`parse_event`]. //! //! # Séparation parsing / machinerie (CRUCIAL — §17.2) //! //! Comme pour Claude, la machinerie de process vit dans [`super::process`] et ignore -//! le format. Quand le **spike S2** aura confirmé la sortie réelle (process -//! persistant vs `exec` par tour, schéma JSON de fin de tour), **seule -//! [`parse_event`] (et la composition de la commande) changera**. +//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le +//! format Codex** ; la machinerie reste inchangée. use std::sync::Mutex; @@ -22,35 +20,36 @@ use domain::SessionId; use super::process::{run_turn, SpawnLine}; -/// Résultat du parsing d'une ligne Codex : un événement (le cas échéant) et/ou un id -/// de conversation Codex capté. Miroir de `claude::ParsedLine`. +/// Résultat du parsing d'une ligne Codex : **zéro ou plusieurs** événements et/ou un +/// id de conversation Codex capté. Miroir de `claude::ParsedLine` (vecteur d'events +/// pour homogénéité du drain ; en pratique Codex rend 0 ou 1 événement par ligne). #[derive(Debug, Default, PartialEq, Eq)] pub struct ParsedLine { - /// Événement universel à émettre, ou `None`. - pub event: Option, - /// Id de conversation Codex capté (pour la reprise via le flag Codex). + /// Événements universels à émettre (dans l'ordre), vide pour une ligne de contrôle. + pub events: Vec, + /// Id de conversation Codex capté (= `thread_id`, pour la reprise). pub conversation_id: Option, } -/// **Parse une ligne de la sortie structurée de `codex exec`** vers le contrat +/// **Parse une ligne de la sortie structurée de `codex exec --json`** vers le contrat /// universel. /// -/// # Schéma SUPPOSÉ — à confirmer au spike S2 (format Codex TRÈS incertain) +/// # Format RÉEL vérifié 2026-06-09 (spike S2 résolu) /// -/// On suppose le **minimum viable** : un flux de lignes JSON (JSONL), dont -/// **un** événement final identifiable porte le texte de réponse. Schéma présumé : +/// Commande : `codex exec --json --skip-git-repo-check ""` ; +/// reprise : `codex exec resume --json --skip-git-repo-check ""`. /// -/// - `{"type":"session","conversation_id":"", …}` (ou `"id"`) ⇒ capte l'id de -/// conversation Codex (pour `--resume`/reprise), **aucun** événement émis. -/// - `{"type":"message"|"delta","text":"…"}` ⇒ [`ReplyEvent::TextDelta`]. -/// - `{"type":"tool"|"tool_call","name":"…"}` ⇒ [`ReplyEvent::ToolActivity`]. -/// - `{"type":"result"|"final","text":"", …}` (ou champ `"output"`) -/// ⇒ [`ReplyEvent::Final`] : **l'événement terminal du tour**. +/// Le flux est du **JSONL**. Types réels : /// -/// > NOTE S2 : ce schéma est **largement présumé**. La forme réelle de `codex exec` -/// > (noms de types, champ portant le texte, présence d'un id de conversation, mode -/// > persistant vs one-shot) est à confirmer au spike. Le contrat de sortie -/// > ([`ReplyEvent`]) ne bougera pas — seule cette fonction changera. +/// - `{"type":"thread.started","thread_id":""}` ⇒ capte le `thread_id` +/// (= id de conversation pour la reprise), **aucun** événement émis. +/// - `{"type":"turn.started"}` ⇒ ignoré. +/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}` +/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`, +/// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒ +/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`). +/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de +/// l'`agent_message`). /// /// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒ /// [`AgentSessionError::Decode`] (jamais de JSON brut propagé). @@ -65,40 +64,42 @@ pub fn parse_event(line: &str) -> Result { let value: Value = serde_json::from_str(trimmed) .map_err(|e| AgentSessionError::Decode(format!("ligne JSON illisible: {e}")))?; - // Id de conversation Codex : on tolère `conversation_id` ou `id` (présumé). - let conversation_id = value - .get("conversation_id") - .or_else(|| value.get("id")) - .and_then(Value::as_str) - .map(str::to_owned); + let mut conversation_id = None; + let mut events = Vec::new(); - // Champ texte présumé : `text` en priorité, repli sur `output`. - let text = || { - value - .get("text") - .or_else(|| value.get("output")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned() - }; - - let event = match value.get("type").and_then(Value::as_str) { - Some("session") => None, // handshake : on ne capte que l'id de conversation. - Some("message" | "delta") => Some(ReplyEvent::TextDelta { text: text() }), - Some("tool" | "tool_call") => { - let label = value - .get("name") + match value.get("type").and_then(Value::as_str) { + Some("thread.started") => { + // Handshake : on ne capte que le thread_id (= id de conversation). + conversation_id = value + .get("thread_id") .and_then(Value::as_str) - .unwrap_or("outil") - .to_owned(); - Some(ReplyEvent::ToolActivity { label }) + .map(str::to_owned); } - Some("result" | "final") => Some(ReplyEvent::Final { content: text() }), - _ => None, // type inconnu : ignoré (robustesse). - }; + Some("item.completed") => { + if let Some(item) = value.get("item") { + match item.get("type").and_then(Value::as_str) { + Some("agent_message") => { + let content = item + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + events.push(ReplyEvent::Final { content }); + } + // reasoning / command / tout autre item ⇒ activité (label = type). + Some(kind) => events.push(ReplyEvent::ToolActivity { + label: kind.to_owned(), + }), + None => {} + } + } + } + // turn.started / turn.completed / type inconnu : ignoré (robustesse). + _ => {} + } Ok(ParsedLine { - event, + events, conversation_id, }) } @@ -106,9 +107,9 @@ pub fn parse_event(line: &str) -> Result { /// Adapter de session structurée Codex. /// /// Incarnation « un `codex exec` par tour » (§17.2 (b)) : chaque `send` relance -/// `codex exec` avec le prompt et, dès qu'un id de conversation a été capté, le flag -/// de reprise. L'incarnation « process persistant » resterait derrière le **même** -/// port sans toucher au parsing. +/// `codex exec --json` avec le prompt et, dès qu'un `thread_id` a été capté, la +/// sous-commande de reprise. L'incarnation « process persistant » resterait derrière +/// le **même** port sans toucher au parsing. pub struct CodexExecSession { /// Id de session IdeA. id: SessionId, @@ -140,17 +141,22 @@ impl CodexExecSession { /// Compose la ligne de commande d'un tour. /// - /// - Conversation neuve : `codex exec `. - /// - Reprise (id connu) : `codex exec --resume `. + /// Format RÉEL vérifié 2026-06-09 : + /// - Conversation neuve : `codex exec --json --skip-git-repo-check `. + /// - Reprise (id connu) : `codex exec resume --json + /// --skip-git-repo-check `. /// - /// > NOTE S2 : sous-commande et flags (`exec`, `--resume`, format de sortie - /// > structuré éventuel) **à confirmer au spike**. + /// NOTE intégration (D3, hors parsing) : pour qu'un agent puisse **écrire**, le + /// lancement devra ajouter `--sandbox workspace-write` + une politique + /// d'approbation non bloquante. Ce n'est pas le rôle de cette composition. fn build_spawn_line(&self, prompt: &str) -> SpawnLine { let mut args = vec!["exec".to_owned()]; if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() { - args.push("--resume".to_owned()); + args.push("resume".to_owned()); args.push(id.clone()); } + args.push("--json".to_owned()); + args.push("--skip-git-repo-check".to_owned()); args.push(prompt.to_owned()); SpawnLine { command: self.command.clone(), @@ -183,9 +189,7 @@ impl AgentSession for CodexExecSession { if let Some(id) = parsed.conversation_id { captured_id = Some(id); } - if let Some(event) = parsed.event { - events.push(event); - } + events.extend(parsed.events); } if let Some(id) = captured_id { *self.conversation_id.lock().expect("mutex sain") = Some(id); diff --git a/crates/infrastructure/src/session/conformance.rs b/crates/infrastructure/src/session/conformance.rs index c2f916a..a3b2ad7 100644 --- a/crates/infrastructure/src/session/conformance.rs +++ b/crates/infrastructure/src/session/conformance.rs @@ -7,11 +7,9 @@ //! Disponible hors `cfg(test)` (mais sous une porte `pub`) pour que QA puisse //! réutiliser le harnais et le fake CLI dans des tests d'intégration ultérieurs. //! -//! > Ce qui dépend du **format réel non vérifié** (spikes S1/S2) : les *scripts* -//! > de lignes JSON fournis aux tests reproduisent le **schéma SUPPOSÉ** documenté -//! > dans `claude::parse_event` / `codex::parse_event`. Quand S1/S2 confirmeront le -//! > vrai format, ces scripts (et le parser) seront ajustés ; la machinerie et le -//! > harnais, eux, restent valides. +//! > Les *scripts* de lignes JSON fournis aux tests reproduisent le **format RÉEL +//! > vérifié 2026-06-09** (spikes S1/S2 résolus), documenté dans `claude::parse_event` +//! > / `codex::parse_event`. La machinerie et le harnais sont indépendants du format. use std::io::Write; use std::path::PathBuf; diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 76705f7..d62b4ae 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -103,53 +103,90 @@ mod tests { assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}"); } - // -- parse_event Claude (schéma SUPPOSÉ S1) --------------------------- + // -- parse_event Claude (format RÉEL vérifié 2026-06-09) -------------- #[test] fn claude_parse_init_captures_session_id_without_event() { - let parsed = - claude::parse_event(r#"{"type":"system","subtype":"init","session_id":"conv-123"}"#) - .expect("parse ok"); + let parsed = claude::parse_event( + r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#, + ) + .expect("parse ok"); + assert_eq!(parsed.session_id.as_deref(), Some("conv-123")); + assert!(parsed.events.is_empty()); + } + + #[test] + fn claude_parse_rate_limit_event_is_ignored() { + let parsed = claude::parse_event( + r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#, + ) + .expect("parse ok"); + // Type inconnu/non pertinent : ignoré (mais session_id tout de même capté). + assert!(parsed.events.is_empty()); assert_eq!(parsed.session_id.as_deref(), Some("conv-123")); - assert_eq!(parsed.event, None); } #[test] fn claude_parse_assistant_text_and_tool() { let text = claude::parse_event( - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"bonjour"}]}}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"bonjour"}]},"session_id":"c","parent_tool_use_id":null}"#, ) .expect("parse ok"); assert_eq!( - text.event, - Some(ReplyEvent::TextDelta { + text.events, + vec![ReplyEvent::TextDelta { text: "bonjour".to_owned() - }) + }] ); let tool = claude::parse_event( - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]}}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]},"session_id":"c","parent_tool_use_id":null}"#, ) .expect("parse ok"); assert_eq!( - tool.event, - Some(ReplyEvent::ToolActivity { + tool.events, + vec![ReplyEvent::ToolActivity { label: "Read".to_owned() - }) + }] + ); + } + + /// Bug multi-blocs CORRIGÉ : une ligne `assistant` portant `[text, tool_use, text]` + /// produit **3** ReplyEvent dans l'ordre (et non plus le seul premier bloc). + #[test] + fn claude_parse_assistant_multiblock_yields_all_events_in_order() { + let parsed = claude::parse_event( + r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"un"}, + {"type":"tool_use","name":"Read"}, + {"type":"text","text":"deux"}]},"session_id":"c","parent_tool_use_id":null}"#, + ) + .expect("parse ok"); + assert_eq!( + parsed.events, + vec![ + ReplyEvent::TextDelta { text: "un".to_owned() }, + ReplyEvent::ToolActivity { + label: "Read".to_owned() + }, + ReplyEvent::TextDelta { + text: "deux".to_owned() + }, + ] ); } #[test] fn claude_parse_result_is_final() { let parsed = claude::parse_event( - r#"{"type":"result","subtype":"success","result":"réponse finale","session_id":"conv-123"}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse finale","session_id":"conv-123","num_turns":1}"#, ) .expect("parse ok"); assert_eq!( - parsed.event, - Some(ReplyEvent::Final { + parsed.events, + vec![ReplyEvent::Final { content: "réponse finale".to_owned() - }) + }] ); assert_eq!(parsed.session_id.as_deref(), Some("conv-123")); } @@ -172,33 +209,36 @@ mod tests { fn claude_parse_empty_and_unknown_lines_are_ignored() { assert_eq!(claude::parse_event("").expect("ok"), Default::default()); let unknown = claude::parse_event(r#"{"type":"telemetry","x":1}"#).expect("ok ignoré"); - assert_eq!(unknown.event, None); + assert!(unknown.events.is_empty()); } - // -- parse_event Codex (schéma SUPPOSÉ S2) ---------------------------- + // -- parse_event Codex (format RÉEL vérifié 2026-06-09) --------------- #[test] - fn codex_parse_session_message_and_final() { - let sess = - codex::parse_event(r#"{"type":"session","conversation_id":"cx-9"}"#).expect("ok"); + fn codex_parse_thread_started_message_and_final() { + let sess = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#) + .expect("ok"); assert_eq!(sess.conversation_id.as_deref(), Some("cx-9")); - assert_eq!(sess.event, None); + assert!(sess.events.is_empty()); - let msg = codex::parse_event(r#"{"type":"message","text":"salut"}"#).expect("ok"); - assert_eq!( - msg.event, - Some(ReplyEvent::TextDelta { - text: "salut".to_owned() - }) - ); + let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok"); + assert!(started.events.is_empty()); - let fin = codex::parse_event(r#"{"type":"result","text":"fini"}"#).expect("ok"); + let msg = codex::parse_event( + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#, + ) + .expect("ok"); assert_eq!( - fin.event, - Some(ReplyEvent::Final { + msg.events, + vec![ReplyEvent::Final { content: "fini".to_owned() - }) + }] ); + + let completed = + codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#) + .expect("ok"); + assert!(completed.events.is_empty()); } #[test] @@ -209,24 +249,27 @@ mod tests { // -- Conformité de port (Liskov) — Claude ET Codex -------------------- - /// Script Claude (schéma SUPPOSÉ S1) : init → texte → tool_use → result. + /// Script Claude (format RÉEL) : init → texte → tool_use → texte → result. fn claude_script() -> Vec<&'static str> { vec![ - r#"{"type":"system","subtype":"init","session_id":"claude-conv-1"}"#, - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"un "}]}}"#, - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]}}"#, - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"deux"}]}}"#, - r#"{"type":"result","subtype":"success","result":"réponse Claude","session_id":"claude-conv-1"}"#, + r#"{"type":"system","subtype":"init","session_id":"claude-conv-1","cwd":"/tmp","tools":[]}"#, + r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"claude-conv-1"}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"un "}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"deux"}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse Claude","session_id":"claude-conv-1","num_turns":1}"#, ] } - /// Script Codex (schéma SUPPOSÉ S2) : session → message → tool → result. + /// Script Codex (format RÉEL) : thread.started → turn.started → reasoning item → + /// agent_message (= Final) → turn.completed. fn codex_script() -> Vec<&'static str> { vec![ - r#"{"type":"session","conversation_id":"codex-conv-1"}"#, - r#"{"type":"delta","text":"trav"}"#, - r#"{"type":"tool_call","name":"bash"}"#, - r#"{"type":"result","text":"réponse Codex"}"#, + r#"{"type":"thread.started","thread_id":"codex-conv-1"}"#, + r#"{"type":"turn.started"}"#, + r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"…"}}"#, + r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"réponse Codex"}}"#, + r#"{"type":"turn.completed","usage":{"input_tokens":10848}}"#, ] } @@ -495,11 +538,10 @@ mod tests { // ---- Claude parse_event : plusieurs blocs, robustesse --------------- - /// Plusieurs `tool_use` / blocs texte dans des messages successifs : chaque - /// message produit UN événement (le premier bloc pertinent). On documente la - /// limite connue : un message multi-blocs ne rend que son **premier** bloc. + /// Messages successifs : chaque message produit ses événements ; ici un par + /// message (texte puis tool_use). #[test] - fn claude_multiple_messages_each_yield_one_event() { + fn claude_multiple_messages_each_yield_their_events() { let t1 = claude::parse_event( r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#, ) @@ -508,20 +550,19 @@ mod tests { r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}"#, ) .unwrap(); - assert_eq!(t1.event, Some(ReplyEvent::TextDelta { text: "a".into() })); + assert_eq!(t1.events, vec![ReplyEvent::TextDelta { text: "a".into() }]); assert_eq!( - t2.event, - Some(ReplyEvent::ToolActivity { + t2.events, + vec![ReplyEvent::ToolActivity { label: "Bash".into() - }) + }] ); } - /// LIMITE CONNUE (à arbitrer dev/archi) : un **seul** message portant plusieurs - /// blocs `text`/`tool_use` ne rend que le PREMIER bloc pertinent — les blocs - /// suivants sont perdus. Ce test PINNE le comportement actuel (pas un échec). + /// Bug multi-blocs CORRIGÉ : un **seul** message portant plusieurs blocs + /// `text`/`tool_use` rend TOUS ses blocs dans l'ordre (plus de perte). #[test] - fn claude_multiblock_message_keeps_only_first_block_known_limit() { + fn claude_multiblock_message_yields_every_block() { let parsed = claude::parse_event( r#"{"type":"assistant","message":{"content":[ {"type":"text","text":"un"}, @@ -529,10 +570,17 @@ mod tests { {"type":"text","text":"deux"}]}}"#, ) .unwrap(); - // Comportement ACTUEL : seul le premier bloc (`text:"un"`) est émis. assert_eq!( - parsed.event, - Some(ReplyEvent::TextDelta { text: "un".into() }) + parsed.events, + vec![ + ReplyEvent::TextDelta { text: "un".into() }, + ReplyEvent::ToolActivity { + label: "Read".into() + }, + ReplyEvent::TextDelta { + text: "deux".into() + }, + ] ); } @@ -544,10 +592,10 @@ mod tests { ) .unwrap(); assert_eq!( - parsed.event, - Some(ReplyEvent::ToolActivity { + parsed.events, + vec![ReplyEvent::ToolActivity { label: "outil".into() - }) + }] ); } @@ -556,7 +604,7 @@ mod tests { #[test] fn claude_result_without_content_yields_no_event() { let parsed = claude::parse_event(r#"{"type":"result","subtype":"success"}"#).unwrap(); - assert_eq!(parsed.event, None); + assert!(parsed.events.is_empty()); } /// Ligne whitespace-only (espaces/tabs) ⇒ ignorée comme une ligne vide. @@ -569,70 +617,86 @@ mod tests { /// de panic, jamais de Decode. #[test] fn claude_valid_non_object_json_is_ignored() { - assert_eq!(claude::parse_event("[1,2,3]").unwrap().event, None); - assert_eq!(claude::parse_event("42").unwrap().event, None); + assert!(claude::parse_event("[1,2,3]").unwrap().events.is_empty()); + assert!(claude::parse_event("42").unwrap().events.is_empty()); } - // ---- Codex parse_event (schéma SUPPOSÉ S2 — NON CONFIRMÉ) ----------- - // NB: toutes ces assertions dépendent du format présumé S2. Le contrat de - // sortie (ReplyEvent) est stable ; le mapping changera au spike S2. + // ---- Codex parse_event (format RÉEL vérifié 2026-06-09) ------------- - /// [S2 présumé] `tool`/`tool_call` ⇒ ToolActivity ; `name` manquant ⇒ « outil ». + /// Un item non-`agent_message` (reasoning/command/…) ⇒ ToolActivity (label = type). #[test] - fn codex_tool_activity_and_fallback_s2() { - let t = codex::parse_event(r#"{"type":"tool","name":"grep"}"#).unwrap(); + fn codex_non_agent_message_item_is_tool_activity() { + let r = codex::parse_event( + r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"…"}}"#, + ) + .unwrap(); assert_eq!( - t.event, - Some(ReplyEvent::ToolActivity { - label: "grep".into() - }) + r.events, + vec![ReplyEvent::ToolActivity { + label: "reasoning".into() + }] ); - let t2 = codex::parse_event(r#"{"type":"tool_call"}"#).unwrap(); + let c = codex::parse_event( + r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#, + ) + .unwrap(); assert_eq!( - t2.event, - Some(ReplyEvent::ToolActivity { - label: "outil".into() - }) + c.events, + vec![ReplyEvent::ToolActivity { + label: "command".into() + }] ); } - /// [S2 présumé] `delta` et `message` produisent tous deux un TextDelta. + /// `agent_message` ⇒ Final (porte le texte de réponse). #[test] - fn codex_delta_and_message_both_text_s2() { - let d = codex::parse_event(r#"{"type":"delta","text":"x"}"#).unwrap(); - let m = codex::parse_event(r#"{"type":"message","text":"y"}"#).unwrap(); - assert_eq!(d.event, Some(ReplyEvent::TextDelta { text: "x".into() })); - assert_eq!(m.event, Some(ReplyEvent::TextDelta { text: "y".into() })); - } - - /// [S2 présumé] `final` (alias de `result`) avec repli sur le champ `output`. - #[test] - fn codex_final_alias_and_output_fallback_s2() { - let f = codex::parse_event(r#"{"type":"final","output":"sortie"}"#).unwrap(); + fn codex_agent_message_is_final() { + let m = codex::parse_event( + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"bonjour"}}"#, + ) + .unwrap(); assert_eq!( - f.event, - Some(ReplyEvent::Final { - content: "sortie".into() - }) + m.events, + vec![ReplyEvent::Final { + content: "bonjour".into() + }] ); } - /// [S2 présumé] id de conversation : repli `id` quand `conversation_id` absent. + /// `agent_message` sans `text` ⇒ Final avec contenu vide (pas de panic). #[test] - fn codex_conversation_id_falls_back_to_id_s2() { - let p = codex::parse_event(r#"{"type":"session","id":"cx-7"}"#).unwrap(); + fn codex_agent_message_without_text_is_empty_final() { + let m = codex::parse_event( + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message"}}"#, + ) + .unwrap(); + assert_eq!(m.events, vec![ReplyEvent::Final { content: String::new() }]); + } + + /// `thread.started` capte le `thread_id` ; pas d'événement. + #[test] + fn codex_thread_started_captures_thread_id() { + let p = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-7"}"#).unwrap(); assert_eq!(p.conversation_id.as_deref(), Some("cx-7")); - assert_eq!(p.event, None); + assert!(p.events.is_empty()); } - /// [S2 présumé] ligne vide / type inconnu ⇒ ignorée sans erreur. + /// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur. #[test] - fn codex_empty_and_unknown_ignored_s2() { + fn codex_empty_and_unknown_ignored() { assert_eq!(codex::parse_event("").unwrap(), Default::default()); - assert_eq!( - codex::parse_event(r#"{"type":"heartbeat"}"#).unwrap().event, - None - ); + assert!(codex::parse_event(r#"{"type":"heartbeat"}"#) + .unwrap() + .events + .is_empty()); + assert!(codex::parse_event(r#"{"type":"turn.started"}"#) + .unwrap() + .events + .is_empty()); + assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#) + .unwrap() + .events + .is_empty()); } // ---- Machinerie process via FakeCli --------------------------------- @@ -642,9 +706,10 @@ mod tests { #[tokio::test] async fn codex_stream_closed_after_final() { let fake = FakeCli::printing(&[ - r#"{"type":"session","conversation_id":"c"}"#, - r#"{"type":"delta","text":"a"}"#, - r#"{"type":"result","text":"fin"}"#, + r#"{"type":"thread.started","thread_id":"c"}"#, + r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"a"}}"#, + r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#, + r#"{"type":"turn.completed","usage":{}}"#, ]); let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None); let events: Vec<_> = s.send("x").await.expect("send").collect(); @@ -769,7 +834,7 @@ mod tests { #[tokio::test] async fn claude_resume_command_carries_resume_flag() { let (cmd, argv) = make_recording_fake(&[ - r#"{"type":"result","subtype":"success","result":"ok","session_id":"resume-id"}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"resume-id","num_turns":1}"#, ]); let session = ClaudeSdkSession::new( SessionId::new_random(), @@ -795,15 +860,26 @@ mod tests { args.contains(&"salut"), "argv doit porter le prompt, vu: {args:?}" ); + // Format RÉEL : --output-format stream-json --verbose sont requis. + assert!( + args.contains(&"--output-format") && args.contains(&"stream-json"), + "argv doit porter --output-format stream-json, vu: {args:?}" + ); + assert!( + args.contains(&"--verbose"), + "argv doit porter --verbose, vu: {args:?}" + ); let _ = std::fs::remove_file(&cmd); let _ = std::fs::remove_file(&argv); } - /// Idem Codex : `codex exec --resume ` (schéma S2 présumé). + /// Idem Codex : `codex exec resume --json --skip-git-repo-check ` + /// (format RÉEL : sous-commande `resume`, pas un flag `--resume`). #[tokio::test] - async fn codex_resume_command_carries_resume_flag_s2() { - let (cmd, argv) = - make_recording_fake(&[r#"{"type":"result","text":"ok","conversation_id":"cx-id"}"#]); + async fn codex_resume_command_carries_resume_subcommand() { + let (cmd, argv) = make_recording_fake(&[ + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, + ]); let session = CodexExecSession::new( SessionId::new_random(), cmd.clone(), @@ -814,8 +890,10 @@ mod tests { let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); assert!(args.contains(&"exec"), "vu: {args:?}"); - assert!(args.contains(&"--resume"), "vu: {args:?}"); + assert!(args.contains(&"resume"), "vu: {args:?}"); assert!(args.contains(&"cx-id"), "vu: {args:?}"); + assert!(args.contains(&"--json"), "vu: {args:?}"); + assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}"); assert!(args.contains(&"vas-y"), "vu: {args:?}"); let _ = std::fs::remove_file(&cmd); let _ = std::fs::remove_file(&argv); @@ -856,7 +934,9 @@ mod tests { #[tokio::test] async fn factory_resume_seeds_codex() { let factory = StructuredSessionFactory::new(); - let fake = FakeCli::printing(&[r#"{"type":"result","text":"ok"}"#]); + let fake = FakeCli::printing(&[ + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, + ]); let codex = structured_profile(StructuredAdapter::Codex, &fake.command()); let session = factory .start( @@ -927,8 +1007,8 @@ mod tests { #[tokio::test] async fn codex_contract_holds_with_zero_deltas() { let fake = FakeCli::printing(&[ - r#"{"type":"session","conversation_id":"cx-0"}"#, - r#"{"type":"result","text":"direct"}"#, + r#"{"type":"thread.started","thread_id":"cx-0"}"#, + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"direct"}}"#, ]); let session: Arc = Arc::new(CodexExecSession::new( SessionId::new_random(), @@ -938,4 +1018,113 @@ mod tests { )); assert_agent_session_contract(session, "cx-0", "direct").await; } + + // ===================================================================== + // DURCISSEMENT QA (lot D2-bis) — bouche les axes RÉSIDUELS du périmètre : + // (a) flot COMPLET init→assistant(MULTI-blocs sur une SEULE ligne)→result + // drainé via `send()` (et pas seulement `parse_event`) : on prouve que + // l'aplatissement multi-blocs tient bout-en-bout et qu'il y a UN Final ; + // (b) commande NEUVE : Claude ne porte PAS `--resume` au 1er tour (isolé, + // pas un `contains` global) et porte la base réelle ; + // (c) commande NEUVE : Codex porte `exec --json --skip-git-repo-check` SANS + // sous-commande `resume`, et `resume` n'apparaît PAS avant capture. + // Tout passe par le FakeCli/sidecar argv — jamais le vrai claude/codex. + // ===================================================================== + + /// Flot COMPLET Claude où l'assistant émet ses blocs sur **une seule ligne** + /// `content:[text,tool_use,text]` : drainé via `send()`, on doit obtenir, dans + /// l'ordre, TextDelta("a"), ToolActivity("T"), TextDelta("b") PUIS exactement + /// **un** Final("..."). Couvre l'aplatissement multi-blocs bout-en-bout (le + /// harnais de conformité, lui, n'utilise que des lignes mono-bloc). + #[tokio::test] + async fn claude_full_flow_multiblock_line_flattens_then_single_final() { + let fake = FakeCli::printing(&[ + r#"{"type":"system","subtype":"init","session_id":"flow-1","cwd":"/tmp","tools":[]}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#, + ]); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let events: Vec = session.send("x").await.expect("send ok").collect(); + assert_eq!( + events, + vec![ + ReplyEvent::TextDelta { text: "a".into() }, + ReplyEvent::ToolActivity { label: "T".into() }, + ReplyEvent::TextDelta { text: "b".into() }, + ReplyEvent::Final { + content: "final-ok".into() + }, + ], + "le flot complet doit aplatir les 3 blocs PUIS un seul Final" + ); + // Un seul Final, en dernière position (redondant mais explicite). + assert_eq!( + events + .iter() + .filter(|e| matches!(e, ReplyEvent::Final { .. })) + .count(), + 1 + ); + assert_eq!(session.conversation_id().as_deref(), Some("flow-1")); + } + + /// Commande NEUVE Claude : au 1er tour (aucun seed), l'argv ne doit PAS porter + /// `--resume` ni d'id, mais DOIT porter `-p --output-format stream-json + /// --verbose`. (Le test existant prouve l'apparition au 2e tour via un `contains` + /// global ; ici on isole le 1er tour pour prouver l'ABSENCE.) + #[tokio::test] + async fn claude_new_conversation_command_has_no_resume() { + let (cmd, argv) = make_recording_fake(&[ + r#"{"type":"system","subtype":"init","session_id":"new-1"}"#, + r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#, + ]); + let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let _ = session.send("bonjour").await.expect("send ok"); + let recorded = std::fs::read_to_string(&argv).expect("argv"); + let args: Vec<&str> = recorded.lines().collect(); + assert!( + !args.contains(&"--resume"), + "1er tour NEUF ne doit PAS porter --resume, vu: {args:?}" + ); + // La base RÉELLE est bien présente. + assert!(args.contains(&"-p"), "vu: {args:?}"); + assert!(args.contains(&"bonjour"), "vu: {args:?}"); + assert!( + args.contains(&"--output-format") && args.contains(&"stream-json"), + "vu: {args:?}" + ); + assert!(args.contains(&"--verbose"), "vu: {args:?}"); + // L'ordre RÉEL : -p précède son prompt, qui précède --output-format. + let p = args.iter().position(|a| *a == "-p").unwrap(); + let of = args.iter().position(|a| *a == "--output-format").unwrap(); + assert!(p < of, "-p doit précéder --output-format, vu: {args:?}"); + let _ = std::fs::remove_file(&cmd); + let _ = std::fs::remove_file(&argv); + } + + /// Commande NEUVE Codex : au 1er tour (aucun seed), l'argv doit porter + /// `exec --json --skip-git-repo-check ` SANS la sous-commande `resume`. + #[tokio::test] + async fn codex_new_conversation_command_has_no_resume_subcommand() { + let (cmd, argv) = make_recording_fake(&[ + r#"{"type":"thread.started","thread_id":"cx-new"}"#, + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, + ]); + let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let _ = session.send("salut").await.expect("send ok"); + let recorded = std::fs::read_to_string(&argv).expect("argv"); + let args: Vec<&str> = recorded.lines().collect(); + assert!(args.contains(&"exec"), "vu: {args:?}"); + assert!( + !args.contains(&"resume"), + "1er tour NEUF ne doit PAS porter la sous-commande resume, vu: {args:?}" + ); + assert!(args.contains(&"--json"), "vu: {args:?}"); + assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}"); + assert!(args.contains(&"salut"), "vu: {args:?}"); + // `exec` est bien la 1re sous-commande (position 0 de l'argv). + assert_eq!(args.first(), Some(&"exec"), "exec doit ouvrir l'argv, vu: {args:?}"); + let _ = std::fs::remove_file(&cmd); + let _ = std::fs::remove_file(&argv); + } }