//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2). //! //! The driven side of conversation-by-pair: a `HashMap` //! plus a pair→id index, so `resolve` is **lazy get-or-create** and the same //! unordered pair `{a, b}` always maps to the same [`ConversationId`]. //! //! ## Concurrency //! //! A single **synchronous** [`Mutex`] guards both maps; it is held only for the O(1) //! lookups/mutations and **never across an `.await`** (this registry is fully sync, //! cf. the `ask_locks` discipline of `service.rs`). use std::collections::HashMap; use std::sync::Mutex; use domain::conversation::{ Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession, SessionRef, }; use domain::ids::ProjectId; /// Order-insensitive key for a conversation pair `{a, b}`. /// /// Normalised so that `{a, b}` and `{b, a}` hash/compare equal — this is what makes /// `resolve(a, b)` and `resolve(b, a)` resolve to the same conversation. fn pair_key(a: ConversationParty, b: ConversationParty) -> (ConversationParty, ConversationParty) { // Stable, total ordering over parties so the smaller end is always `left` in the // key. `User` sorts before any agent; agents order by their UUID. fn rank(p: ConversationParty) -> (u8, u128) { match p { ConversationParty::User => (0, 0), ConversationParty::Agent { agent_id } => (1, agent_id.as_uuid().as_u128()), } } if rank(a) <= rank(b) { (a, b) } else { (b, a) } } /// In-memory, pair-keyed conversation registry (the production [`ConversationRegistry`]). #[derive(Default)] pub struct InMemoryConversationRegistry { inner: Mutex, } #[derive(Default)] struct Inner { by_id: HashMap, by_pair: HashMap<(ProjectId, ConversationParty, ConversationParty), ConversationId>, } impl InMemoryConversationRegistry { /// Creates an empty registry. #[must_use] pub fn new() -> Self { Self { inner: Mutex::new(Inner::default()), } } /// Number of conversations currently held (test/inspection helper). #[must_use] pub fn len(&self) -> usize { self.lock().by_id.len() } /// Whether the registry is empty. #[must_use] pub fn is_empty(&self) -> bool { self.lock().by_id.is_empty() } fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { self.inner .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } } impl ConversationRegistry for InMemoryConversationRegistry { fn resolve( &self, project_id: ProjectId, a: ConversationParty, b: ConversationParty, ) -> Conversation { let key = pair_key(a, b); let scoped_key = (project_id, key.0, key.1); let mut inner = self.lock(); if let Some(id) = inner.by_pair.get(&scoped_key).copied() { // Existing thread for this pair — return its current snapshot. return inner .by_id .get(&id) .cloned() .expect("by_pair id always present in by_id"); } // Lazy create: mint a Dormant conversation for the pair. The id is **derived // deterministically** from the pair (`ConversationId::for_pair`, ARCHITECTURE // §19.7) so the **same pair always yields the same id across registry instances** // (i.e. it survives an IDE restart with an empty `by_pair`/`by_id`). This keeps // the persistence key stable and aligned with `LaunchAgent` (P8a) / the // `resolve_conversation` fallback. The pair is valid by construction at call // sites; `try_new` still guards the invariants. let id = ConversationId::for_project_pair(project_id, key.0, key.1); let conv = Conversation::try_new(id, key.0, key.1) .expect("pair_key yields a valid distinct/≤1-user pair"); inner.by_pair.insert(scoped_key, id); inner.by_id.insert(id, conv.clone()); conv } fn bind_session(&self, id: ConversationId, session: SessionRef) { let mut inner = self.lock(); if let Some(conv) = inner.by_id.get_mut(&id) { conv.session = ConversationSession::Live { handle_ref: session, }; } } fn suspend(&self, id: ConversationId, resumable_id: Option) { let mut inner = self.lock(); if let Some(conv) = inner.by_id.get_mut(&id) { conv.session = ConversationSession::Dormant; conv.resumable_id = resumable_id; } } fn get(&self, id: ConversationId) -> Option { self.lock().by_id.get(&id).cloned() } } #[cfg(test)] mod tests { use super::*; use domain::ids::{AgentId, SessionId}; fn agent(n: u128) -> ConversationParty { ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) } fn project(n: u128) -> ProjectId { ProjectId::from_uuid(uuid::Uuid::from_u128(n)) } #[test] fn resolve_is_lazy_get_or_create() { let reg = InMemoryConversationRegistry::new(); assert!(reg.is_empty()); let c = reg.resolve(project(1), ConversationParty::User, agent(1)); assert_eq!(reg.len(), 1); // Same pair ⇒ same id, no new conversation created. let c2 = reg.resolve(project(1), ConversationParty::User, agent(1)); assert_eq!(c.id, c2.id); assert_eq!(reg.len(), 1); } #[test] fn same_pair_unordered_yields_same_id() { let reg = InMemoryConversationRegistry::new(); let c1 = reg.resolve(project(1), agent(1), agent(2)); let c2 = reg.resolve(project(1), agent(2), agent(1)); // swapped order assert_eq!(c1.id, c2.id, "unordered pair identity"); assert_eq!(reg.len(), 1); } #[test] fn distinct_pairs_get_distinct_ids() { let reg = InMemoryConversationRegistry::new(); let user_b = reg.resolve(project(1), ConversationParty::User, agent(2)); let a_b = reg.resolve(project(1), agent(1), agent(2)); assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads"); assert_eq!(reg.len(), 2); } #[test] fn fresh_resolve_is_dormant() { let reg = InMemoryConversationRegistry::new(); let c = reg.resolve(project(1), ConversationParty::User, agent(1)); assert_eq!(c.session, ConversationSession::Dormant); } #[test] fn bind_session_makes_it_live_then_suspend_restores_dormant() { let reg = InMemoryConversationRegistry::new(); let c = reg.resolve(project(1), ConversationParty::User, agent(1)); let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99))); reg.bind_session(c.id, sref); let live = reg.get(c.id).unwrap(); assert!(live.session.is_live()); assert_eq!(live.session, ConversationSession::Live { handle_ref: sref }); reg.suspend(c.id, Some("sess-abc".to_owned())); let dormant = reg.get(c.id).unwrap(); assert_eq!(dormant.session, ConversationSession::Dormant); assert_eq!(dormant.resumable_id.as_deref(), Some("sess-abc")); } #[test] fn get_unknown_is_none() { let reg = InMemoryConversationRegistry::new(); assert!(reg.get(ConversationId::new_random()).is_none()); } // --------------------------------------------------------------------------- // Bloc 1 — déterminisme du registre (§19 : la clé de persistance = id de paire, // déterministe et **stable au redémarrage**). Scelle l'invariant que Main a // rattrapé : deux instances neuves du registre (= deux démarrages de l'IDE) // doivent dériver **le même** id pour la même paire, et cet id doit être // **exactement** `ConversationId::for_pair`, sinon la clé sous laquelle le // handoff/log est rangé dériverait au redémarrage. // --------------------------------------------------------------------------- #[test] fn resolve_is_stable_across_registry_restart() { // Deux instances neuves (by_pair/by_id vides) simulent deux démarrages de // l'IDE : la même paire User↔Agent doit produire le **même** id. let a = agent(7); let first = InMemoryConversationRegistry::new() .resolve(project(1), ConversationParty::User, a) .id; let second = InMemoryConversationRegistry::new() .resolve(project(1), ConversationParty::User, a) .id; assert_eq!( first, second, "id de paire stable au redémarrage (registre neuf ⇒ même id)" ); } #[test] fn resolve_is_stable_across_registry_restart_agent_agent() { // Même garantie pour une paire Agent↔Agent (dérivation XOR commutative). let x = agent(11); let y = agent(13); let first = InMemoryConversationRegistry::new() .resolve(project(1), x, y) .id; let second = InMemoryConversationRegistry::new() .resolve(project(1), y, x) .id; assert_eq!( first, second, "id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre" ); } #[test] fn resolve_id_equals_for_pair_user_agent() { // Alignement de clé : l'id que le registre matérialise == le repli pur // `for_pair` == `from_uuid(agent)` (la clé que P8a/`resolve_conversation` // dérivent pour la paire canonique User↔Agent). let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42)); let party = ConversationParty::agent(agent_id); let resolved = InMemoryConversationRegistry::new() .resolve(project(1), ConversationParty::User, party) .id; assert_eq!( resolved, ConversationId::for_project_pair(project(1), ConversationParty::User, party), "resolve == for_pair (alignement de clé)" ); } #[test] fn resolve_id_equals_for_pair_agent_agent_commutative() { // Commutativité et alignement sur `for_pair` pour Agent↔Agent. let x = agent(101); let y = agent(202); let reg = InMemoryConversationRegistry::new(); let id_xy = reg.resolve(project(1), x, y).id; let id_yx = reg.resolve(project(1), y, x).id; assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)"); assert_eq!( id_xy, ConversationId::for_project_pair(project(1), x, y), "resolve == for_pair (Agent↔Agent)" ); assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}"); } #[test] fn distinct_pairs_yield_distinct_ids_across_kinds() { // Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé). let reg = InMemoryConversationRegistry::new(); let user_a = reg .resolve(project(1), ConversationParty::User, agent(1)) .id; let user_b = reg .resolve(project(1), ConversationParty::User, agent(2)) .id; let a_b = reg.resolve(project(1), agent(1), agent(2)).id; assert_ne!(user_a, user_b, "User↔A ≠ User↔B"); assert_ne!(user_a, a_b, "User↔A ≠ A↔B"); assert_ne!(user_b, a_b, "User↔B ≠ A↔B"); } #[test] fn same_agent_ids_in_distinct_projects_get_distinct_threads() { let reg = InMemoryConversationRegistry::new(); let p1 = reg.resolve(project(1), ConversationParty::User, agent(7)); let p2 = reg.resolve(project(2), ConversationParty::User, agent(7)); assert_ne!(p1.id, p2.id); assert_eq!(reg.len(), 2); } }