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,941 @@
//! Adapters d'**exécution structurée** des agents IA (ARCHITECTURE §17.2), pair de
//! [`crate::runtime`] (TUI/PTY) et [`crate::pty`]. Implémentent le port domaine
//! [`domain::ports::AgentSession`] et la fabrique [`domain::ports::AgentSessionFactory`].
//!
//! # Principe directeur (CRUCIAL, §17.2)
//!
//! Le **parsing du format de sortie de chaque CLI est ISOLÉ** dans une fonction pure
//! dédiée par adapter ([`claude::parse_event`], [`codex::parse_event`]), **séparée**
//! de la machinerie de process ([`process`]). Les **vrais** formats Claude/Codex
//! seront confirmés par les spikes **S1** (Claude) et **S2** (Codex) ; quand on les
//! aura, **seules ces fonctions de parsing changeront**, pas la machinerie.
//!
//! # Composants
//!
//! - [`process`] : machinerie de process **paramétrable par la commande** (spawn,
//! pipes, drain ligne-à-ligne, timeout) — substituable par un fake CLI en test.
//! - [`claude::ClaudeSdkSession`] / [`codex::CodexExecSession`] : les deux adapters.
//! - [`factory::StructuredSessionFactory`] : route un profil vers le bon adapter.
//! - [`conformance`] : **fake CLI** scriptable + **harnais de conformité** (Liskov),
//! réutilisable pour valider le contrat de port des deux adapters hors-réseau.
pub mod claude;
pub mod codex;
pub mod conformance;
pub mod factory;
pub mod process;
pub use claude::ClaudeSdkSession;
pub use codex::CodexExecSession;
pub use conformance::FakeCli;
pub use factory::StructuredSessionFactory;
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use domain::ids::ProfileId;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
PreparedContext, ReplyEvent, SessionPlan,
};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::project::ProjectPath;
use domain::{MarkdownDoc, SessionId};
use super::claude::{self, ClaudeSdkSession};
use super::codex::{self, CodexExecSession};
use super::conformance::harness::assert_agent_session_contract;
use super::conformance::FakeCli;
use super::factory::StructuredSessionFactory;
use super::process::run_turn;
// -- Helpers ----------------------------------------------------------
fn prepared_ctx() -> PreparedContext {
PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "CLAUDE.md".to_owned(),
}
}
fn cwd() -> ProjectPath {
ProjectPath::new("/").expect("cwd valide")
}
fn structured_profile(adapter: StructuredAdapter, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::new_random(),
"Profil structuré",
command,
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"),
None,
"{agentRunDir}",
None,
)
.expect("profil valide")
.with_structured_adapter(adapter)
}
// -- Machinerie de process (paramétrable, fake CLI) -------------------
#[tokio::test]
async fn run_turn_drains_every_line_in_order() {
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
let lines = run_turn(&fake.spawn_line(), None)
.await
.expect("run_turn réussit");
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
}
#[tokio::test]
async fn run_turn_unknown_binary_yields_start_error() {
let spec = super::process::SpawnLine {
command: "/binaire/qui/n/existe/pas/idea-xyz".to_owned(),
args: Vec::new(),
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
};
let err = run_turn(&spec, None).await.expect_err("doit échouer");
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
}
// -- parse_event Claude (schéma SUPPOSÉ S1) ---------------------------
#[test]
fn claude_parse_init_captures_session_id_without_event() {
let parsed =
claude::parse_event(r#"{"type":"system","subtype":"init","session_id":"conv-123"}"#)
.expect("parse ok");
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
assert_eq!(parsed.event, None);
}
#[test]
fn claude_parse_assistant_text_and_tool() {
let text = claude::parse_event(
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"bonjour"}]}}"#,
)
.expect("parse ok");
assert_eq!(
text.event,
Some(ReplyEvent::TextDelta {
text: "bonjour".to_owned()
})
);
let tool = claude::parse_event(
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]}}"#,
)
.expect("parse ok");
assert_eq!(
tool.event,
Some(ReplyEvent::ToolActivity {
label: "Read".to_owned()
})
);
}
#[test]
fn claude_parse_result_is_final() {
let parsed = claude::parse_event(
r#"{"type":"result","subtype":"success","result":"réponse finale","session_id":"conv-123"}"#,
)
.expect("parse ok");
assert_eq!(
parsed.event,
Some(ReplyEvent::Final {
content: "réponse finale".to_owned()
})
);
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
}
#[test]
fn claude_parse_broken_json_is_decode_error_no_raw_leak() {
let err = claude::parse_event("{ pas du json").expect_err("doit échouer");
match err {
AgentSessionError::Decode(msg) => {
assert!(
!msg.contains("pas du json"),
"le JSON brut ne doit pas fuir"
);
}
other => panic!("attendu Decode, vu: {other:?}"),
}
}
#[test]
fn claude_parse_empty_and_unknown_lines_are_ignored() {
assert_eq!(claude::parse_event("").expect("ok"), Default::default());
let unknown = claude::parse_event(r#"{"type":"telemetry","x":1}"#).expect("ok ignoré");
assert_eq!(unknown.event, None);
}
// -- parse_event Codex (schéma SUPPOSÉ S2) ----------------------------
#[test]
fn codex_parse_session_message_and_final() {
let sess =
codex::parse_event(r#"{"type":"session","conversation_id":"cx-9"}"#).expect("ok");
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
assert_eq!(sess.event, None);
let msg = codex::parse_event(r#"{"type":"message","text":"salut"}"#).expect("ok");
assert_eq!(
msg.event,
Some(ReplyEvent::TextDelta {
text: "salut".to_owned()
})
);
let fin = codex::parse_event(r#"{"type":"result","text":"fini"}"#).expect("ok");
assert_eq!(
fin.event,
Some(ReplyEvent::Final {
content: "fini".to_owned()
})
);
}
#[test]
fn codex_parse_broken_json_is_decode_error() {
let err = codex::parse_event("<<<").expect_err("doit échouer");
assert!(matches!(err, AgentSessionError::Decode(_)), "vu: {err:?}");
}
// -- Conformité de port (Liskov) — Claude ET Codex --------------------
/// Script Claude (schéma SUPPOSÉ S1) : init → texte → tool_use → result.
fn claude_script() -> Vec<&'static str> {
vec![
r#"{"type":"system","subtype":"init","session_id":"claude-conv-1"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"un "}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"deux"}]}}"#,
r#"{"type":"result","subtype":"success","result":"réponse Claude","session_id":"claude-conv-1"}"#,
]
}
/// Script Codex (schéma SUPPOSÉ S2) : session → message → tool → result.
fn codex_script() -> Vec<&'static str> {
vec![
r#"{"type":"session","conversation_id":"codex-conv-1"}"#,
r#"{"type":"delta","text":"trav"}"#,
r#"{"type":"tool_call","name":"bash"}"#,
r#"{"type":"result","text":"réponse Codex"}"#,
]
}
#[tokio::test]
async fn claude_session_respects_port_contract() {
let fake = FakeCli::printing(&claude_script());
let session: Arc<dyn AgentSession> = Arc::new(ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
));
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
}
#[tokio::test]
async fn codex_session_respects_port_contract() {
let fake = FakeCli::printing(&codex_script());
let session: Arc<dyn AgentSession> = Arc::new(CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
));
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
}
/// Le flux est **clos** après le `Final` : drainé une fois, il ne reproduit
/// rien (l'incarnation « un run par tour » est intrinsèquement bornée).
#[tokio::test]
async fn stream_is_closed_after_final() {
let fake = FakeCli::printing(&claude_script());
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let stream = session.send("x").await.expect("send ok");
let events: Vec<_> = stream.collect();
let after_final = events
.iter()
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
.skip(1)
.count();
assert_eq!(after_final, 0, "aucun événement après le Final");
}
/// Un JSON cassé **au milieu du flux** remonte `Decode` (jamais de panic).
#[tokio::test]
async fn broken_line_in_stream_yields_decode() {
let fake = FakeCli::printing(&[
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
"{ ceci n'est pas du json",
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
match session.send("x").await {
Err(AgentSessionError::Decode(_)) => {}
Err(other) => panic!("attendu Decode, vu: {other:?}"),
Ok(_) => panic!("attendu une erreur Decode, vu un flux"),
}
}
// -- Factory : routage par structured_adapter ------------------------
#[tokio::test]
async fn factory_supports_only_structured_profiles() {
let factory = StructuredSessionFactory::new();
let claude = structured_profile(StructuredAdapter::Claude, "claude");
let codex = structured_profile(StructuredAdapter::Codex, "codex");
let tui = AgentProfile::new(
ProfileId::new_random(),
"Gemini",
"gemini",
Vec::new(),
ContextInjection::convention_file("GEMINI.md").expect("valide"),
None,
"{agentRunDir}",
None,
)
.expect("profil valide"); // pas de structured_adapter
assert!(factory.supports(&claude));
assert!(factory.supports(&codex));
assert!(!factory.supports(&tui));
}
#[tokio::test]
async fn factory_routes_claude_and_codex() {
let factory = StructuredSessionFactory::new();
let fake = FakeCli::printing(&claude_script());
// Claude : la session démarre et respecte le contrat via le fake CLI.
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
let session = factory
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
.await
.expect("start Claude ok");
let content = drain_final(session.as_ref()).await;
assert_eq!(content, "réponse Claude");
// Codex : routé vers l'adapter Codex (id de session distinct, démarrage ok).
let fake_cx = FakeCli::printing(&codex_script());
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
let session_cx = factory
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
.await
.expect("start Codex ok");
let content_cx = drain_final(session_cx.as_ref()).await;
assert_eq!(content_cx, "réponse Codex");
}
#[tokio::test]
async fn factory_resume_seeds_conversation_id() {
let factory = StructuredSessionFactory::new();
let fake = FakeCli::printing(&claude_script());
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
let session = factory
.start(
&claude,
&prepared_ctx(),
&cwd(),
&SessionPlan::Resume {
conversation_id: "repris-42".to_owned(),
},
)
.await
.expect("start resume ok");
// L'id de reprise amorce la session avant tout tour (pivot model-agnostic).
assert_eq!(session.conversation_id().as_deref(), Some("repris-42"));
}
async fn drain_final(session: &dyn AgentSession) -> String {
let stream = session.send("x").await.expect("send ok");
for event in stream {
if let ReplyEvent::Final { content } = event {
return content;
}
}
panic!("aucun Final");
}
// -- Sanity : le PreparedContext et le ContextInjectionPlan ne sont pas
// requis par l'adapter structuré (le .md est déjà écrit par LaunchAgent).
#[test]
fn prepared_context_is_carried_not_required_by_adapter() {
// Documentation exécutable : un plan de fichier existe côté runtime PTY,
// mais l'adapter structuré ne le consomme pas (la CLI lit son convention
// file depuis le cwd). On vérifie juste que le type compose.
let _plan = ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
};
let _ = prepared_ctx();
}
/// Le timeout de la machinerie tue le process et remonte `Timeout` (un fake CLI
/// qui dort plus longtemps que la borne).
#[tokio::test]
async fn run_turn_honours_timeout() {
// Fake CLI qui dort 5s avant d'imprimer : la borne 50ms doit déclencher.
let mut path = std::env::temp_dir();
// Nom unique (compteur atomique) pour éviter toute collision entre exécutions
// parallèles répétées de la suite.
use std::sync::atomic::{AtomicU64, Ordering};
static SLOW_COUNTER: AtomicU64 = AtomicU64::new(0);
let n = SLOW_COUNTER.fetch_add(1, Ordering::Relaxed);
path.push(format!("idea-fake-slow-{}-{n}", std::process::id()));
// `File::create` + `sync_all` + `drop` ferme le descripteur en écriture AVANT
// l'exécution : sinon `execve` peut retourner `ETXTBSY` sous charge parallèle.
{
use std::io::Write as _;
let mut f = std::fs::File::create(&path).expect("write");
f.write_all(b"#!/bin/sh\nsleep 5\nprintf 'tard\\n'\n")
.expect("write");
f.sync_all().expect("sync");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
// Garantit que le binaire est exec-ready (plus de `ETXTBSY`) avant le spawn
// mesuré : on probe en boucle, mais on **tue immédiatement** l'enfant (il
// dormirait 5s) — on ne veut prouver que l'exécutabilité, pas attendre.
#[cfg(unix)]
{
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match Command::new(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(mut child) => {
let _ = child.kill();
let _ = child.wait();
break;
}
Err(e) if e.raw_os_error() == Some(26) && Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
}
let spec = super::process::SpawnLine {
command: path.to_string_lossy().into_owned(),
args: Vec::new(),
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
};
let err = run_turn(&spec, Some(Duration::from_millis(50)))
.await
.expect_err("doit expirer");
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
let _ = std::fs::remove_file(&path);
}
// =====================================================================
// DURCISSEMENT QA (lot D2) — couvre les axes non couverts par les tests
// initiaux. Tout passe par le FakeCli (jamais le vrai claude/codex).
// =====================================================================
// -- Helper : fake CLI qui enregistre son argv dans un fichier sidecar,
// puis rejoue un script de lignes. Permet de PROUVER que la commande
// générée porte bien le flag de reprise (`--resume <id>`).
fn make_recording_fake(script: &[&str]) -> (String, std::path::PathBuf) {
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut bin = std::env::temp_dir();
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
let mut argv = std::env::temp_dir();
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
let mut s = String::from("#!/bin/sh\n");
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
s.push_str(&format!(
"for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{}'; done\n",
argv.display()
));
for line in script {
s.push_str("printf '%s\\n' ");
// réutilise le quoting de conformance via un quoting local simple.
s.push('\'');
s.push_str(&line.replace('\'', "'\\''"));
s.push_str("'\n");
}
{
let mut f = std::fs::File::create(&bin).expect("create rec fake");
f.write_all(s.as_bytes()).expect("write rec fake");
f.sync_all().expect("sync rec fake");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = std::fs::metadata(&bin).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(&bin, p).unwrap();
}
super::conformance::wait_until_executable(&bin);
(bin.to_string_lossy().into_owned(), argv)
}
// ---- Claude parse_event : plusieurs blocs, robustesse ---------------
/// Plusieurs `tool_use` / blocs texte dans des messages successifs : chaque
/// message produit UN événement (le premier bloc pertinent). On documente la
/// limite connue : un message multi-blocs ne rend que son **premier** bloc.
#[test]
fn claude_multiple_messages_each_yield_one_event() {
let t1 = claude::parse_event(
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
)
.unwrap();
let t2 = claude::parse_event(
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}"#,
)
.unwrap();
assert_eq!(t1.event, Some(ReplyEvent::TextDelta { text: "a".into() }));
assert_eq!(
t2.event,
Some(ReplyEvent::ToolActivity {
label: "Bash".into()
})
);
}
/// LIMITE CONNUE (à arbitrer dev/archi) : un **seul** message portant plusieurs
/// blocs `text`/`tool_use` ne rend que le PREMIER bloc pertinent — les blocs
/// suivants sont perdus. Ce test PINNE le comportement actuel (pas un échec).
#[test]
fn claude_multiblock_message_keeps_only_first_block_known_limit() {
let parsed = claude::parse_event(
r#"{"type":"assistant","message":{"content":[
{"type":"text","text":"un"},
{"type":"tool_use","name":"Read"},
{"type":"text","text":"deux"}]}}"#,
)
.unwrap();
// Comportement ACTUEL : seul le premier bloc (`text:"un"`) est émis.
assert_eq!(
parsed.event,
Some(ReplyEvent::TextDelta { text: "un".into() })
);
}
/// `tool_use` sans `name` ⇒ label de repli « outil » (jamais de panic).
#[test]
fn claude_tool_use_without_name_falls_back() {
let parsed = claude::parse_event(
r#"{"type":"assistant","message":{"content":[{"type":"tool_use"}]}}"#,
)
.unwrap();
assert_eq!(
parsed.event,
Some(ReplyEvent::ToolActivity {
label: "outil".into()
})
);
}
/// `result` sans champ `result` ⇒ aucun event (pas de panic, pas de Final vide
/// fabriqué). Documente la robustesse du parser.
#[test]
fn claude_result_without_content_yields_no_event() {
let parsed = claude::parse_event(r#"{"type":"result","subtype":"success"}"#).unwrap();
assert_eq!(parsed.event, None);
}
/// Ligne whitespace-only (espaces/tabs) ⇒ ignorée comme une ligne vide.
#[test]
fn claude_whitespace_line_is_ignored() {
assert_eq!(claude::parse_event(" \t ").unwrap(), Default::default());
}
/// JSON valide mais non-objet (tableau, nombre) ⇒ pas de type ⇒ ignoré, jamais
/// de panic, jamais de Decode.
#[test]
fn claude_valid_non_object_json_is_ignored() {
assert_eq!(claude::parse_event("[1,2,3]").unwrap().event, None);
assert_eq!(claude::parse_event("42").unwrap().event, None);
}
// ---- Codex parse_event (schéma SUPPOSÉ S2 — NON CONFIRMÉ) -----------
// 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 ».
#[test]
fn codex_tool_activity_and_fallback_s2() {
let t = codex::parse_event(r#"{"type":"tool","name":"grep"}"#).unwrap();
assert_eq!(
t.event,
Some(ReplyEvent::ToolActivity {
label: "grep".into()
})
);
let t2 = codex::parse_event(r#"{"type":"tool_call"}"#).unwrap();
assert_eq!(
t2.event,
Some(ReplyEvent::ToolActivity {
label: "outil".into()
})
);
}
/// [S2 présumé] `delta` et `message` produisent tous deux un TextDelta.
#[test]
fn codex_delta_and_message_both_text_s2() {
let d = codex::parse_event(r#"{"type":"delta","text":"x"}"#).unwrap();
let m = codex::parse_event(r#"{"type":"message","text":"y"}"#).unwrap();
assert_eq!(d.event, Some(ReplyEvent::TextDelta { text: "x".into() }));
assert_eq!(m.event, Some(ReplyEvent::TextDelta { text: "y".into() }));
}
/// [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!(
f.event,
Some(ReplyEvent::Final {
content: "sortie".into()
})
);
}
/// [S2 présumé] id de conversation : repli `id` quand `conversation_id` absent.
#[test]
fn codex_conversation_id_falls_back_to_id_s2() {
let p = codex::parse_event(r#"{"type":"session","id":"cx-7"}"#).unwrap();
assert_eq!(p.conversation_id.as_deref(), Some("cx-7"));
assert_eq!(p.event, None);
}
/// [S2 présumé] ligne vide / type inconnu ⇒ ignorée sans erreur.
#[test]
fn codex_empty_and_unknown_ignored_s2() {
assert_eq!(codex::parse_event("").unwrap(), Default::default());
assert_eq!(
codex::parse_event(r#"{"type":"heartbeat"}"#).unwrap().event,
None
);
}
// ---- Machinerie process via FakeCli ---------------------------------
/// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert
/// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov).
#[tokio::test]
async fn codex_stream_closed_after_final() {
let fake = FakeCli::printing(&[
r#"{"type":"session","conversation_id":"c"}"#,
r#"{"type":"delta","text":"a"}"#,
r#"{"type":"result","text":"fin"}"#,
]);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let after = events
.iter()
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
.skip(1)
.count();
assert_eq!(after, 0);
}
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
/// niveau de l'adapter — `send()` renvoie Ok avec uniquement des deltas et AUCUN
/// `Final`. Le §17.9 D2 mentionne « flux sans Final ⇒ Io » ; l'adapter actuel ne
/// l'applique pas (c'est `send_blocking` côté application, lot D1, qui transforme
/// l'absence de Final en Timeout). Ce test PINNE le comportement réel observé.
#[tokio::test]
async fn stream_without_final_is_silently_ok_at_adapter_level() {
let fake = FakeCli::printing(&[
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
]);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
let finals = events
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(finals, 0, "comportement actuel: aucun Final fabriqué");
// L'id de conversation est tout de même capté (init lu).
assert_eq!(s.conversation_id().as_deref(), Some("c"));
}
/// EOF immédiat (binaire qui n'imprime rien) ⇒ flux vide, pas d'erreur, pas de
/// panic. La machinerie draine proprement un stdout vide.
#[tokio::test]
async fn run_turn_empty_output_is_ok() {
let fake = FakeCli::printing(&[]);
let lines = run_turn(&fake.spawn_line(), None).await.expect("ok");
assert!(lines.is_empty());
}
/// stdin fourni : la machinerie l'écrit et ferme le pipe (EOF) sans bloquer.
#[tokio::test]
async fn run_turn_writes_stdin_then_eof() {
let fake = FakeCli::printing(&["pong"]);
let mut spec = fake.spawn_line();
spec.stdin = Some("ping".to_owned());
let lines = run_turn(&spec, None).await.expect("ok");
assert_eq!(lines, vec!["pong"]);
}
/// Timeout via la machinerie + FakeCli lent (≈ le test existant, mais bâti sur
/// un fake qui dort) : borne courte ⇒ `Timeout`.
#[tokio::test]
async fn run_turn_timeout_on_slow_fake() {
// Fake dormeur déterministe (sleep 5s) borné par un timeout de 50ms.
let mut path = std::env::temp_dir();
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
path.push(format!(
"idea-slow2-{}-{}",
std::process::id(),
C.fetch_add(1, Ordering::Relaxed)
));
{
use std::io::Write as _;
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"#!/bin/sh\nsleep 5\n").unwrap();
f.sync_all().unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = std::fs::metadata(&path).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(&path, p).unwrap();
}
// Probe non-bloquant (kill immédiat) pour écarter ETXTBSY.
#[cfg(unix)]
{
use std::process::{Command, Stdio};
use std::time::Instant;
let dl = Instant::now() + Duration::from_secs(5);
loop {
match Command::new(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(mut c) => {
let _ = c.kill();
let _ = c.wait();
break;
}
Err(e) if e.raw_os_error() == Some(26) && Instant::now() < dl => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
}
let spec = super::process::SpawnLine {
command: path.to_string_lossy().into_owned(),
args: Vec::new(),
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
};
let err = run_turn(&spec, Some(Duration::from_millis(50)))
.await
.expect_err("doit expirer");
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
let _ = std::fs::remove_file(&path);
}
// ---- Adapters : reprise + commande générée porte le flag --------------
/// PROUVE que la commande réellement lancée porte `--resume <id>` quand la
/// session a été amorcée en reprise (via le sidecar argv du fake enregistreur).
#[tokio::test]
async fn claude_resume_command_carries_resume_flag() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"result","subtype":"success","result":"ok","session_id":"resume-id"}"#,
]);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
Some("resume-id".to_owned()),
);
// conversation_id amorcé avant tout tour.
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv enregistré");
let args: Vec<&str> = recorded.lines().collect();
assert!(
args.contains(&"--resume"),
"argv doit porter --resume, vu: {args:?}"
);
assert!(
args.contains(&"resume-id"),
"argv doit porter l'id de reprise, vu: {args:?}"
);
assert!(
args.contains(&"salut"),
"argv doit porter le prompt, vu: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
/// Idem Codex : `codex exec --resume <id> <prompt>` (schéma S2 présumé).
#[tokio::test]
async fn codex_resume_command_carries_resume_flag_s2() {
let (cmd, argv) =
make_recording_fake(&[r#"{"type":"result","text":"ok","conversation_id":"cx-id"}"#]);
let session = CodexExecSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
Some("cx-id".to_owned()),
);
let _ = session.send("vas-y").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"), "vu: {args:?}");
assert!(args.contains(&"cx-id"), "vu: {args:?}");
assert!(args.contains(&"vas-y"), "vu: {args:?}");
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
/// Une conversation NEUVE (pas de seed) NE porte PAS `--resume` au premier tour,
/// mais le capte après (init) ⇒ le SECOND tour, lui, porte `--resume`.
#[tokio::test]
async fn claude_new_then_resume_flag_appears_on_second_turn() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
assert_eq!(session.conversation_id(), None);
let _ = session.send("t1").await.expect("t1");
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
let _ = session.send("t2").await.expect("t2");
let recorded = std::fs::read_to_string(&argv).unwrap();
// Le sidecar accumule les deux tours. Le 1er tour ne doit PAS avoir d'id avant
// capture ; après capture le 2e tour porte --resume captured-1. On vérifie la
// présence globale (les deux tours sont concaténés).
assert!(
recorded.contains("--resume"),
"2e tour doit porter --resume"
);
assert!(recorded.contains("captured-1"));
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
// ---- Factory : reprise amorce le seed + route correctement -----------
/// Reprise via la factory : `SessionPlan::Resume` amorce le seed, et la session
/// résultante porte bien l'id AVANT tout tour (déjà partiellement couvert ; ici
/// on couvre AUSSI Codex).
#[tokio::test]
async fn factory_resume_seeds_codex() {
let factory = StructuredSessionFactory::new();
let fake = FakeCli::printing(&[r#"{"type":"result","text":"ok"}"#]);
let codex = structured_profile(StructuredAdapter::Codex, &fake.command());
let session = factory
.start(
&codex,
&prepared_ctx(),
&cwd(),
&SessionPlan::Resume {
conversation_id: "cx-resume".to_owned(),
},
)
.await
.expect("start resume codex");
assert_eq!(session.conversation_id().as_deref(), Some("cx-resume"));
}
/// `SessionPlan::Assign` (assigne un id côté IdeA mais conversation moteur neuve)
/// ⇒ pas de seed moteur (l'id moteur sera capté au 1er tour). Couvre la 3e
/// variante de SessionPlan, non testée jusqu'ici.
#[tokio::test]
async fn factory_assign_does_not_seed_engine_id() {
let factory = StructuredSessionFactory::new();
let fake = FakeCli::printing(&claude_script());
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
let session = factory
.start(
&claude,
&prepared_ctx(),
&cwd(),
&SessionPlan::Assign {
conversation_id: "ignored-by-engine".to_owned(),
},
)
.await
.expect("start assign");
// Assign n'amorce PAS le moteur : conversation_id reste None avant tour.
assert_eq!(session.conversation_id(), None);
}
/// La factory échoue proprement (`Start`) si on lui passe un profil SANS adapter
/// structuré (cohérence avec `supports`). Garde-fou défensif.
#[tokio::test]
async fn factory_start_rejects_non_structured_profile() {
let factory = StructuredSessionFactory::new();
let tui = AgentProfile::new(
ProfileId::new_random(),
"Aider",
"aider",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").expect("valide"),
None,
"{agentRunDir}",
None,
)
.expect("profil valide");
match factory
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
.await
{
Err(AgentSessionError::Start(_)) => {}
Err(other) => panic!("attendu Start, vu: {other:?}"),
Ok(_) => panic!("la factory ne doit pas démarrer un profil non structuré"),
}
}
/// Codex passe le harnais de conformité partagé (substituabilité Liskov) — déjà
/// présent ; on ajoute un script Codex MINIMAL (zéro delta) pour prouver que le
/// contrat « ≥0 deltas puis un Final » tient avec zéro delta.
#[tokio::test]
async fn codex_contract_holds_with_zero_deltas() {
let fake = FakeCli::printing(&[
r#"{"type":"session","conversation_id":"cx-0"}"#,
r#"{"type":"result","text":"direct"}"#,
]);
let session: Arc<dyn AgentSession> = Arc::new(CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
));
assert_agent_session_contract(session, "cx-0", "direct").await;
}
}