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:
@ -9,9 +9,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Le **parsing du format Claude est ISOLÉ** dans la fonction pure [`parse_event`].
|
//! 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
|
//! 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
|
//! ignore tout du JSON. Le **spike S1 est résolu** : le schéma réel est vérifié
|
||||||
//! l'utilisateur, **seule [`parse_event`] (et la composition de la commande) devra
|
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
|
||||||
//! changer**, pas la machinerie ni le reste de l'adapter.
|
//! le format**, pas la machinerie ni le reste de l'adapter.
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@ -23,39 +23,43 @@ use domain::SessionId;
|
|||||||
|
|
||||||
use super::process::{run_turn, SpawnLine};
|
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**
|
/// un `session_id` capté (init/result). Permet à [`parse_event`] de rester **pure**
|
||||||
/// (aucun effet de bord) tout en remontant les deux informations.
|
/// (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)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
pub struct ParsedLine {
|
pub struct ParsedLine {
|
||||||
/// Événement universel à émettre, ou `None` (ligne de contrôle, ex. `init`).
|
/// Événements universels à émettre (dans l'ordre), vide pour une ligne de contrôle.
|
||||||
pub event: Option<ReplyEvent>,
|
pub events: Vec<ReplyEvent>,
|
||||||
/// `session_id` Claude capté sur cette ligne (id de conversation pour la reprise).
|
/// `session_id` Claude capté sur cette ligne (id de conversation pour la reprise).
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Parse une ligne du flux `stream-json` de Claude** vers le contrat universel.
|
/// **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 "<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**
|
/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun**
|
||||||
/// événement émis.
|
/// événement émis.
|
||||||
|
/// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}`
|
||||||
|
/// ⇒ **ignoré** (comme tout `type` inconnu).
|
||||||
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
/// - `{"type":"assistant","message":{"role":"assistant","content":[
|
||||||
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
/// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …}
|
||||||
/// ]}, "session_id":"…"}`
|
/// ], …},"session_id":"…","parent_tool_use_id":null}`
|
||||||
/// ⇒ chaque bloc `text` ⇒ [`ReplyEvent::TextDelta`] ; chaque bloc `tool_use`
|
/// ⇒ **chaque** bloc `text` ⇒ [`ReplyEvent::TextDelta`] ; **chaque** bloc `tool_use`
|
||||||
/// ⇒ [`ReplyEvent::ToolActivity`] (`label` = `name`).
|
/// ⇒ [`ReplyEvent::ToolActivity`] (`label` = `name`). Une ligne `assistant` peut
|
||||||
/// - `{"type":"result","subtype":"success","result":"<texte final>","session_id":"<uuid>", …}`
|
/// 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`.
|
/// ⇒ [`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**
|
/// 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
|
/// (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`.
|
/// é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)
|
.and_then(Value::as_str)
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
let event = match value.get("type").and_then(Value::as_str) {
|
let events = match value.get("type").and_then(Value::as_str) {
|
||||||
Some("system") => None, // init/handshake : on ne capte que le session_id.
|
Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id.
|
||||||
Some("assistant") => first_assistant_event(&value),
|
Some("assistant") => assistant_events(&value),
|
||||||
Some("result") => {
|
Some("result") => value
|
||||||
value
|
.get("result")
|
||||||
.get("result")
|
.and_then(Value::as_str)
|
||||||
.and_then(Value::as_str)
|
.map(|content| {
|
||||||
.map(|content| ReplyEvent::Final {
|
vec![ReplyEvent::Final {
|
||||||
content: content.to_owned(),
|
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` :
|
/// Itère **TOUS** les blocs de contenu d'un message `assistant`, dans l'ordre :
|
||||||
/// un `text` ⇒ `TextDelta`, un `tool_use` ⇒ `ToolActivity`. (Un message porte en
|
/// chaque `text` ⇒ `TextDelta`, chaque `tool_use` ⇒ `ToolActivity`. Le `content`
|
||||||
/// pratique un bloc ; on prend le premier pertinent — robuste si la forme évolue.)
|
/// est un **tableau** : un message multi-blocs produit donc plusieurs événements.
|
||||||
fn first_assistant_event(value: &Value) -> Option<ReplyEvent> {
|
fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
|
||||||
let content = value
|
let Some(content) = value
|
||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|m| m.get("content"))
|
.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 {
|
for block in content {
|
||||||
match block.get("type").and_then(Value::as_str) {
|
match block.get("type").and_then(Value::as_str) {
|
||||||
Some("text") => {
|
Some("text") => {
|
||||||
if let Some(text) = block.get("text").and_then(Value::as_str) {
|
if let Some(text) = block.get("text").and_then(Value::as_str) {
|
||||||
return Some(ReplyEvent::TextDelta {
|
events.push(ReplyEvent::TextDelta {
|
||||||
text: text.to_owned(),
|
text: text.to_owned(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -116,12 +125,12 @@ fn first_assistant_event(value: &Value) -> Option<ReplyEvent> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("outil")
|
.unwrap_or("outil")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
return Some(ReplyEvent::ToolActivity { label });
|
events.push(ReplyEvent::ToolActivity { label });
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adapter de session structurée Claude.
|
/// 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.
|
/// 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
|
/// - 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`,
|
/// Le flag `--verbose` est **requis** : sans lui, `--output-format stream-json`
|
||||||
/// > éventuel `--input-format stream-json`) à confirmer au spike.
|
/// n'émet pas le flux JSONL ligne-à-ligne attendu par le parser.
|
||||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
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(prompt.to_owned());
|
||||||
args.push("--output-format".to_owned());
|
args.push("--output-format".to_owned());
|
||||||
args.push("stream-json".to_owned());
|
args.push("stream-json".to_owned());
|
||||||
|
args.push("--verbose".to_owned());
|
||||||
SpawnLine {
|
SpawnLine {
|
||||||
command: self.command.clone(),
|
command: self.command.clone(),
|
||||||
args,
|
args,
|
||||||
@ -210,9 +221,8 @@ impl AgentSession for ClaudeSdkSession {
|
|||||||
if let Some(id) = parsed.session_id {
|
if let Some(id) = parsed.session_id {
|
||||||
captured_id = Some(id);
|
captured_id = Some(id);
|
||||||
}
|
}
|
||||||
if let Some(event) = parsed.event {
|
// Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements.
|
||||||
events.push(event);
|
events.extend(parsed.events);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
|
// Persiste le session_id capté (pivot de reprise) avant de rendre le flux.
|
||||||
if let Some(id) = captured_id {
|
if let Some(id) = captured_id {
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
//! [`CodexExecSession`] — adapter structuré Codex (ARCHITECTURE §17.2, spike **S2**).
|
//! [`CodexExecSession`] — adapter structuré Codex (ARCHITECTURE §17.2, spike **S2**).
|
||||||
//!
|
//!
|
||||||
//! Pilote `codex exec` en mode non-interactif et traduit sa sortie structurée vers
|
//! Pilote `codex exec --json` en mode non-interactif et traduit sa sortie structurée
|
||||||
//! le contrat universel [`ReplyEvent`]. **Le format de Codex est BEAUCOUP plus
|
//! vers le contrat universel [`ReplyEvent`]. Le **spike S2 est résolu** : le format
|
||||||
//! incertain que celui de Claude** : tout le format présumé est ISOLÉ dans
|
//! réel est vérifié (2026-06-09) et ISOLÉ dans [`parse_event`].
|
||||||
//! [`parse_event`], clairement marqué « SUPPOSÉ — à confirmer S2 ».
|
|
||||||
//!
|
//!
|
||||||
//! # Séparation parsing / machinerie (CRUCIAL — §17.2)
|
//! # Séparation parsing / machinerie (CRUCIAL — §17.2)
|
||||||
//!
|
//!
|
||||||
//! Comme pour Claude, la machinerie de process vit dans [`super::process`] et ignore
|
//! 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
|
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
|
||||||
//! persistant vs `exec` par tour, schéma JSON de fin de tour), **seule
|
//! format Codex** ; la machinerie reste inchangée.
|
||||||
//! [`parse_event`] (et la composition de la commande) changera**.
|
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@ -22,35 +20,36 @@ use domain::SessionId;
|
|||||||
|
|
||||||
use super::process::{run_turn, SpawnLine};
|
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
|
/// Résultat du parsing d'une ligne Codex : **zéro ou plusieurs** événements et/ou un
|
||||||
/// de conversation Codex capté. Miroir de `claude::ParsedLine`.
|
/// 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)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
pub struct ParsedLine {
|
pub struct ParsedLine {
|
||||||
/// Événement universel à émettre, ou `None`.
|
/// Événements universels à émettre (dans l'ordre), vide pour une ligne de contrôle.
|
||||||
pub event: Option<ReplyEvent>,
|
pub events: Vec<ReplyEvent>,
|
||||||
/// Id de conversation Codex capté (pour la reprise via le flag Codex).
|
/// Id de conversation Codex capté (= `thread_id`, pour la reprise).
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **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.
|
/// 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
|
/// Commande : `codex exec --json --skip-git-repo-check "<prompt>"` ;
|
||||||
/// **un** événement final identifiable porte le texte de réponse. Schéma présumé :
|
/// reprise : `codex exec resume <thread_id> --json --skip-git-repo-check "<prompt>"`.
|
||||||
///
|
///
|
||||||
/// - `{"type":"session","conversation_id":"<id>", …}` (ou `"id"`) ⇒ capte l'id de
|
/// Le flux est du **JSONL**. Types réels :
|
||||||
/// 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`
|
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
||||||
/// > (noms de types, champ portant le texte, présence d'un id de conversation, mode
|
/// (= id de conversation pour la reprise), **aucun** événement émis.
|
||||||
/// > persistant vs one-shot) est à confirmer au spike. Le contrat de sortie
|
/// - `{"type":"turn.started"}` ⇒ ignoré.
|
||||||
/// > ([`ReplyEvent`]) ne bougera pas — seule cette fonction changera.
|
/// - `{"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 ⇒
|
/// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒
|
||||||
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
|
/// [`AgentSessionError::Decode`] (jamais de JSON brut propagé).
|
||||||
@ -65,40 +64,42 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
let value: Value = serde_json::from_str(trimmed)
|
let value: Value = serde_json::from_str(trimmed)
|
||||||
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON illisible: {e}")))?;
|
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON illisible: {e}")))?;
|
||||||
|
|
||||||
// Id de conversation Codex : on tolère `conversation_id` ou `id` (présumé).
|
let mut conversation_id = None;
|
||||||
let conversation_id = value
|
let mut events = Vec::new();
|
||||||
.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`.
|
match value.get("type").and_then(Value::as_str) {
|
||||||
let text = || {
|
Some("thread.started") => {
|
||||||
value
|
// Handshake : on ne capte que le thread_id (= id de conversation).
|
||||||
.get("text")
|
conversation_id = value
|
||||||
.or_else(|| value.get("output"))
|
.get("thread_id")
|
||||||
.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)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("outil")
|
.map(str::to_owned);
|
||||||
.to_owned();
|
|
||||||
Some(ReplyEvent::ToolActivity { label })
|
|
||||||
}
|
}
|
||||||
Some("result" | "final") => Some(ReplyEvent::Final { content: text() }),
|
Some("item.completed") => {
|
||||||
_ => None, // type inconnu : ignoré (robustesse).
|
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 {
|
Ok(ParsedLine {
|
||||||
event,
|
events,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -106,9 +107,9 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
/// Adapter de session structurée Codex.
|
/// Adapter de session structurée Codex.
|
||||||
///
|
///
|
||||||
/// Incarnation « un `codex exec` par tour » (§17.2 (b)) : chaque `send` relance
|
/// 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
|
/// `codex exec --json` avec le prompt et, dès qu'un `thread_id` a été capté, la
|
||||||
/// de reprise. L'incarnation « process persistant » resterait derrière le **même**
|
/// sous-commande de reprise. L'incarnation « process persistant » resterait derrière
|
||||||
/// port sans toucher au parsing.
|
/// le **même** port sans toucher au parsing.
|
||||||
pub struct CodexExecSession {
|
pub struct CodexExecSession {
|
||||||
/// Id de session IdeA.
|
/// Id de session IdeA.
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
@ -140,17 +141,22 @@ impl CodexExecSession {
|
|||||||
|
|
||||||
/// Compose la ligne de commande d'un tour.
|
/// Compose la ligne de commande d'un tour.
|
||||||
///
|
///
|
||||||
/// - Conversation neuve : `codex exec <prompt>`.
|
/// Format RÉEL vérifié 2026-06-09 :
|
||||||
/// - Reprise (id connu) : `codex exec --resume <id> <prompt>`.
|
/// - Conversation neuve : `codex exec --json --skip-git-repo-check <prompt>`.
|
||||||
|
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
|
||||||
|
/// --skip-git-repo-check <prompt>`.
|
||||||
///
|
///
|
||||||
/// > NOTE S2 : sous-commande et flags (`exec`, `--resume`, format de sortie
|
/// NOTE intégration (D3, hors parsing) : pour qu'un agent puisse **écrire**, le
|
||||||
/// > structuré éventuel) **à confirmer au spike**.
|
/// 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 {
|
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||||
let mut args = vec!["exec".to_owned()];
|
let mut args = vec!["exec".to_owned()];
|
||||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
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(id.clone());
|
||||||
}
|
}
|
||||||
|
args.push("--json".to_owned());
|
||||||
|
args.push("--skip-git-repo-check".to_owned());
|
||||||
args.push(prompt.to_owned());
|
args.push(prompt.to_owned());
|
||||||
SpawnLine {
|
SpawnLine {
|
||||||
command: self.command.clone(),
|
command: self.command.clone(),
|
||||||
@ -183,9 +189,7 @@ impl AgentSession for CodexExecSession {
|
|||||||
if let Some(id) = parsed.conversation_id {
|
if let Some(id) = parsed.conversation_id {
|
||||||
captured_id = Some(id);
|
captured_id = Some(id);
|
||||||
}
|
}
|
||||||
if let Some(event) = parsed.event {
|
events.extend(parsed.events);
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(id) = captured_id {
|
if let Some(id) = captured_id {
|
||||||
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
||||||
|
|||||||
@ -7,11 +7,9 @@
|
|||||||
//! Disponible hors `cfg(test)` (mais sous une porte `pub`) pour que QA puisse
|
//! 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.
|
//! 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*
|
//! > Les *scripts* de lignes JSON fournis aux tests reproduisent le **format RÉEL
|
||||||
//! > de lignes JSON fournis aux tests reproduisent le **schéma SUPPOSÉ** documenté
|
//! > vérifié 2026-06-09** (spikes S1/S2 résolus), documenté dans `claude::parse_event`
|
||||||
//! > dans `claude::parse_event` / `codex::parse_event`. Quand S1/S2 confirmeront le
|
//! > / `codex::parse_event`. La machinerie et le harnais sont indépendants du format.
|
||||||
//! > vrai format, ces scripts (et le parser) seront ajustés ; la machinerie et le
|
|
||||||
//! > harnais, eux, restent valides.
|
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@ -103,53 +103,90 @@ mod tests {
|
|||||||
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
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]
|
#[test]
|
||||||
fn claude_parse_init_captures_session_id_without_event() {
|
fn claude_parse_init_captures_session_id_without_event() {
|
||||||
let parsed =
|
let parsed = claude::parse_event(
|
||||||
claude::parse_event(r#"{"type":"system","subtype":"init","session_id":"conv-123"}"#)
|
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
|
||||||
.expect("parse ok");
|
)
|
||||||
|
.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.session_id.as_deref(), Some("conv-123"));
|
||||||
assert_eq!(parsed.event, None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_parse_assistant_text_and_tool() {
|
fn claude_parse_assistant_text_and_tool() {
|
||||||
let text = claude::parse_event(
|
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");
|
.expect("parse ok");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.event,
|
text.events,
|
||||||
Some(ReplyEvent::TextDelta {
|
vec![ReplyEvent::TextDelta {
|
||||||
text: "bonjour".to_owned()
|
text: "bonjour".to_owned()
|
||||||
})
|
}]
|
||||||
);
|
);
|
||||||
|
|
||||||
let tool = claude::parse_event(
|
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");
|
.expect("parse ok");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.event,
|
tool.events,
|
||||||
Some(ReplyEvent::ToolActivity {
|
vec![ReplyEvent::ToolActivity {
|
||||||
label: "Read".to_owned()
|
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]
|
#[test]
|
||||||
fn claude_parse_result_is_final() {
|
fn claude_parse_result_is_final() {
|
||||||
let parsed = claude::parse_event(
|
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");
|
.expect("parse ok");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.event,
|
parsed.events,
|
||||||
Some(ReplyEvent::Final {
|
vec![ReplyEvent::Final {
|
||||||
content: "réponse finale".to_owned()
|
content: "réponse finale".to_owned()
|
||||||
})
|
}]
|
||||||
);
|
);
|
||||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
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() {
|
fn claude_parse_empty_and_unknown_lines_are_ignored() {
|
||||||
assert_eq!(claude::parse_event("").expect("ok"), Default::default());
|
assert_eq!(claude::parse_event("").expect("ok"), Default::default());
|
||||||
let unknown = claude::parse_event(r#"{"type":"telemetry","x":1}"#).expect("ok ignoré");
|
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]
|
#[test]
|
||||||
fn codex_parse_session_message_and_final() {
|
fn codex_parse_thread_started_message_and_final() {
|
||||||
let sess =
|
let sess = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#)
|
||||||
codex::parse_event(r#"{"type":"session","conversation_id":"cx-9"}"#).expect("ok");
|
.expect("ok");
|
||||||
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
|
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");
|
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||||
assert_eq!(
|
assert!(started.events.is_empty());
|
||||||
msg.event,
|
|
||||||
Some(ReplyEvent::TextDelta {
|
|
||||||
text: "salut".to_owned()
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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!(
|
assert_eq!(
|
||||||
fin.event,
|
msg.events,
|
||||||
Some(ReplyEvent::Final {
|
vec![ReplyEvent::Final {
|
||||||
content: "fini".to_owned()
|
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]
|
#[test]
|
||||||
@ -209,24 +249,27 @@ mod tests {
|
|||||||
|
|
||||||
// -- Conformité de port (Liskov) — Claude ET Codex --------------------
|
// -- 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> {
|
fn claude_script() -> Vec<&'static str> {
|
||||||
vec![
|
vec![
|
||||||
r#"{"type":"system","subtype":"init","session_id":"claude-conv-1"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"claude-conv-1","cwd":"/tmp","tools":[]}"#,
|
||||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"un "}]}}"#,
|
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"claude-conv-1"}"#,
|
||||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]}}"#,
|
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":"text","text":"deux"}]}}"#,
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"réponse Claude","session_id":"claude-conv-1"}"#,
|
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> {
|
fn codex_script() -> Vec<&'static str> {
|
||||||
vec![
|
vec![
|
||||||
r#"{"type":"session","conversation_id":"codex-conv-1"}"#,
|
r#"{"type":"thread.started","thread_id":"codex-conv-1"}"#,
|
||||||
r#"{"type":"delta","text":"trav"}"#,
|
r#"{"type":"turn.started"}"#,
|
||||||
r#"{"type":"tool_call","name":"bash"}"#,
|
r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"…"}}"#,
|
||||||
r#"{"type":"result","text":"réponse Codex"}"#,
|
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 ---------------
|
// ---- Claude parse_event : plusieurs blocs, robustesse ---------------
|
||||||
|
|
||||||
/// Plusieurs `tool_use` / blocs texte dans des messages successifs : chaque
|
/// Messages successifs : chaque message produit ses événements ; ici un par
|
||||||
/// message produit UN événement (le premier bloc pertinent). On documente la
|
/// message (texte puis tool_use).
|
||||||
/// limite connue : un message multi-blocs ne rend que son **premier** bloc.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_multiple_messages_each_yield_one_event() {
|
fn claude_multiple_messages_each_yield_their_events() {
|
||||||
let t1 = claude::parse_event(
|
let t1 = claude::parse_event(
|
||||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
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"}]}}"#,
|
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(t1.event, Some(ReplyEvent::TextDelta { text: "a".into() }));
|
assert_eq!(t1.events, vec![ReplyEvent::TextDelta { text: "a".into() }]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t2.event,
|
t2.events,
|
||||||
Some(ReplyEvent::ToolActivity {
|
vec![ReplyEvent::ToolActivity {
|
||||||
label: "Bash".into()
|
label: "Bash".into()
|
||||||
})
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LIMITE CONNUE (à arbitrer dev/archi) : un **seul** message portant plusieurs
|
/// Bug multi-blocs CORRIGÉ : un **seul** message portant plusieurs blocs
|
||||||
/// blocs `text`/`tool_use` ne rend que le PREMIER bloc pertinent — les blocs
|
/// `text`/`tool_use` rend TOUS ses blocs dans l'ordre (plus de perte).
|
||||||
/// suivants sont perdus. Ce test PINNE le comportement actuel (pas un échec).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_multiblock_message_keeps_only_first_block_known_limit() {
|
fn claude_multiblock_message_yields_every_block() {
|
||||||
let parsed = claude::parse_event(
|
let parsed = claude::parse_event(
|
||||||
r#"{"type":"assistant","message":{"content":[
|
r#"{"type":"assistant","message":{"content":[
|
||||||
{"type":"text","text":"un"},
|
{"type":"text","text":"un"},
|
||||||
@ -529,10 +570,17 @@ mod tests {
|
|||||||
{"type":"text","text":"deux"}]}}"#,
|
{"type":"text","text":"deux"}]}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Comportement ACTUEL : seul le premier bloc (`text:"un"`) est émis.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.event,
|
parsed.events,
|
||||||
Some(ReplyEvent::TextDelta { text: "un".into() })
|
vec![
|
||||||
|
ReplyEvent::TextDelta { text: "un".into() },
|
||||||
|
ReplyEvent::ToolActivity {
|
||||||
|
label: "Read".into()
|
||||||
|
},
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "deux".into()
|
||||||
|
},
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -544,10 +592,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.event,
|
parsed.events,
|
||||||
Some(ReplyEvent::ToolActivity {
|
vec![ReplyEvent::ToolActivity {
|
||||||
label: "outil".into()
|
label: "outil".into()
|
||||||
})
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -556,7 +604,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn claude_result_without_content_yields_no_event() {
|
fn claude_result_without_content_yields_no_event() {
|
||||||
let parsed = claude::parse_event(r#"{"type":"result","subtype":"success"}"#).unwrap();
|
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.
|
/// Ligne whitespace-only (espaces/tabs) ⇒ ignorée comme une ligne vide.
|
||||||
@ -569,70 +617,86 @@ mod tests {
|
|||||||
/// de panic, jamais de Decode.
|
/// de panic, jamais de Decode.
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_valid_non_object_json_is_ignored() {
|
fn claude_valid_non_object_json_is_ignored() {
|
||||||
assert_eq!(claude::parse_event("[1,2,3]").unwrap().event, None);
|
assert!(claude::parse_event("[1,2,3]").unwrap().events.is_empty());
|
||||||
assert_eq!(claude::parse_event("42").unwrap().event, None);
|
assert!(claude::parse_event("42").unwrap().events.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Codex parse_event (schéma SUPPOSÉ S2 — NON CONFIRMÉ) -----------
|
// ---- Codex parse_event (format RÉEL vérifié 2026-06-09) -------------
|
||||||
// NB: toutes ces assertions dépendent du format présumé S2. Le contrat de
|
|
||||||
// sortie (ReplyEvent) est stable ; le mapping changera au spike S2.
|
|
||||||
|
|
||||||
/// [S2 présumé] `tool`/`tool_call` ⇒ ToolActivity ; `name` manquant ⇒ « outil ».
|
/// Un item non-`agent_message` (reasoning/command/…) ⇒ ToolActivity (label = type).
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_tool_activity_and_fallback_s2() {
|
fn codex_non_agent_message_item_is_tool_activity() {
|
||||||
let t = codex::parse_event(r#"{"type":"tool","name":"grep"}"#).unwrap();
|
let r = codex::parse_event(
|
||||||
|
r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"…"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t.event,
|
r.events,
|
||||||
Some(ReplyEvent::ToolActivity {
|
vec![ReplyEvent::ToolActivity {
|
||||||
label: "grep".into()
|
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!(
|
assert_eq!(
|
||||||
t2.event,
|
c.events,
|
||||||
Some(ReplyEvent::ToolActivity {
|
vec![ReplyEvent::ToolActivity {
|
||||||
label: "outil".into()
|
label: "command".into()
|
||||||
})
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [S2 présumé] `delta` et `message` produisent tous deux un TextDelta.
|
/// `agent_message` ⇒ Final (porte le texte de réponse).
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_delta_and_message_both_text_s2() {
|
fn codex_agent_message_is_final() {
|
||||||
let d = codex::parse_event(r#"{"type":"delta","text":"x"}"#).unwrap();
|
let m = codex::parse_event(
|
||||||
let m = codex::parse_event(r#"{"type":"message","text":"y"}"#).unwrap();
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"bonjour"}}"#,
|
||||||
assert_eq!(d.event, Some(ReplyEvent::TextDelta { text: "x".into() }));
|
)
|
||||||
assert_eq!(m.event, Some(ReplyEvent::TextDelta { text: "y".into() }));
|
.unwrap();
|
||||||
}
|
|
||||||
|
|
||||||
/// [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();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.event,
|
m.events,
|
||||||
Some(ReplyEvent::Final {
|
vec![ReplyEvent::Final {
|
||||||
content: "sortie".into()
|
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]
|
#[test]
|
||||||
fn codex_conversation_id_falls_back_to_id_s2() {
|
fn codex_agent_message_without_text_is_empty_final() {
|
||||||
let p = codex::parse_event(r#"{"type":"session","id":"cx-7"}"#).unwrap();
|
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.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]
|
#[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("").unwrap(), Default::default());
|
||||||
assert_eq!(
|
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
|
||||||
codex::parse_event(r#"{"type":"heartbeat"}"#).unwrap().event,
|
.unwrap()
|
||||||
None
|
.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 ---------------------------------
|
// ---- Machinerie process via FakeCli ---------------------------------
|
||||||
@ -642,9 +706,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_stream_closed_after_final() {
|
async fn codex_stream_closed_after_final() {
|
||||||
let fake = FakeCli::printing(&[
|
let fake = FakeCli::printing(&[
|
||||||
r#"{"type":"session","conversation_id":"c"}"#,
|
r#"{"type":"thread.started","thread_id":"c"}"#,
|
||||||
r#"{"type":"delta","text":"a"}"#,
|
r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"a"}}"#,
|
||||||
r#"{"type":"result","text":"fin"}"#,
|
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 s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
||||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||||
@ -769,7 +834,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn claude_resume_command_carries_resume_flag() {
|
async fn claude_resume_command_carries_resume_flag() {
|
||||||
let (cmd, argv) = make_recording_fake(&[
|
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(
|
let session = ClaudeSdkSession::new(
|
||||||
SessionId::new_random(),
|
SessionId::new_random(),
|
||||||
@ -795,15 +860,26 @@ mod tests {
|
|||||||
args.contains(&"salut"),
|
args.contains(&"salut"),
|
||||||
"argv doit porter le prompt, vu: {args:?}"
|
"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(&cmd);
|
||||||
let _ = std::fs::remove_file(&argv);
|
let _ = std::fs::remove_file(&argv);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idem Codex : `codex exec --resume <id> <prompt>` (schéma S2 présumé).
|
/// Idem Codex : `codex exec resume <thread_id> --json --skip-git-repo-check <prompt>`
|
||||||
|
/// (format RÉEL : sous-commande `resume`, pas un flag `--resume`).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_resume_command_carries_resume_flag_s2() {
|
async fn codex_resume_command_carries_resume_subcommand() {
|
||||||
let (cmd, argv) =
|
let (cmd, argv) = make_recording_fake(&[
|
||||||
make_recording_fake(&[r#"{"type":"result","text":"ok","conversation_id":"cx-id"}"#]);
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||||
|
]);
|
||||||
let session = CodexExecSession::new(
|
let session = CodexExecSession::new(
|
||||||
SessionId::new_random(),
|
SessionId::new_random(),
|
||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
@ -814,8 +890,10 @@ mod tests {
|
|||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
assert!(args.contains(&"exec"), "vu: {args:?}");
|
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(&"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:?}");
|
assert!(args.contains(&"vas-y"), "vu: {args:?}");
|
||||||
let _ = std::fs::remove_file(&cmd);
|
let _ = std::fs::remove_file(&cmd);
|
||||||
let _ = std::fs::remove_file(&argv);
|
let _ = std::fs::remove_file(&argv);
|
||||||
@ -856,7 +934,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn factory_resume_seeds_codex() {
|
async fn factory_resume_seeds_codex() {
|
||||||
let factory = StructuredSessionFactory::new();
|
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 codex = structured_profile(StructuredAdapter::Codex, &fake.command());
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(
|
.start(
|
||||||
@ -927,8 +1007,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn codex_contract_holds_with_zero_deltas() {
|
async fn codex_contract_holds_with_zero_deltas() {
|
||||||
let fake = FakeCli::printing(&[
|
let fake = FakeCli::printing(&[
|
||||||
r#"{"type":"session","conversation_id":"cx-0"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-0"}"#,
|
||||||
r#"{"type":"result","text":"direct"}"#,
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"direct"}}"#,
|
||||||
]);
|
]);
|
||||||
let session: Arc<dyn AgentSession> = Arc::new(CodexExecSession::new(
|
let session: Arc<dyn AgentSession> = Arc::new(CodexExecSession::new(
|
||||||
SessionId::new_random(),
|
SessionId::new_random(),
|
||||||
@ -938,4 +1018,113 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "cx-0", "direct").await;
|
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<ReplyEvent> = 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 <prompt> --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 <prompt>` 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user