//! Conversation-by-pair domain model (cadrage « conversation par paire », lot C1/C2). //! //! A [`Conversation`] is a **thread between two distinct parties** with its own //! I/O session. Its identity is the **unordered pair** `{left, right}`: the same //! two parties always denote the same conversation (this is the key to the //! *lazy materialisation* the [`ConversationRegistry`] performs — `resolve(a, b)` //! and `resolve(b, a)` yield the same [`ConversationId`]). //! //! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no //! `std::fs`, no `std::process`. It owns the value objects and entities plus the //! [`ConversationRegistry`] port (a trait the application depends on; the infra //! provides `InMemoryConversationRegistry`). The session handle is held only as a //! [`SessionRef`] (a reference to a [`crate::terminal::TerminalSession`]); the //! domain never holds the PTY itself. //! //! ## Why this exists //! //! The previous coupling «1 live session / agent» was ambiguous once an agent can //! take part in several threads (`session-registry-agent-ambiguity`). Here the live //! session is keyed **by conversation**, so a delegation `A↔B` never borrows the //! `User↔B` thread — the context leak is closed by construction. use serde::{Deserialize, Serialize}; use crate::ids::{AgentId, SessionId}; /// Identifies one [`Conversation`]. /// /// Newtype around [`uuid::Uuid`], calqué sur [`crate::ids::AgentId`] / /// [`crate::mailbox::TicketId`]. Minted by the [`ConversationRegistry`] when a pair /// is first resolved; stable for the lifetime of that pair's thread. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct ConversationId(pub uuid::Uuid); impl ConversationId { /// Wraps an existing [`uuid::Uuid`]. #[must_use] pub const fn from_uuid(id: uuid::Uuid) -> Self { Self(id) } /// Mints a fresh random conversation id. #[must_use] pub fn new_random() -> Self { Self(uuid::Uuid::new_v4()) } /// Returns the inner [`uuid::Uuid`]. #[must_use] pub const fn as_uuid(&self) -> uuid::Uuid { self.0 } /// **Dérivation déterministe et model-agnostic** de l'id de paire pour le couple /// `{a, b}` (ARCHITECTURE §19.7). **Pure** (aucune I/O, aucun registre) et /// **insensible à l'ordre** : `for_pair(a, b) == for_pair(b, a)`. /// /// Sert de **repli stable** quand aucun [`ConversationRegistry`] (stateful) n'est /// disponible pour matérialiser le fil — typiquement le lancement **direct** d'une /// cellule structurée neuve par l'utilisateur (`LaunchAgent`, lot P8a) : la cellule /// doit porter la **même** clé que celle sous laquelle le handoff a été (ou sera) /// rangé. Pour la paire canonique `User↔Agent`, le résultat est **exactement** /// l'id du repli de `OrchestratorService::resolve_conversation(None, agent)` /// (`ConversationId::from_uuid(agent.as_uuid())`), garantissant la cohérence de /// clé end-to-end sans coupler `LaunchAgent` à l'orchestrateur ni au registre. /// /// Pour une paire `Agent↔Agent`, l'id est dérivé des deux UUID agent de façon /// commutative (XOR), stable et déterministe. #[must_use] pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self { match (a.as_agent(), b.as_agent()) { // User↔Agent (un seul agent) : aligné sur le repli de `resolve_conversation`. (Some(agent), None) | (None, Some(agent)) => Self::from_uuid(agent.as_uuid()), // Agent↔Agent : combinaison commutative des deux ids (insensible à l'ordre). (Some(x), Some(y)) => { let xor = x.as_uuid().as_u128() ^ y.as_uuid().as_u128(); Self::from_uuid(uuid::Uuid::from_u128(xor)) } // User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic. (None, None) => Self::from_uuid(uuid::Uuid::nil()), } } } impl std::fmt::Display for ConversationId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// One end of a [`Conversation`]. /// /// Invariant (enforced by [`Conversation::try_new`]): a conversation relates **two /// distinct** parties and carries **at most one** [`ConversationParty::User`] /// (never `User↔User`, never `Agent(x)↔Agent(x)`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum ConversationParty { /// The human operator — a single logical instance on IdeA's side. User, /// A project agent. #[serde(rename_all = "camelCase")] Agent { /// The agent on this end. agent_id: AgentId, }, } impl ConversationParty { /// Convenience constructor for an agent party. #[must_use] pub const fn agent(agent_id: AgentId) -> Self { Self::Agent { agent_id } } /// Returns the [`AgentId`] when this party is an agent. #[must_use] pub const fn as_agent(&self) -> Option { match self { Self::Agent { agent_id } => Some(*agent_id), Self::User => None, } } /// Whether this party is the human user. #[must_use] pub const fn is_user(&self) -> bool { matches!(self, Self::User) } } /// Errors raised when constructing a [`Conversation`]. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ConversationError { /// The two ends are the **same** party (`left == right`). #[error("a conversation must relate two distinct parties")] SameParty, /// **Both** ends are [`ConversationParty::User`] — at most one is allowed. #[error("a conversation may carry at most one User party")] TwoUsers, } /// The I/O state of a conversation thread. /// /// The domain holds only a [`SessionRef`]; the live PTY/structured stream is an /// infrastructure concern (`session-registry`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "state")] pub enum ConversationSession { /// Never launched, or suspended (only `resumable_id` is retained on the /// [`Conversation`]). Dormant, /// A live I/O stream backs this thread. #[serde(rename_all = "camelCase")] Live { /// Reference to the backing [`crate::terminal::TerminalSession`]. handle_ref: SessionRef, }, } impl ConversationSession { /// Whether the thread currently has a live session. #[must_use] pub const fn is_live(&self) -> bool { matches!(self, Self::Live { .. }) } } /// An opaque reference to a live session handle (a [`crate::terminal::TerminalSession`]). /// /// The domain never owns the PTY; it only names the session by its [`SessionId`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct SessionRef(pub SessionId); impl SessionRef { /// Wraps a [`SessionId`]. #[must_use] pub const fn new(id: SessionId) -> Self { Self(id) } /// Returns the referenced [`SessionId`]. #[must_use] pub const fn session_id(&self) -> SessionId { self.0 } } /// A thread between two distinct parties, with its own session and resumable id. /// /// Identity is the **unordered pair** `{left, right}`: see [`Conversation::same_pair`]. /// Pure value/entity — no I/O. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Conversation { /// Stable identifier (minted by the registry on first resolve). pub id: ConversationId, /// One end of the thread. pub left: ConversationParty, /// The other end of the thread. pub right: ConversationParty, /// Current I/O state. pub session: ConversationSession, /// CLI session-id to resume on restart (`suspend` stores it; `Dormant` keeps it). pub resumable_id: Option, } impl Conversation { /// Builds a `Dormant` conversation for the pair `{left, right}`. /// /// # Errors /// - [`ConversationError::SameParty`] if `left == right`. /// - [`ConversationError::TwoUsers`] if both ends are [`ConversationParty::User`]. pub fn try_new( id: ConversationId, left: ConversationParty, right: ConversationParty, ) -> Result { if left.is_user() && right.is_user() { // More specific than SameParty: "at most one User" invariant. return Err(ConversationError::TwoUsers); } if left == right { return Err(ConversationError::SameParty); } Ok(Self { id, left, right, session: ConversationSession::Dormant, resumable_id: None, }) } /// Whether this conversation relates the **same unordered pair** as `{a, b}`. /// /// `{left, right}` is order-insensitive: `same_pair(a, b) == same_pair(b, a)`. #[must_use] pub fn same_pair(&self, a: ConversationParty, b: ConversationParty) -> bool { (self.left == a && self.right == b) || (self.left == b && self.right == a) } } /// A pure wait-for graph for deadlock (cycle) detection on inter-agent delegation. /// /// Each edge `(A, B)` reads «A is waiting for B». A delegation `from → to` would /// deadlock iff `to` already (transitively) waits on `from`; [`WaitForGraph::would_cycle`] /// answers that **without** mutating the graph, so the caller can refuse the ask /// **before** posting the edge. 100 % pure and testable. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct WaitForGraph { edges: Vec<(AgentId, AgentId)>, } impl WaitForGraph { /// An empty graph. #[must_use] pub fn new() -> Self { Self { edges: Vec::new() } } /// Records that `from` waits for `to` (no-op if the edge already exists). pub fn add_edge(&mut self, from: AgentId, to: AgentId) { if !self.edges.contains(&(from, to)) { self.edges.push((from, to)); } } /// Removes the edge `from → to` (the wait resolved or timed out). pub fn remove_edge(&mut self, from: AgentId, to: AgentId) { self.edges.retain(|e| *e != (from, to)); } /// Number of recorded edges (test/inspection helper). #[must_use] pub fn len(&self) -> usize { self.edges.len() } /// Whether the graph holds no edges. #[must_use] pub fn is_empty(&self) -> bool { self.edges.is_empty() } /// Whether adding `from → to` would close a cycle in the wait-for graph. /// /// `true` when `to` already reaches `from` through existing edges (so `from` /// waiting on `to` would deadlock), or when `from == to` (self-wait). The graph /// is **not** modified. #[must_use] pub fn would_cycle(&self, from: AgentId, to: AgentId) -> bool { if from == to { return true; } // Does `to` already reach `from`? DFS over existing edges from `to`. let mut stack = vec![to]; let mut seen = vec![to]; while let Some(node) = stack.pop() { if node == from { return true; } for (a, b) in &self.edges { if *a == node && !seen.contains(b) { seen.push(*b); stack.push(*b); } } } false } } /// Lazy, get-or-create registry of conversations keyed by **unordered pair**. /// /// Pure port (no I/O); the infra adapter `InMemoryConversationRegistry` owns the /// interior mutability (`HashMap` + sync mutex, never held across an `.await`). /// Kept object-safe so the application holds it as `Arc`. pub trait ConversationRegistry: Send + Sync { /// Get-or-create: returns the thread for the pair `{a, b}`, opening it /// (`Dormant`) if it did not exist. Pure registry — opens **no** session. /// The same unordered pair always yields the same [`ConversationId`]. fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation; /// Marks the conversation `id` `Live` with the given session reference. fn bind_session(&self, id: ConversationId, session: SessionRef); /// Suspends `id` (back to `Dormant`), retaining `resumable_id` for restart. fn suspend(&self, id: ConversationId, resumable_id: Option); /// Returns the current snapshot of `id`, if it exists. fn get(&self, id: ConversationId) -> Option; } #[cfg(test)] mod tests { use super::*; fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn conv_id(n: u128) -> ConversationId { ConversationId::from_uuid(uuid::Uuid::from_u128(n)) } #[test] fn conversation_id_roundtrips_through_uuid() { let u = uuid::Uuid::from_u128(42); let id = ConversationId::from_uuid(u); assert_eq!(id.as_uuid(), u); assert_eq!(id.to_string(), u.to_string()); } #[test] fn rejects_same_party_pair() { let a = ConversationParty::agent(agent(1)); assert_eq!( Conversation::try_new(conv_id(1), a, a), Err(ConversationError::SameParty) ); } #[test] fn rejects_two_users() { assert_eq!( Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User), Err(ConversationError::TwoUsers) ); } #[test] fn accepts_user_agent_and_agent_agent() { assert!(Conversation::try_new( conv_id(1), ConversationParty::User, ConversationParty::agent(agent(1)) ) .is_ok()); assert!(Conversation::try_new( conv_id(2), ConversationParty::agent(agent(1)), ConversationParty::agent(agent(2)) ) .is_ok()); } #[test] fn fresh_conversation_is_dormant_without_resumable() { let c = Conversation::try_new( conv_id(1), ConversationParty::User, ConversationParty::agent(agent(1)), ) .unwrap(); assert_eq!(c.session, ConversationSession::Dormant); assert!(!c.session.is_live()); assert_eq!(c.resumable_id, None); } #[test] fn identity_is_the_unordered_pair() { let a = ConversationParty::agent(agent(1)); let b = ConversationParty::agent(agent(2)); let c = Conversation::try_new(conv_id(1), a, b).unwrap(); assert!(c.same_pair(a, b)); assert!(c.same_pair(b, a), "pair identity is order-insensitive"); let other = ConversationParty::agent(agent(3)); assert!(!c.same_pair(a, other)); } #[test] fn would_cycle_refuses_direct_back_edge() { // A→B exists; B→A would close the cycle. let mut g = WaitForGraph::new(); g.add_edge(agent(1), agent(2)); // A waits B assert!(g.would_cycle(agent(2), agent(1)), "B→A closes A→B→A"); } #[test] fn would_cycle_allows_linear_chain() { // A→B exists; B→C is fine (no path C→…→B). let mut g = WaitForGraph::new(); g.add_edge(agent(1), agent(2)); // A waits B assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic"); } #[test] fn would_cycle_detects_transitive_cycle() { // A→B, B→C; C→A would close A→B→C→A. let mut g = WaitForGraph::new(); g.add_edge(agent(1), agent(2)); g.add_edge(agent(2), agent(3)); assert!(g.would_cycle(agent(3), agent(1)), "transitive cycle"); } #[test] fn would_cycle_refuses_self_wait() { let g = WaitForGraph::new(); assert!(g.would_cycle(agent(1), agent(1))); } #[test] fn add_edge_is_idempotent_and_removable() { let mut g = WaitForGraph::new(); g.add_edge(agent(1), agent(2)); g.add_edge(agent(1), agent(2)); assert_eq!(g.len(), 1); g.remove_edge(agent(1), agent(2)); assert!(g.is_empty()); } }