fix(agent): adapters Claude/Codex au format CLI réel (spikes S1/S2 résolus)

Formats vérifiés sur les vraies CLI le 2026-06-09 :
- Claude (claude -p … --output-format stream-json --verbose ; reprise --resume
  <session_id>) : parse system/init (session_id), ignore rate_limit_event +
  types inconnus, assistant.content[] ITÉRÉ (fix multi-blocs), result→Final.
  Flag --verbose ajouté à build_spawn_line.
- Codex (codex exec --json --skip-git-repo-check ; reprise codex exec resume
  <thread_id>) : thread.started→conversation_id, item.completed/agent_message
  →Final, autres items→ToolActivity, turn.*/inconnu ignorés.

parse_event émet désormais Vec<ReplyEvent> (multi-blocs) ; drain aplatit.
Scripts fake CLI passés aux lignes réelles. Tests : session 46 + intégration,
workspace vert.

À vérifier à l'intégration (D3) : reprise Codex (ordre d'args), et lancement
Codex avec --sandbox workspace-write + approbation non bloquante (hors parsing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:34:03 +02:00
parent 751d94dd89
commit f104682477
4 changed files with 437 additions and 236 deletions

View File

@ -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<ReplyEvent>,
/// Événements universels à émettre (dans l'ordre), vide pour une ligne de contrôle.
pub events: Vec<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 (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 "<prompt>" --output-format stream-json --verbose` ;
/// reprise : `claude --resume <session_id> -p … --output-format stream-json --verbose`.
///
/// - `{"type":"system","subtype":"init","session_id":"<uuid>", …}`
/// Le flux est du **JSONL** (un objet JSON par ligne). Types réels :
///
/// - `{"type":"system","subtype":"init","session_id":"<uuid>","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":"<texte final>","session_id":"<uuid>", …}`
/// ], …},"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":"<texte final>","session_id":"<uuid>","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<ParsedLine, AgentSessionError> {
.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<ReplyEvent> {
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<ReplyEvent> {
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<ReplyEvent> {
.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 <prompt> --output-format stream-json`.
/// Format RÉEL vérifié 2026-06-09 :
/// - Conversation neuve : `claude -p <prompt> --output-format stream-json --verbose`.
/// - Reprise (id connu) : `claude --resume <id> -p <prompt> --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 {