feat(agent): fondation exécution structurée des agents IA (D0+D1) — §17

Pivot orchestration : agents IA pilotés via leur mode programmatique/JSON
(capture déterministe), au lieu du TUI brut + self-report. §16 (idea/MCP)
marquée remplacée comme voie principale.

- D0 (domaine) : port AgentSession + AgentSessionFactory, types ReplyEvent
  /ReplyStream/AgentSessionError, champ AgentProfile.structured_adapter
  (Option<StructuredAdapter{Claude,Codex}>, skip si None ⇒ zéro régression),
  catalogue Claude/Codex annotés.
- D1 (application) : registre StructuredSessions (jumeau de TerminalSessions),
  agrégateur LiveSessions{pty,structured} derrière LiveAgentRegistry (vivant si
  PTY OU structuré, surface du trait inchangée), helper send_blocking (draine le
  ReplyStream jusqu'au Final, Timeout sans tuer la session).

Tests : domaine 16+2 ; application registre 11 + send_blocking 9 ; workspace 0 échec.
A/B intacts. Aucun adapter concret (D2), pas de Tauri/front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:54:48 +02:00
parent 7375f706da
commit 5e10b5eb42
17 changed files with 2084 additions and 11 deletions

View File

@ -14,6 +14,9 @@ serde = { workspace = true }
serde_json = { workspace = true }
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
uuid = { workspace = true }
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
# Déjà le runtime async du workspace ; pas une nouvelle dépendance externe.
tokio = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@ -18,7 +18,7 @@
//! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
@ -56,7 +56,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
"{agentRunDir}",
None,
)
.expect("claude reference profile is valid"),
.expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude),
AgentProfile::new(
reference_id("codex"),
"OpenAI Codex CLI",
@ -68,7 +69,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
"{agentRunDir}",
None,
)
.expect("codex reference profile is valid"),
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex),
AgentProfile::new(
reference_id("gemini"),
"Gemini CLI",

View File

@ -10,10 +10,13 @@ mod catalogue;
mod inspect;
mod lifecycle;
mod resume;
mod structured;
mod usecases;
pub(crate) use lifecycle::unique_md_path;
pub use structured::send_blocking;
pub use catalogue::{reference_profile_id, reference_profiles};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use resume::{

View File

@ -0,0 +1,73 @@
//! Helper applicatif `send_blocking` — le rendez-vous synchrone inter-agents
//! au-dessus du port [`AgentSession`] (ARCHITECTURE §17.1 / §17.4).
//!
//! `AgentSession::send` retourne un **flux** d'événements (`ReplyStream`), à la
//! manière de `PtyPort::subscribe_output`, mais **typé** : deltas de texte →
//! activités d'outil → **un** événement terminal déterministe
//! [`ReplyEvent::Final`]. Le rendez-vous synchrone dont l'orchestrateur a besoin
//! (§17.4) s'obtient en **drainant ce flux jusqu'au `Final`** : c'est *la* primitive
//! de la messagerie inter-agents, **sans outbox, sans corrélation fichier** (le
//! `Final` *est* la fin de tour).
//!
//! DRY : un seul chemin de lecture (le flux). `send_blocking` n'est qu'un *consom-
//! mateur* du même flux que la cellule chat utilise pour le rendu incrémental.
use std::time::Duration;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
///
/// C'est le rendez-vous synchrone (§17.4) : on attend que le tour soit
/// déterministiquement terminé (`Final`) avant de rendre la main. Les deltas de
/// texte et les activités d'outil traversés en chemin sont **ignorés** ici (ils
/// servent le rendu incrémental côté UI, pas l'appelant synchrone).
///
/// `timeout`, lorsqu'il est fourni, borne l'attente : si aucun `Final` n'est observé
/// dans le délai, on retourne [`AgentSessionError::Timeout`] **sans tuer la
/// session** (elle reste vivante dans le registre ; l'appelant décide de la suite).
/// `None` ⇒ pas de borne temporelle (on attend la fin du tour).
///
/// # Errors
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
/// (échec de communication / décodage de la sortie structurée) ;
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
/// interrompu) ;
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
pub async fn send_blocking(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
) -> Result<String, AgentSessionError> {
match timeout {
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
Ok(result) => result,
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
Err(_elapsed) => Err(AgentSessionError::Timeout),
},
None => drain_to_final(session, prompt).await,
}
}
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**.
///
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
async fn drain_to_final(
session: &dyn AgentSession,
prompt: &str,
) -> Result<String, AgentSessionError> {
let stream = session.send(prompt).await?;
for event in stream {
if let ReplyEvent::Final { content } = event {
return Ok(content);
}
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
}
Err(AgentSessionError::Io(
"le flux de réponse s'est terminé sans événement Final".to_string(),
))
}

View File

@ -38,7 +38,7 @@ pub use agent::{
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET,
send_blocking, AGENT_MEMORY_RECALL_BUDGET,
};
pub use embedder::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
@ -94,8 +94,8 @@ pub use template::{
UpdateTemplateOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, OpenTerminal,
OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions,
WriteToTerminal, WriteToTerminalInput,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};

View File

@ -28,7 +28,7 @@
mod registry;
mod usecases;
pub use registry::{LiveAgentRegistry, TerminalSessions};
pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
pub use usecases::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,

View File

@ -7,9 +7,9 @@
//! application layer rather than the domain or the adapter.
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use domain::ports::PtyHandle;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
/// A registered, live terminal: its PTY handle plus the domain snapshot.
@ -204,3 +204,254 @@ impl TerminalSessions {
self.len() == 0
}
}
// ---------------------------------------------------------------------------
// StructuredSessions — le jumeau de TerminalSessions pour les sessions IA (§17.5)
// ---------------------------------------------------------------------------
/// Une session structurée enregistrée : la session vivante plus les coordonnées
/// (agent + cellule hôte) que [`AgentSession`] ne porte pas lui-même.
///
/// `AgentSession` n'expose que `id()`/`conversation_id()` ; comme le snapshot
/// [`TerminalSession`] côté PTY, on associe ici l'`agent_id` (clé de liveness) et
/// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
/// [`TerminalSessions`].
struct StructuredEntry {
/// La session vivante (ressource process/SDK), derrière le port domaine.
session: Arc<dyn AgentSession>,
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
agent_id: AgentId,
/// La cellule (feuille de layout) qui héberge actuellement la vue.
node_id: NodeId,
}
/// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5).
///
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
/// du modèle métier — cf. les docs de [`TerminalSessions`]), même surface côté
/// liveness/agent (`session_for_agent`, `node_for_agent`, `live_agents`,
/// `rebind_agent_node`, `insert`/`remove`/`session`). La seule différence : il
/// stocke des `Arc<dyn AgentSession>` (sessions programmatiques) au lieu de
/// [`PtyHandle`]/[`TerminalSession`].
///
/// Respecte l'invariant produit **« 1 session vivante par agent »** : la garde
/// d'unicité (généralisée sur les deux registres via [`LiveSessions`]) interroge
/// `session_for_agent` avant tout lancement.
#[derive(Default)]
pub struct StructuredSessions {
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
}
impl LiveAgentRegistry for StructuredSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some()
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.entries
.lock()
.map(|m| m.values().any(|e| e.node_id == *node_id))
.unwrap_or(false)
}
}
impl StructuredSessions {
/// Crée un registre vide.
#[must_use]
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
/// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par
/// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
if let Ok(mut map) = self.entries.lock() {
let id = session.id();
map.insert(
id,
StructuredEntry {
session,
agent_id,
node_id,
},
);
}
}
/// Retourne la session enregistrée pour un id, si présente.
#[must_use]
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
}
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
///
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| &e.agent_id == agent_id)
.map(|e| Arc::clone(&e.session))
})
}
/// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| &e.agent_id == agent_id)
.map(|e| e.session.id())
})
}
/// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
///
/// Jumeau de [`TerminalSessions::node_for_agent`].
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| &e.agent_id == agent_id)
.map(|e| e.node_id)
})
}
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session.
///
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
/// `(AgentId, NodeId, SessionId)` par session structurée vivante.
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries
.lock()
.map(|m| {
m.values()
.map(|e| (e.agent_id, e.node_id, e.session.id()))
.collect()
})
.unwrap_or_default()
}
/// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
/// redémarrer la conversation (« la cellule est une vue », §17.6).
///
/// Jumeau de [`TerminalSessions::rebind_agent_node`] : seul le `node_id`
/// change ; la session, son id et sa conversation restent intacts. Retourne la
/// session rebindée, ou `None` si l'agent n'a pas de session vivante.
#[must_use]
pub fn rebind_agent_node(
&self,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?;
entry.node_id = node_id;
Some(Arc::clone(&entry.session))
})
}
/// Retire une session du registre, retournant la session si présente (pour que
/// l'appelant la `shutdown` hors du verrou).
pub fn remove(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
self.entries
.lock()
.ok()
.and_then(|mut m| m.remove(id).map(|e| e.session))
}
/// Retourne toutes les sessions vivantes (pour un arrêt global propre au
/// shutdown applicatif, jumeau de [`TerminalSessions::handles`]).
#[must_use]
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
self.entries
.lock()
.map(|m| m.values().map(|e| Arc::clone(&e.session)).collect())
.unwrap_or_default()
}
/// Nombre de sessions structurées vivantes.
#[must_use]
pub fn len(&self) -> usize {
self.entries.lock().map(|m| m.len()).unwrap_or(0)
}
/// Si le registre est vide.
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ---------------------------------------------------------------------------
// LiveSessions — agrégateur des deux registres derrière LiveAgentRegistry (§17.5)
// ---------------------------------------------------------------------------
/// Agrégateur de liveness sur **les deux** registres (PTY + structuré).
///
/// Un agent terminal brut vit dans [`TerminalSessions`] ; un agent IA structuré vit
/// dans [`StructuredSessions`]. La garde d'unicité et l'orchestrateur (§17.4)
/// dépendent de cet agrégateur (ISP : ils ne voient que la capacité « liveness +
/// résolution »), et une requête de liveness/agent voit donc les deux registres :
/// un agent est vivant s'il a une session vivante dans **l'un OU l'autre**.
///
/// Implémente [`LiveAgentRegistry`] : le trait existant n'est **pas modifié** (ses
/// implémenteurs et appelants actuels — `TerminalSessions`, le snapshot — restent
/// inchangés), il est simplement **réalisé par un troisième implémenteur** qui
/// agrège, ce qui *généralise* sa portée à l'ensemble PTY+structuré sans régression.
pub struct LiveSessions {
/// Registre des sessions terminal brut (PTY).
pub pty: Arc<TerminalSessions>,
/// Registre des sessions IA structurées.
pub structured: Arc<StructuredSessions>,
}
impl LiveSessions {
/// Construit l'agrégateur à partir des deux registres partagés.
#[must_use]
pub fn new(pty: Arc<TerminalSessions>, structured: Arc<StructuredSessions>) -> Self {
Self { pty, structured }
}
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.pty
.session_for_agent(agent_id)
.or_else(|| self.structured.session_id_for_agent(agent_id))
}
/// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.pty
.node_for_agent(agent_id)
.or_else(|| self.structured.node_for_agent(agent_id))
}
/// Tous les agents vivants des deux registres (PTY puis structurés).
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
let mut all = self.pty.live_agents();
all.extend(self.structured.live_agents());
all
}
}
impl LiveAgentRegistry for LiveSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id)
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
}
}

View File

@ -324,3 +324,39 @@ fn catalogue_ids_are_stable_across_calls() {
// And match the slug-derived id helper.
assert_eq!(first[0].id, reference_profile_id("claude"));
}
// ---------------------------------------------------------------------------
// LOT D0 (§17.3) — structured_adapter sur les profils de référence
// ---------------------------------------------------------------------------
#[test]
fn catalogue_claude_and_codex_carry_their_structured_adapter() {
use domain::profile::StructuredAdapter;
let profiles = reference_profiles();
let by_command: HashMap<&str, &AgentProfile> =
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
// Claude / Codex sont pilotés en mode structuré (cellule chat + AgentSession).
assert_eq!(
by_command["claude"].structured_adapter,
Some(StructuredAdapter::Claude),
"Claude reference profile must declare the Claude structured adapter"
);
assert_eq!(
by_command["codex"].structured_adapter,
Some(StructuredAdapter::Codex),
"Codex reference profile must declare the Codex structured adapter"
);
}
#[test]
fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
// §17.3 : les profils non encore couverts restent TUI/PTY (pas d'adapter).
let profiles = reference_profiles();
let by_command: HashMap<&str, &AgentProfile> =
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
assert_eq!(by_command["gemini"].structured_adapter, None);
assert_eq!(by_command["aider"].structured_adapter, None);
}

View File

@ -0,0 +1,222 @@
//! LOT D1 (ARCHITECTURE §17.1 / §17.4 / §17.9 ligne D1) — tests unitaires du
//! helper applicatif `send_blocking`, **100 % fakes**.
//!
//! `send_blocking` draine le flux d'un tour jusqu'au [`ReplyEvent::Final`] :
//! - cas nominal : `TextDelta*` puis `Final{content}` ⇒ renvoie `content` (les
//! deltas/activités traversés sont ignorés par le rendez-vous synchrone) ;
//! - flux **sans** `Final` (épuisé) ⇒ [`AgentSessionError::Io`] ;
//! - `send` renvoyant `Err(Decode/Io)` ⇒ propagé tel quel ;
//! - **timeout** ⇒ [`AgentSessionError::Timeout`] **sans tuer la session**
//! (aucun `shutdown` appelé) ; le fake retarde son `send` de façon
//! déterministe (au-delà du `timeout`) pour forcer l'expiration.
//!
//! Fake `AgentSession` local et **scriptable** (inspiré du fake D0 du domaine) :
//! il produit le `ReplyStream` qu'on lui a scripté, ou une erreur, ou un délai ;
//! il compte ses `shutdown` pour prouver la non-mise-à-mort sur timeout.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use application::send_blocking;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::SessionId;
use uuid::Uuid;
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
/// Ce que le fake doit faire au prochain `send`.
enum Script {
/// Renvoyer ce flux d'événements (consommé tel quel).
Stream(Vec<ReplyEvent>),
/// `send` échoue avec cette erreur.
Err(AgentSessionError),
/// `send` dort `delay` avant de renvoyer le flux (force le timeout).
Delayed {
delay: Duration,
events: Vec<ReplyEvent>,
},
}
/// Fake scriptable d'`AgentSession`. Mono-usage (un `send` scripté).
struct ScriptedSession {
id: SessionId,
script: std::sync::Mutex<Option<Script>>,
shutdowns: AtomicUsize,
}
impl ScriptedSession {
fn new(script: Script) -> Self {
Self {
id: sid(1),
script: std::sync::Mutex::new(Some(script)),
shutdowns: AtomicUsize::new(0),
}
}
fn shutdown_count(&self) -> usize {
self.shutdowns.load(Ordering::SeqCst)
}
}
#[async_trait]
impl AgentSession for ScriptedSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let script = self
.script
.lock()
.unwrap()
.take()
.expect("send scripté une seule fois");
match script {
Script::Stream(events) => {
let stream: ReplyStream = Box::new(events.into_iter());
Ok(stream)
}
Script::Err(e) => Err(e),
Script::Delayed { delay, events } => {
tokio::time::sleep(delay).await;
let stream: ReplyStream = Box::new(events.into_iter());
Ok(stream)
}
}
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
fn delta(t: &str) -> ReplyEvent {
ReplyEvent::TextDelta { text: t.to_owned() }
}
fn tool(l: &str) -> ReplyEvent {
ReplyEvent::ToolActivity { label: l.to_owned() }
}
fn final_(c: &str) -> ReplyEvent {
ReplyEvent::Final { content: c.to_owned() }
}
// ---------------------------------------------------------------------------
// Cas nominal
// ---------------------------------------------------------------------------
#[tokio::test]
async fn returns_final_content_ignoring_deltas_and_tools() {
let session = ScriptedSession::new(Script::Stream(vec![
delta("hel"),
tool("reads a file"),
delta("lo"),
final_("hello world"),
]));
let out = send_blocking(&session, "ping", None).await;
assert_eq!(out, Ok("hello world".to_owned()));
// Rendez-vous synchrone : on ne tue pas la session sur succès.
assert_eq!(session.shutdown_count(), 0);
}
#[tokio::test]
async fn returns_final_content_with_no_intermediate_events() {
// Flux = juste le Final déterministe.
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
let out = send_blocking(&session, "x", Some(Duration::from_secs(5))).await;
assert_eq!(out, Ok("done".to_owned()));
}
#[tokio::test]
async fn stops_at_first_final_even_if_more_events_follow() {
// Robustesse : le drain s'arrête au PREMIER Final et renvoie son contenu.
let session = ScriptedSession::new(Script::Stream(vec![
delta("a"),
final_("first"),
final_("second-should-be-ignored"),
]));
let out = send_blocking(&session, "x", None).await;
assert_eq!(out, Ok("first".to_owned()));
}
// ---------------------------------------------------------------------------
// Bord : flux sans Final
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stream_without_final_is_io_error() {
let session = ScriptedSession::new(Script::Stream(vec![delta("a"), tool("b")]));
let out = send_blocking(&session, "x", None).await;
assert!(
matches!(out, Err(AgentSessionError::Io(_))),
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
);
}
#[tokio::test]
async fn empty_stream_is_io_error() {
let session = ScriptedSession::new(Script::Stream(vec![]));
let out = send_blocking(&session, "x", None).await;
assert!(
matches!(out, Err(AgentSessionError::Io(_))),
"flux vide ⇒ Io, obtenu {out:?}"
);
}
// ---------------------------------------------------------------------------
// Bord : erreur de `send` propagée
// ---------------------------------------------------------------------------
#[tokio::test]
async fn send_decode_error_is_propagated() {
let session =
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
let out = send_blocking(&session, "x", None).await;
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
}
#[tokio::test]
async fn send_io_error_is_propagated() {
let session =
ScriptedSession::new(Script::Err(AgentSessionError::Io("broken pipe".to_owned())));
let out = send_blocking(&session, "x", Some(Duration::from_secs(5))).await;
assert_eq!(out, Err(AgentSessionError::Io("broken pipe".to_owned())));
}
// ---------------------------------------------------------------------------
// Bord : timeout — Timeout renvoyé ET session NON tuée
// ---------------------------------------------------------------------------
#[tokio::test]
async fn timeout_returns_timeout_error_and_does_not_kill_session() {
// `send` dort 1 s ; timeout fixé à 20 ms ⇒ l'attente expire avant le Final.
let session = ScriptedSession::new(Script::Delayed {
delay: Duration::from_secs(1),
events: vec![final_("too late")],
});
let out = send_blocking(&session, "x", Some(Duration::from_millis(20))).await;
assert_eq!(out, Err(AgentSessionError::Timeout));
// §17.1 : on ne `shutdown` rien sur timeout — la session reste vivante.
assert_eq!(
session.shutdown_count(),
0,
"timeout ne doit PAS tuer la session"
);
}
#[tokio::test]
async fn no_timeout_bound_waits_for_final() {
// `timeout = None` ⇒ pas de borne : on attend le Final même après un délai.
let session = ScriptedSession::new(Script::Delayed {
delay: Duration::from_millis(10),
events: vec![final_("eventually")],
});
let out = send_blocking(&session, "x", None).await;
assert_eq!(out, Ok("eventually".to_owned()));
}

View File

@ -0,0 +1,305 @@
//! LOT D1 (ARCHITECTURE §17.5 / §17.9 ligne D1) — tests unitaires des registres
//! structurés et de l'agrégateur de liveness, **100 % fakes**.
//!
//! Couvre :
//! - [`StructuredSessions`] : `insert` + résolution `session_for_agent` /
//! `session_id_for_agent` / `node_for_agent` / `session` (`None` si inconnu) ;
//! invariant « 1 session vivante par agent » ; `rebind_agent_node` (change la
//! cellule, pas la session) ; `remove` (retire + renvoie la session) ;
//! `live_agents` (triplets `(AgentId, NodeId, SessionId)`) ; impl
//! [`LiveAgentRegistry`] (`is_agent_live` / `is_node_live`).
//! - [`LiveSessions`] (agrégateur) : OR des deux registres pour `is_agent_live` /
//! `is_node_live` ; `live_agents` agrège (PTY puis structuré) ;
//! `node_for_agent` / `session_id_for_agent` trouvent dans l'un ou l'autre.
//!
//! Style calqué sur `terminal_usecases.rs` (constructeurs validants du domaine,
//! pas de littéraux fragiles). Le fake `AgentSession` est local et minimal :
//! ces tests n'exercent QUE le registre, pas `send`.
use std::sync::Arc;
use async_trait::async_trait;
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
use domain::ports::{
AgentSession, AgentSessionError, PtyHandle, ReplyStream,
};
use domain::{
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
};
use uuid::Uuid;
// --- petits constructeurs déterministes ------------------------------------
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
/// Fake minimal d'`AgentSession` : porte juste l'id (ce que le registre clé).
/// `send`/`shutdown` ne sont pas exercés ici.
struct FakeSession {
id: SessionId,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let stream: ReplyStream = Box::new(std::iter::empty());
Ok(stream)
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
fn fake(id: SessionId) -> Arc<dyn AgentSession> {
Arc::new(FakeSession { id })
}
// ===========================================================================
// StructuredSessions
// ===========================================================================
#[test]
fn structured_insert_resolve_and_remove() {
let reg = StructuredSessions::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
let s = sid(1);
let a = aid(10);
let n = nid(100);
reg.insert(fake(s), a, n);
assert_eq!(reg.len(), 1);
assert!(!reg.is_empty());
// Résolution par id de session.
assert!(reg.session(&s).is_some());
assert_eq!(reg.session(&s).unwrap().id(), s);
// Résolution par agent.
assert_eq!(reg.session_id_for_agent(&a), Some(s));
assert_eq!(reg.node_for_agent(&a), Some(n));
assert!(reg.session_for_agent(&a).is_some());
assert_eq!(reg.session_for_agent(&a).unwrap().id(), s);
// Inconnu ⇒ None partout.
assert!(reg.session(&sid(999)).is_none());
assert!(reg.session_for_agent(&aid(999)).is_none());
assert!(reg.session_id_for_agent(&aid(999)).is_none());
assert!(reg.node_for_agent(&aid(999)).is_none());
// remove retire ET renvoie la session.
let removed = reg.remove(&s).expect("remove returns the session");
assert_eq!(removed.id(), s);
assert!(reg.is_empty());
assert!(reg.remove(&s).is_none(), "second remove is a no-op");
assert!(reg.session_for_agent(&a).is_none());
}
#[test]
fn structured_live_agents_lists_triples() {
let reg = StructuredSessions::new();
reg.insert(fake(sid(1)), aid(10), nid(100));
reg.insert(fake(sid(2)), aid(20), nid(200));
let mut live = reg.live_agents();
live.sort_by_key(|(a, _, _)| a.as_uuid());
assert_eq!(
live,
vec![
(aid(10), nid(100), sid(1)),
(aid(20), nid(200), sid(2)),
]
);
}
#[test]
fn structured_one_live_session_per_agent_invariant() {
// L'agent n'a pas de session vivante avant insertion.
let reg = StructuredSessions::new();
let a = aid(10);
assert!(!reg.is_agent_live(&a));
assert!(reg.session_for_agent(&a).is_none());
reg.insert(fake(sid(1)), a, nid(100));
assert!(reg.is_agent_live(&a));
// `session_for_agent` est non ambigu : il rend LA session de l'agent.
let resolved = reg.session_for_agent(&a).unwrap().id();
assert_eq!(resolved, sid(1));
// Et un seul triplet figure pour cet agent dans live_agents.
let count = reg.live_agents().iter().filter(|(x, _, _)| *x == a).count();
assert_eq!(count, 1, "one live session per agent");
}
#[test]
fn structured_rebind_changes_cell_not_session() {
let reg = StructuredSessions::new();
let a = aid(10);
reg.insert(fake(sid(1)), a, nid(100));
let new_cell = nid(200);
let rebound = reg
.rebind_agent_node(&a, new_cell)
.expect("rebind returns the session");
// Session inchangée (même id), cellule mise à jour.
assert_eq!(rebound.id(), sid(1));
assert_eq!(reg.node_for_agent(&a), Some(new_cell));
assert_eq!(reg.session_id_for_agent(&a), Some(sid(1)));
// Toujours une seule entrée (pas de duplication).
assert_eq!(reg.len(), 1);
// Rebind d'un agent inconnu ⇒ None.
assert!(reg.rebind_agent_node(&aid(999), nid(300)).is_none());
}
#[test]
fn structured_live_agent_registry_impl() {
let reg = StructuredSessions::new();
let a = aid(10);
let n = nid(100);
assert!(!reg.is_agent_live(&a));
assert!(!reg.is_node_live(&n));
reg.insert(fake(sid(1)), a, n);
assert!(reg.is_agent_live(&a));
assert!(reg.is_node_live(&n));
assert!(!reg.is_agent_live(&aid(999)));
assert!(!reg.is_node_live(&nid(999)));
// is_node_live suit le rebind (la cellule vivante change).
let _ = reg.rebind_agent_node(&a, nid(200));
assert!(!reg.is_node_live(&n), "old cell no longer live");
assert!(reg.is_node_live(&nid(200)), "new cell is live");
}
#[test]
fn structured_sessions_snapshot_for_global_shutdown() {
let reg = StructuredSessions::new();
reg.insert(fake(sid(1)), aid(10), nid(100));
reg.insert(fake(sid(2)), aid(20), nid(200));
let mut ids: Vec<SessionId> = reg.sessions().iter().map(|s| s.id()).collect();
ids.sort_by_key(|s| s.as_uuid());
assert_eq!(ids, vec![sid(1), sid(2)]);
}
// ===========================================================================
// LiveSessions — agrégateur (PTY OR structuré)
// ===========================================================================
/// Insère un agent PTY dans `TerminalSessions`.
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
let session = TerminalSession::starting(
s,
n,
ProjectPath::new("/p").unwrap(),
SessionKind::Agent { agent_id: a },
PtySize::new(24, 80).unwrap(),
);
pty.insert(PtyHandle { session_id: s }, session);
}
#[test]
fn aggregator_agent_live_via_structured_only() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
let a = aid(10);
structured.insert(fake(sid(1)), a, nid(100));
assert!(agg.is_agent_live(&a), "live in structured ⇒ true");
assert!(agg.is_node_live(&nid(100)));
assert_eq!(agg.session_id_for_agent(&a), Some(sid(1)));
assert_eq!(agg.node_for_agent(&a), Some(nid(100)));
}
#[test]
fn aggregator_agent_live_via_pty_only() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
let a = aid(20);
insert_pty(&pty, sid(2), a, nid(200));
assert!(agg.is_agent_live(&a), "live in PTY ⇒ true");
assert!(agg.is_node_live(&nid(200)));
assert_eq!(agg.session_id_for_agent(&a), Some(sid(2)));
assert_eq!(agg.node_for_agent(&a), Some(nid(200)));
}
#[test]
fn aggregator_agent_absent_from_both_is_not_live() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let agg = LiveSessions::new(pty, structured);
let a = aid(30);
assert!(!agg.is_agent_live(&a), "absent from both ⇒ false");
assert!(!agg.is_node_live(&nid(300)));
assert!(agg.session_id_for_agent(&a).is_none());
assert!(agg.node_for_agent(&a).is_none());
assert!(agg.live_agents().is_empty());
}
#[test]
fn aggregator_live_agents_concatenates_pty_then_structured() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
insert_pty(&pty, sid(1), aid(10), nid(100));
structured.insert(fake(sid(2)), aid(20), nid(200));
let all = agg.live_agents();
assert_eq!(all.len(), 2, "both registries contribute");
// PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur).
assert_eq!(all[0], (aid(10), nid(100), sid(1)));
assert_eq!(all[1], (aid(20), nid(200), sid(2)));
}
#[test]
fn aggregator_resolution_prefers_pty_then_falls_back_to_structured() {
// Deux agents distincts, un par registre : la résolution trouve chacun
// dans le bon registre (OR / fallback).
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
let pty_agent = aid(10);
let struct_agent = aid(20);
insert_pty(&pty, sid(1), pty_agent, nid(100));
structured.insert(fake(sid(2)), struct_agent, nid(200));
// Agent PTY : résolu par le registre PTY.
assert_eq!(agg.session_id_for_agent(&pty_agent), Some(sid(1)));
assert_eq!(agg.node_for_agent(&pty_agent), Some(nid(100)));
// Agent structuré : fallback sur le registre structuré.
assert_eq!(agg.session_id_for_agent(&struct_agent), Some(sid(2)));
assert_eq!(agg.node_for_agent(&struct_agent), Some(nid(200)));
// is_node_live : OR sur les deux.
assert!(agg.is_node_live(&nid(100)));
assert!(agg.is_node_live(&nid(200)));
assert!(!agg.is_node_live(&nid(999)));
}