Files
IdeA/crates/application/tests/structured_registry_d1.rs
Blomios 5e10b5eb42 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>
2026-06-09 17:54:48 +02:00

306 lines
10 KiB
Rust

//! 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)));
}