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:
2026-06-09 18:11:38 +02:00
parent 5e10b5eb42
commit 751d94dd89
7 changed files with 1839 additions and 0 deletions

View File

@ -0,0 +1,230 @@
//! [`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. 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.
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 : un événement à émettre (le cas échéant) et/ou
/// un `session_id` capté (init/result). Permet à [`parse_event`] de rester **pure**
/// (aucun effet de bord) tout en remontant les deux informations.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ParsedLine {
/// Événement universel à émettre, ou `None` (ligne de contrôle, ex. `init`).
pub event: Option<ReplyEvent>,
/// `session_id` Claude capté sur cette ligne (id de conversation pour la reprise).
pub session_id: Option<String>,
}
/// **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)
///
/// Le flux est du **JSONL** (un objet JSON par ligne). Schéma présumé :
///
/// - `{"type":"system","subtype":"init","session_id":"<uuid>", …}`
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
/// événement émis.
/// - `{"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":"<texte final>","session_id":"<uuid>", …}`
/// ⇒ [`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`.
///
/// # 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<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}")))?;
let session_id = value
.get("session_id")
.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 {
content: content.to_owned(),
})
}
_ => None, // type inconnu / non pertinent : ignoré (robustesse).
};
Ok(ParsedLine { event, 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<ReplyEvent> {
let content = value
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)?;
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 {
text: text.to_owned(),
});
}
}
Some("tool_use") => {
let label = block
.get("name")
.and_then(Value::as_str)
.unwrap_or("outil")
.to_owned();
return Some(ReplyEvent::ToolActivity { label });
}
_ => {}
}
}
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<Option<String>>,
}
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.
#[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 selon l'état de conversation.
///
/// - Conversation neuve : `claude -p <prompt> --output-format stream-json`.
/// - Reprise (id connu) : `claude --resume <id> -p <prompt> --output-format
/// stream-json`.
///
/// > NOTE S1 : flags exacts (`-p`, `--resume`, `--output-format stream-json`,
/// > éventuel `--input-format stream-json`) à confirmer au spike.
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());
SpawnLine {
command: self.command.clone(),
args,
cwd: self.cwd.clone(),
env: Vec::new(),
stdin: None,
}
}
}
#[async_trait]
impl AgentSession for ClaudeSdkSession {
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.session_id {
captured_id = Some(id);
}
if let Some(event) = parsed.event {
events.push(event);
}
}
// 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()))
}
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(())
}
}