feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
431
crates/domain/src/conversation.rs
Normal file
431
crates/domain/src/conversation.rs
Normal file
@ -0,0 +1,431 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user