//! [`CodexExecSession`] — adapter structuré Codex (ARCHITECTURE §17.2, spike **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. **Seule [`parse_event`] (et la composition de la commande) porte le //! format Codex** ; la machinerie reste inchangée. use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; use domain::ports::{ AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, ReplyStream, }; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::process::{run_turn, SpawnLine}; /// 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é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 --json`** vers le contrat /// universel. /// /// # Format RÉEL vérifié 2026-06-09 (spike S2 résolu) /// /// Commande : `codex exec --json --skip-git-repo-check ""` ; /// reprise : `codex exec resume --json --skip-git-repo-check ""`. /// /// Le flux est du **JSONL**. Types réels : /// /// - `{"type":"thread.started","thread_id":""}` ⇒ capte le `thread_id` /// (= id de conversation pour la reprise), **aucun** événement émis. /// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale). /// - `{"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) ; si `item.type=="error"` ⇒ [`ReplyEvent::Error`] avec message /// extrait de `item.text`, `item.message`, `item.error.message`, `item.error`, puis /// des champs top-level `stderr`/`message`/`error.message`/`error` ; sinon /// (`reasoning`/`command`/autre) ⇒ [`ReplyEvent::ToolActivity`] (`label` = `item.type`). /// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final` /// vient de l'`agent_message`, pas de `turn.completed`). /// /// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒ /// [`AgentSessionError::Decode`] (jamais de JSON brut propagé). /// /// # Errors /// [`AgentSessionError::Decode`] si la ligne n'est pas un JSON valide. 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 mut conversation_id = None; let mut events = Vec::new(); 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) .map(str::to_owned); } // Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒ // battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient // toujours de l'`agent_message`, jamais de `turn.completed`. Some("turn.started") => { events.push(ReplyEvent::Progress { progress: provider_progress( ReplyProgressKind::Turn, ReplyProgressStage::Started, "tour démarré", "turn.started", ), }); events.push(ReplyEvent::Heartbeat); } Some("turn.completed") => { events.push(ReplyEvent::Progress { progress: provider_progress( ReplyProgressKind::Turn, ReplyProgressStage::Completed, "tour terminé côté provider", "turn.completed", ), }); events.push(ReplyEvent::Heartbeat); } 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 }); } Some("error") => { events.push(ReplyEvent::Error { message: codex_error_message(&value, item), }); } // reasoning / command / tout autre item ⇒ activité (label = type). Some(kind) => { events.push(ReplyEvent::Progress { progress: provider_progress( ReplyProgressKind::Tool, ReplyProgressStage::Completed, kind, "item.completed", ) .with_tool_name(kind.to_owned()), }); events.push(ReplyEvent::ToolActivity { label: kind.to_owned(), }); } None => {} } } } // type inconnu : ignoré (robustesse). _ => {} } Ok(ParsedLine { events, conversation_id, }) } fn provider_progress( kind: ReplyProgressKind, stage: ReplyProgressStage, label: impl Into, native_event: impl Into, ) -> ReplyProgress { ReplyProgress::new(ReplyProgressSource::ProviderNative, kind, stage, label) .with_provider_event("codex", native_event) } const CODEX_ERROR_FALLBACK: &str = "Codex a renvoyé une erreur."; fn non_blank_string(value: Option<&Value>) -> Option { value .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) } fn error_value_message(value: Option<&Value>) -> Option { let value = value?; non_blank_string(value.get("message")).or_else(|| non_blank_string(Some(value))) } fn codex_error_message(root: &Value, item: &Value) -> String { non_blank_string(item.get("text")) .or_else(|| non_blank_string(item.get("message"))) .or_else(|| error_value_message(item.get("error"))) .or_else(|| non_blank_string(root.get("stderr"))) .or_else(|| non_blank_string(root.get("message"))) .or_else(|| error_value_message(root.get("error"))) .unwrap_or_else(|| CODEX_ERROR_FALLBACK.to_owned()) } /// Adapter de session structurée Codex. /// /// Incarnation « un `codex exec` par tour » (§17.2 (b)) : chaque `send` relance /// `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, /// Binaire à lancer (`codex` en prod, fake CLI en test). command: String, /// Répertoire de travail (run dir isolé §14.1). cwd: String, /// Codex CLI sandbox mode passed to `codex exec --sandbox`. sandbox_mode: String, /// Project/workspace roots that must be writable in Codex's CLI sandbox. writable_roots: Vec, /// Structured policy projection of Codex workspace-write sandbox network access. network_access: Option, /// Profile-selected model forwarded as a Codex config override on every exec turn. model: Option, /// Profile-selected reasoning effort forwarded as a Codex config override. model_reasoning_effort: Option, /// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé). env: Vec<(String, String)>, /// Id de conversation **du moteur** Codex, 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 CodexExecSession { /// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ; /// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve). /// `sandbox` / `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS ; /// `None`/`None` ⇒ chemin natif inchangé. #[must_use] pub fn new( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, writable_roots: Vec, env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, ) -> Self { Self::new_with_policy_and_overrides( id, command, cwd, seed_conversation_id, "workspace-write", writable_roots, None, None, None, env, sandbox, sandbox_enforcer, ) } /// Construit l'adapter avec la politique compatible `codex exec` résolue par /// `LaunchAgent`. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new_with_policy( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, sandbox_mode: impl Into, writable_roots: Vec, network_access: Option, env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, ) -> Self { Self::new_with_policy_and_overrides( id, command, cwd, seed_conversation_id, sandbox_mode, writable_roots, network_access, None, None, env, sandbox, sandbox_enforcer, ) } /// Construit l'adapter avec politique + overrides de config issus du profil IdeA. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new_with_policy_and_overrides( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, sandbox_mode: impl Into, writable_roots: Vec, network_access: Option, model: Option, model_reasoning_effort: Option, env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, ) -> Self { Self { id, command: command.into(), cwd: cwd.into(), sandbox_mode: sandbox_mode.into(), writable_roots, network_access, model, model_reasoning_effort, env, conversation_id: Mutex::new(seed_conversation_id), sandbox, sandbox_enforcer, } } /// Compose la ligne de commande d'un tour. /// /// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) : /// - Conversation neuve : `codex exec --json --skip-git-repo-check /// --sandbox workspace-write --add-dir `. /// - Reprise (id connu) : `codex exec --json /// --skip-git-repo-check --sandbox workspace-write --add-dir resume /// `. /// /// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à /// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt /// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag /// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui /// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent /// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root /// à la sandbox Codex pour que les écritures Git touchent le vrai workspace. fn build_spawn_line(&self, prompt: &str) -> SpawnLine { let mut args = vec!["exec".to_owned()]; let conversation_id = self.conversation_id.lock().expect("mutex sain").clone(); args.push("--json".to_owned()); args.push("--skip-git-repo-check".to_owned()); if !self.sandbox_mode.trim().is_empty() { args.push("--sandbox".to_owned()); args.push(self.sandbox_mode.clone()); } if self.sandbox_mode == "workspace-write" { for root in self.writable_roots.iter().filter(|root| !root.is_empty()) { args.push("--add-dir".to_owned()); args.push(root.clone()); } } if let Some(network_access) = self.network_access { args.push("-c".to_owned()); args.push(format!( "sandbox_workspace_write.network_access={network_access}" )); } if let Some(model) = self .model .as_deref() .filter(|model| !model.trim().is_empty()) { args.push("-c".to_owned()); args.push(format!("model={}", codex_toml_string(model))); } if let Some(effort) = self .model_reasoning_effort .as_deref() .filter(|effort| !effort.trim().is_empty()) { args.push("-c".to_owned()); args.push(format!( "model_reasoning_effort={}", codex_toml_string(effort) )); } if let Some(id) = conversation_id { args.push("resume".to_owned()); args.push(id); } args.push(prompt.to_owned()); let mut env = self.env.clone(); if let Some(network_access) = self.network_access { upsert_env( &mut env, "CODEX_SANDBOX_NETWORK_DISABLED", if network_access { "0" } else { "1" }, ); } SpawnLine { command: self.command.clone(), args, cwd: self.cwd.clone(), env, stdin: None, sandbox: self.sandbox.clone(), } } } fn codex_toml_string(value: &str) -> String { serde_json::to_string(value).expect("string serialization cannot fail") } fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) { if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) { *existing = value.to_owned(); } else { env.push((key.to_owned(), value.to_owned())); } } #[async_trait] impl AgentSession for CodexExecSession { 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> { // « un run par tour » ⇒ pas de process long survivant : idempotent, no-op. Ok(()) } } impl CodexExecSession { async fn send_inner( &self, prompt: &str, tap: Option>, ) -> Result { let spec = self.build_spawn_line(prompt); let has_tap = tap.is_some(); 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 !matches!(event, ReplyEvent::Final { .. }) { let _ = event_tap.send(event); } } } } }); (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; // Fix canal (B2, bootstrap) : un tour `codex exec` peut émettre PLUSIEURS // `agent_message` — un préambule (« je vais… ») puis la conclusion. On ne coupe // donc plus au premier : on retient le **dernier** `agent_message` comme unique // `Final` terminal (la conclusion du tour), et on dégrade les précédents en // activité non terminale (readiness préservée). Ainsi le demandeur reçoit la // conclusion, pas le préambule. Absence d'`agent_message` ⇒ aucun `Final` // (no-reply inchangé). let mut last_final: Option = None; for line in &raw_lines { let parsed = parse_event(line)?; if let Some(id) = parsed.conversation_id { captured_id = Some(id); } for event in parsed.events { match event { ReplyEvent::Final { content } => { // Un `agent_message` précédemment retenu est supersédé : il devient // une annonce non terminale, on garde le plus récent en conclusion. if last_final.is_some() { if !has_tap { events.push(ReplyEvent::Announcement { text: last_final.take().expect("présent"), }); } } last_final = Some(content); } other => { if !has_tap { events.push(other); } } } } } // Le dernier `agent_message` retenu est le seul `Final` terminal, émis en fin de // flux (après les heartbeats/activités du tour). if let Some(content) = last_final { events.push(ReplyEvent::Final { content }); } if let Some(id) = captured_id { *self.conversation_id.lock().expect("mutex sain") = Some(id); } Ok(Box::new(events.into_iter())) } }