Le handle de limite de session ne se déclenchait pas quand un agent directement adressé (dont Main) touchait sa propre limite : le ReplyEvent ::RateLimited du chemin structuré direct n'était pas relayé au service de limite. On tap désormais ReplyEvent::RateLimited dans le registre terminal vers SessionLimitService::on_rate_limited, qui émet AgentRateLimited puis AgentResumeScheduled et arme la reprise auto annulable, exactement comme le chemin délégué. Couverture QA (sortie réelle) : structured_registry_d1 12 passed, session_limit_wiring 6 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
11 KiB
Rust
331 lines
11 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,
|
|
conversation_id: Option<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for FakeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
fn conversation_id(&self) -> Option<String> {
|
|
self.conversation_id.clone()
|
|
}
|
|
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,
|
|
conversation_id: None,
|
|
})
|
|
}
|
|
|
|
fn fake_with_conversation(id: SessionId, conversation_id: &str) -> Arc<dyn AgentSession> {
|
|
Arc::new(FakeSession {
|
|
id,
|
|
conversation_id: Some(conversation_id.to_owned()),
|
|
})
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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_meta_for_session_resolves_agent_node_and_conversation() {
|
|
// LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver
|
|
// l'agent + la cellule hôte à passer au service de limite. Lookup direct par id.
|
|
let reg = StructuredSessions::new();
|
|
let s = sid(1);
|
|
let a = aid(10);
|
|
let n = nid(100);
|
|
reg.insert(fake_with_conversation(s, "conv-live"), a, n);
|
|
|
|
assert_eq!(
|
|
reg.meta_for_session(&s),
|
|
Some((a, n, Some("conv-live".to_owned())))
|
|
);
|
|
|
|
// Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte).
|
|
assert_eq!(reg.meta_for_session(&sid(999)), None);
|
|
reg.remove(&s);
|
|
assert_eq!(reg.meta_for_session(&s), 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)));
|
|
}
|