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:
238
crates/infrastructure/src/session/conformance.rs
Normal file
238
crates/infrastructure/src/session/conformance.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user