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:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View 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());
}
}