//! 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), /// `send` échoue avec cette erreur. Err(AgentSessionError), /// `send` dort `delay` avant de renvoyer le flux (force le timeout). Delayed { delay: Duration, events: Vec, }, } /// Fake scriptable d'`AgentSession`. Mono-usage (un `send` scripté). struct ScriptedSession { id: SessionId, script: std::sync::Mutex>, 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 { None } async fn send(&self, _prompt: &str) -> Result { 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())); }