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:
194
crates/infrastructure/src/conversation/mod.rs
Normal file
194
crates/infrastructure/src/conversation/mod.rs
Normal file
@ -0,0 +1,194 @@
|
||||
//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2).
|
||||
//!
|
||||
//! The driven side of conversation-by-pair: a `HashMap<ConversationId, Conversation>`
|
||||
//! 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,
|
||||
};
|
||||
|
||||
/// 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<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
by_id: HashMap<ConversationId, Conversation>,
|
||||
by_pair: HashMap<(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, a: ConversationParty, b: ConversationParty) -> Conversation {
|
||||
let key = pair_key(a, b);
|
||||
let mut inner = self.lock();
|
||||
if let Some(id) = inner.by_pair.get(&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 fresh Dormant conversation for the pair. The pair is
|
||||
// valid by construction at call sites (resolve is only asked for real pairs);
|
||||
// `try_new` still guards the invariants, and on the (impossible) error we fall
|
||||
// back to a normalised pair to never panic in production.
|
||||
let id = ConversationId::new_random();
|
||||
let conv = Conversation::try_new(id, key.0, key.1)
|
||||
.expect("pair_key yields a valid distinct/≤1-user pair");
|
||||
inner.by_pair.insert(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<String>) {
|
||||
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<Conversation> {
|
||||
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)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_lazy_get_or_create() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
let c = reg.resolve(ConversationParty::User, agent(1));
|
||||
assert_eq!(reg.len(), 1);
|
||||
// Same pair ⇒ same id, no new conversation created.
|
||||
let c2 = reg.resolve(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(agent(1), agent(2));
|
||||
let c2 = reg.resolve(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(ConversationParty::User, agent(2));
|
||||
let a_b = reg.resolve(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(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(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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user