feat(agent): adapters structurés Claude/Codex + fake CLI + conformité (D2) — §17
infrastructure/src/session/ : machinerie de process générique (paramétrable par la commande = seam d'injection du fake CLI), adapters ClaudeSdkSession/ CodexExecSession avec parsing ISOLÉ par adapter (parse_event), factory StructuredSessionFactory (routage par structured_adapter), FakeCli scriptable + harnais de conformité Liskov assert_agent_session_contract. Incarnation « un run par tour » (send relance claude -p / --resume <id>, continuité via conversation_id — colle au pivot reprise B). Tests : 41 contre le FAKE CLI (jamais le vrai claude/codex), workspace vert. Points en attente des spikes S1/S2 (format réel) — n'impactent que parse_event : - mapping JSON→ReplyEvent Claude (S1) et Codex (S2) sur schémas SUPPOSÉS ; - Claude multi-blocs : parse_event ne garde que le 1er bloc (à corriger si Claude émet plusieurs blocs/message — confirmer S1) ; - flux sans Final : permissif côté adapter, l'erreur est gérée par send_blocking (consommateur). À reconfirmer côté UI streaming (D4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
201
crates/infrastructure/src/session/codex.rs
Normal file
201
crates/infrastructure/src/session/codex.rs
Normal file
@ -0,0 +1,201 @@
|
||||
//! [`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 ».
|
||||
//!
|
||||
//! # 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**.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
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`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct ParsedLine {
|
||||
/// Événement universel à émettre, ou `None`.
|
||||
pub event: Option<ReplyEvent>,
|
||||
/// Id de conversation Codex capté (pour la reprise via le flag Codex).
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// **Parse une ligne de la sortie structurée de `codex exec`** vers le contrat
|
||||
/// universel.
|
||||
///
|
||||
/// # Schéma SUPPOSÉ — à confirmer au spike S2 (format Codex TRÈS incertain)
|
||||
///
|
||||
/// 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é :
|
||||
///
|
||||
/// - `{"type":"session","conversation_id":"<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":"<texte final>", …}` (ou champ `"output"`)
|
||||
/// ⇒ [`ReplyEvent::Final`] : **l'événement terminal du tour**.
|
||||
///
|
||||
/// > 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.
|
||||
///
|
||||
/// 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<ParsedLine, AgentSessionError> {
|
||||
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}")))?;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("outil")
|
||||
.to_owned();
|
||||
Some(ReplyEvent::ToolActivity { label })
|
||||
}
|
||||
Some("result" | "final") => Some(ReplyEvent::Final { content: text() }),
|
||||
_ => None, // type inconnu : ignoré (robustesse).
|
||||
};
|
||||
|
||||
Ok(ParsedLine {
|
||||
event,
|
||||
conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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,
|
||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||
conversation_id: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl CodexExecSession {
|
||||
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
|
||||
/// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
id: SessionId,
|
||||
command: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
seed_conversation_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
command: command.into(),
|
||||
cwd: cwd.into(),
|
||||
conversation_id: Mutex::new(seed_conversation_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose la ligne de commande d'un tour.
|
||||
///
|
||||
/// - Conversation neuve : `codex exec <prompt>`.
|
||||
/// - Reprise (id connu) : `codex exec --resume <id> <prompt>`.
|
||||
///
|
||||
/// > NOTE S2 : sous-commande et flags (`exec`, `--resume`, format de sortie
|
||||
/// > structuré éventuel) **à confirmer au spike**.
|
||||
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(id.clone());
|
||||
}
|
||||
args.push(prompt.to_owned());
|
||||
SpawnLine {
|
||||
command: self.command.clone(),
|
||||
args,
|
||||
cwd: self.cwd.clone(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for CodexExecSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
self.conversation_id.lock().expect("mutex sain").clone()
|
||||
}
|
||||
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
let spec = self.build_spawn_line(prompt);
|
||||
let raw_lines = run_turn(&spec, None).await?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut captured_id = None;
|
||||
for line in &raw_lines {
|
||||
let parsed = parse_event(line)?;
|
||||
if let Some(id) = parsed.conversation_id {
|
||||
captured_id = Some(id);
|
||||
}
|
||||
if let Some(event) = parsed.event {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
if let Some(id) = captured_id {
|
||||
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
||||
}
|
||||
|
||||
Ok(Box::new(events.into_iter()))
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
// « un run par tour » ⇒ pas de process long survivant : idempotent, no-op.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user