Files
IdeA/crates/domain/src/conversation.rs
Blomios fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00

455 lines
16 KiB
Rust

//! 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<AgentId> {
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<String>,
}
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<Self, ConversationError> {
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<dyn ConversationRegistry>`.
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<String>);
/// Returns the current snapshot of `id`, if it exists.
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
#[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());
}
}