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,238 @@
//! Fake CLI scriptable + **harnais de conformité de port (Liskov)** pour les
//! adapters structurés (ARCHITECTURE §17.2). Permet de tester la **machinerie**
//! (spawn, lecture ligne-à-ligne, drain jusqu'au `Final`, capture d'id de session,
//! shutdown, timeout) **sans réseau ni vraie CLI**, et d'asserter le **contrat
//! [`AgentSession`]** de façon réutilisable pour Claude ET Codex.
//!
//! 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.
//!
//! > Ce qui dépend du **format réel non vérifié** (spikes S1/S2) : les *scripts*
//! > de lignes JSON fournis aux tests reproduisent le **schéma SUPPOSÉ** documenté
//! > dans `claude::parse_event` / `codex::parse_event`. Quand S1/S2 confirmeront le
//! > vrai format, ces scripts (et le parser) seront ajustés ; la machinerie et le
//! > harnais, eux, restent valides.
use std::io::Write;
use std::path::PathBuf;
use super::process::SpawnLine;
/// Un **fake CLI** : un script exécutable qui **rejoue un script de lignes** sur
/// stdout (en ignorant ses arguments), puis se termine. Substitué au vrai
/// `claude`/`codex` pour rendre la machinerie déterministe et hors-réseau.
///
/// Le binaire est matérialisé dans un fichier temporaire ; il est supprimé au drop.
pub struct FakeCli {
/// Chemin du script exécutable généré.
path: PathBuf,
}
impl FakeCli {
/// Crée un fake CLI qui imprimera exactement `lines` (une par ligne de stdout),
/// dans l'ordre, puis sortira avec le code 0.
///
/// # Panics
/// Panique si le fichier temporaire ne peut être écrit (environnement de test
/// cassé) — acceptable dans un utilitaire de test.
#[must_use]
pub fn printing(lines: &[&str]) -> Self {
let mut path = std::env::temp_dir();
// Nom unique : pid + compteur atomique pour éviter toute collision entre
// tests parallèles.
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
path.push(format!("idea-fake-cli-{}-{n}", std::process::id()));
let mut script = String::from("#!/bin/sh\n");
for line in lines {
// `printf '%s\n'` imprime la ligne littéralement (pas d'interprétation
// d'échappements), en isolant la donnée de toute injection shell via
// l'unique argument `--`.
script.push_str("printf '%s\\n' ");
script.push_str(&shell_single_quote(line));
script.push('\n');
}
let mut file = std::fs::File::create(&path).expect("création du fake CLI");
file.write_all(script.as_bytes())
.expect("écriture du fake CLI");
// `sync_all` force la fermeture/flush du descripteur en écriture AVANT toute
// tentative d'exécution : sans cela, `execve` sur un binaire encore ouvert en
// écriture par un autre thread (suite parallèle) retourne `ETXTBSY`
// (« Text file busy », os error 26) — défaut de fixture intermittent.
file.sync_all().expect("sync du fake CLI");
drop(file);
set_executable(&path);
wait_until_executable(&path);
Self { path }
}
/// Le binaire à passer en `command` d'un [`SpawnLine`] / d'un adapter.
#[must_use]
pub fn command(&self) -> String {
self.path.to_string_lossy().into_owned()
}
/// Construit un [`SpawnLine`] minimal lançant ce fake CLI (utile pour tester la
/// machinerie [`super::process::run_turn`] directement).
#[must_use]
pub fn spawn_line(&self) -> SpawnLine {
SpawnLine {
command: self.command(),
args: Vec::new(),
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
}
}
}
impl Drop for FakeCli {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
/// Échappe une chaîne pour l'insérer en argument shell entre quotes simples.
fn shell_single_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
/// Attend que `path` soit réellement exécutable (probe `execve` qui ne retourne
/// plus `ETXTBSY`). Sur Linux, exécuter un fichier encore ouvert en écriture par un
/// autre thread échoue avec « Text file busy » : on boucle un court instant jusqu'à
/// ce que la condition se lève, garantissant qu'un `spawn` ultérieur ne *race* pas.
#[cfg(unix)]
pub(crate) fn wait_until_executable(path: &std::path::Path) {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(5);
loop {
// Probe la plus légère possible : on tente le spawn ; ETXTBSY ⇒ on réessaie.
match Command::new(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(mut child) => {
let _ = child.wait();
return;
}
Err(e) if e.raw_os_error() == Some(26) && Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(2));
}
// Toute autre erreur (ou dépassement de délai) : on rend la main, le test
// appelant remontera l'échec réel s'il subsiste.
Err(_) => return,
}
}
}
#[cfg(not(unix))]
pub(crate) fn wait_until_executable(_path: &std::path::Path) {}
#[cfg(unix)]
fn set_executable(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.expect("metadata fake CLI")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).expect("chmod fake CLI");
}
#[cfg(not(unix))]
fn set_executable(_path: &std::path::Path) {
// Sur les plateformes non-Unix le harnais s'appuiera sur un fake CLI adapté
// (ex. `.cmd`) ; non requis pour la CI Linux actuelle.
}
// ---------------------------------------------------------------------------
// Harnais de conformité de port (Liskov) — réutilisable Claude ET Codex
// ---------------------------------------------------------------------------
#[cfg(test)]
pub(crate) mod harness {
use std::sync::Arc;
use domain::ports::{AgentSession, ReplyEvent};
/// Asserte le **contrat [`AgentSession`]** sur une session déjà construite
/// derrière un fake CLI dont le script produit ≥0 deltas/activités puis **un**
/// `Final` portant `expected_final`, et dont l'init assigne `expected_conv_id`.
///
/// Vérifie (substituabilité Liskov, §17.2) :
/// 1. `send` émet une séquence de deltas/activités **puis exactement un** `Final` ;
/// 2. après le `Final` le flux est **clos** (plus aucun événement) ;
/// 3. le `Final` porte bien `expected_final` ;
/// 4. `conversation_id()` devient `Some(expected_conv_id)` après le tour assignant ;
/// 5. `shutdown()` réussit et est **idempotent** (deux appels OK).
pub async fn assert_agent_session_contract(
session: Arc<dyn AgentSession>,
expected_conv_id: &str,
expected_final: &str,
) {
// Avant tout tour : aucun id de conversation assigné.
assert_eq!(
session.conversation_id(),
None,
"conversation_id doit être None avant le premier tour"
);
let stream = session.send("salut").await.expect("send doit réussir");
let events: Vec<ReplyEvent> = stream.collect();
// (1)+(2) : exactement un Final, en dernière position.
let final_count = events
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(final_count, 1, "le flux doit porter EXACTEMENT un Final");
match events.last() {
Some(ReplyEvent::Final { content }) => {
// (3)
assert_eq!(content, expected_final, "contenu Final inattendu");
}
other => panic!("le dernier événement doit être Final, vu: {other:?}"),
}
// Les événements avant le Final ne sont que des deltas / activités.
for e in &events[..events.len() - 1] {
assert!(
matches!(
e,
ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. }
),
"avant le Final, seuls deltas/activités sont permis, vu: {e:?}"
);
}
// (4) : id de conversation capté après le tour assignant.
assert_eq!(
session.conversation_id().as_deref(),
Some(expected_conv_id),
"conversation_id doit être assigné après le premier tour"
);
// (5) : shutdown réussit et est idempotent.
session.shutdown().await.expect("shutdown doit réussir");
session
.shutdown()
.await
.expect("shutdown doit être idempotent");
}
}