//! Integration test for the **session-limit** wiring in the composition root //! (ARCHITECTURE §21, LS7). //! //! Proves that [`AppState`] actually *constructs and wires* the //! `SessionLimitService` (the gap LS7 closes: the LS1–LS4 domain/application //! machinery existed but was never instantiated at runtime). The detect→plan→ //! resume→cancel logic itself is covered by the application/infrastructure unit //! tests (LS3/LS4); here we assert the **composition** the commands rely on: //! the service is present, `on_rate_limited` arms a cancellable resume that emits //! the domain events on the real bus, and `cancel_resume` is a clean no-op for an //! unknown agent (never a panic). use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use app_tauri_lib::state::AppState; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ids::{NodeId, SessionId}; use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream}; use domain::AgentId; use infrastructure::UuidGenerator; fn temp_path(tag: &str) -> PathBuf { let ids = UuidGenerator::new(); std::env::temp_dir().join(format!("idea-sl-test-{tag}-{}", ids.new_uuid())) } fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn node(n: u128) -> NodeId { NodeId::from_uuid(uuid::Uuid::from_u128(n)) } fn session(n: u128) -> SessionId { SessionId::from_uuid(uuid::Uuid::from_u128(n)) } struct LiveStructuredSession { id: SessionId, conversation_id: Option, } #[async_trait] impl AgentSession for LiveStructuredSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { self.conversation_id.clone() } async fn send(&self, _prompt: &str) -> Result { Ok(Box::new(std::iter::empty())) } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } } /// `cancel_resume` on an agent with no armed resume returns `false` (and never /// panics): the service is wired and behaves as a clean no-op. #[tokio::test] async fn cancel_resume_is_a_clean_noop_for_unknown_agent() { let state = AppState::build(temp_path("appdata")); assert!( !state.session_limit_service.cancel_resume(agent(1)), "no armed resume ⇒ cancel returns false" ); } /// A structured rate-limit signal with a **future** reset arms a cancellable /// resume: the wired service publishes `AgentRateLimited` then /// `AgentResumeScheduled` on the **real** event bus, and `cancel_resume` then /// publishes `AgentResumeCancelled` and returns `true`. #[tokio::test] async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() { let state = AppState::build(temp_path("appdata")); let mut rx = state.event_bus.raw_receiver(); // Reset far in the future so the scheduler does NOT fire before we cancel. let resets_at_ms = i64::MAX; state .session_limit_service .on_rate_limited(agent(7), node(70), None, Some(resets_at_ms)); // The two arming events, in order, on the bus. let mut saw_rate_limited = false; let mut saw_scheduled = false; for _ in 0..32 { match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await { Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => { saw_rate_limited = true; } Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => { saw_scheduled = true; break; } Ok(Ok(_)) => continue, _ => break, } } assert!(saw_rate_limited, "AgentRateLimited relayed on the bus"); assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus"); // The window is cancellable: cancel succeeds and emits AgentResumeCancelled. assert!( state.session_limit_service.cancel_resume(agent(7)), "an armed resume is cancellable" ); let mut saw_cancelled = false; for _ in 0..32 { match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await { Ok(Ok(DomainEvent::AgentResumeCancelled { agent_id })) if agent_id == agent(7) => { saw_cancelled = true; break; } Ok(Ok(_)) => continue, _ => break, } } assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus"); } /// B1/B2 direct : le tap structuré doit résoudre `agent_id` + `node_id` + /// `conversation_id` depuis le registre vivant avant d'appeler `on_rate_limited`. /// Avec un reset connu et une conversation moteur connue, le service publie /// `AgentRateLimited` puis `AgentResumeScheduled`. #[tokio::test] async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() { let state = AppState::build(temp_path("appdata")); let mut rx = state.event_bus.raw_receiver(); let sid = session(707); state.structured_sessions.insert( Arc::new(LiveStructuredSession { id: sid, conversation_id: Some("engine-main".to_owned()), }), agent(7), node(70), ); let (agent_id, node_id, conversation_id) = state .structured_sessions .meta_for_session(&sid) .expect("live structured session metadata"); assert_eq!(agent_id, agent(7)); assert_eq!(node_id, node(70)); assert_eq!(conversation_id.as_deref(), Some("engine-main")); let resets_at_ms = i64::MAX; state.session_limit_service.on_rate_limited( agent_id, node_id, conversation_id, Some(resets_at_ms), ); let mut saw_rate_limited = false; let mut saw_scheduled = false; for _ in 0..32 { match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await { Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => { saw_rate_limited = true; } Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => { saw_scheduled = true; break; } Ok(Ok(_)) => continue, _ => break, } } assert!( saw_rate_limited, "AgentRateLimited emitted for direct agent" ); assert!( saw_scheduled, "AgentResumeScheduled emitted for direct agent" ); assert!(state.session_limit_service.cancel_resume(agent(7))); } /// B1/B2 direct : si le moteur donne une heure de reset mais que la session vivante /// ne porte pas de `conversation_id`, la commande doit normaliser vers le fallback /// humain plutôt que d'armer une reprise impossible à reprendre. #[tokio::test] async fn direct_structured_rate_limit_without_conversation_uses_human_fallback() { let state = AppState::build(temp_path("appdata")); let mut rx = state.event_bus.raw_receiver(); let sid = session(708); state.structured_sessions.insert( Arc::new(LiveStructuredSession { id: sid, conversation_id: None, }), agent(7), node(70), ); let (agent_id, node_id, conversation_id) = state .structured_sessions .meta_for_session(&sid) .expect("live structured session metadata"); assert_eq!(conversation_id, None); let resets_at_ms = Some(i64::MAX); let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) { (Some(conversation_id), resets_at_ms) => (Some(conversation_id), resets_at_ms), (None, None) => (None, None), (None, Some(_)) => (None, None), }; state .session_limit_service .on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms); let mut saw_rate_limited_none = false; let mut saw_suspected = false; let mut saw_scheduled = false; for _ in 0..32 { match tokio::time::timeout(Duration::from_millis(50), rx.recv()).await { Ok(Ok(DomainEvent::AgentRateLimited { agent_id, resets_at_ms: None, })) if agent_id == agent(7) => saw_rate_limited_none = true, Ok(Ok(DomainEvent::AgentRateLimitSuspected { agent_id, .. })) if agent_id == agent(7) => { saw_suspected = true; } Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => { saw_scheduled = true; } Ok(Ok(_)) => continue, _ => break, } } assert!( saw_rate_limited_none, "fallback exposes AgentRateLimited(None)" ); assert!(saw_suspected, "fallback exposes human input state"); assert!( !saw_scheduled, "no AgentResumeScheduled without engine conversation_id" ); assert!(!state.session_limit_service.cancel_resume(agent(7))); } /// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries /// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande /// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin). /// /// La commande `#[tauri::command]` exige une `State` non constructible hors /// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState` /// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément /// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428). #[tokio::test] async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() { let state = AppState::build(temp_path("appdata")); let unknown = agent(99); assert!( state.structured_sessions.node_for_agent(&unknown).is_none(), "aucune session structurée vivante ⇒ pas de cellule" ); assert!( state.terminal_sessions.node_for_agent(&unknown).is_none(), "aucune session terminal vivante ⇒ pas de cellule" ); // Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise : // la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin. let resolved = state .structured_sessions .node_for_agent(&unknown) .or_else(|| state.terminal_sessions.node_for_agent(&unknown)); assert!( resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)" ); } /// Parité runtime du filet humain : `confirm_human_resume` (la délégation de /// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited` /// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`. #[tokio::test] async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() { let state = AppState::build(temp_path("appdata")); let mut rx = state.event_bus.raw_receiver(); // Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation. let resets_at_ms = i64::MAX; state .session_limit_service .confirm_human_resume(agent(8), node(80), None, resets_at_ms); let mut saw_rate_limited = false; let mut saw_scheduled = false; for _ in 0..32 { match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await { Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(8) => { saw_rate_limited = true; } Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => { saw_scheduled = true; break; } Ok(Ok(_)) => continue, _ => break, } } assert!( saw_rate_limited, "AgentRateLimited relayed on the bus (humain)" ); assert!( saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)" ); // Annulable par la même voie que l'auto. assert!( state.session_limit_service.cancel_resume(agent(8)), "an armed human resume is cancellable" ); }