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:
@ -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);
|
||||
}
|
||||
|
||||
222
crates/application/tests/send_blocking_d1.rs
Normal file
222
crates/application/tests/send_blocking_d1.rs
Normal 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()));
|
||||
}
|
||||
305
crates/application/tests/structured_registry_d1.rs
Normal file
305
crates/application/tests/structured_registry_d1.rs
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user