Files
IdeA/crates/infrastructure/src/conversation/mod.rs

312 lines
12 KiB
Rust

//! [`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,
};
use domain::ids::ProjectId;
/// 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<(ProjectId, 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,
project_id: ProjectId,
a: ConversationParty,
b: ConversationParty,
) -> Conversation {
let key = pair_key(a, b);
let scoped_key = (project_id, key.0, key.1);
let mut inner = self.lock();
if let Some(id) = inner.by_pair.get(&scoped_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 Dormant conversation for the pair. The id is **derived
// deterministically** from the pair (`ConversationId::for_pair`, ARCHITECTURE
// §19.7) so the **same pair always yields the same id across registry instances**
// (i.e. it survives an IDE restart with an empty `by_pair`/`by_id`). This keeps
// the persistence key stable and aligned with `LaunchAgent` (P8a) / the
// `resolve_conversation` fallback. The pair is valid by construction at call
// sites; `try_new` still guards the invariants.
let id = ConversationId::for_project_pair(project_id, key.0, key.1);
let conv = Conversation::try_new(id, key.0, key.1)
.expect("pair_key yields a valid distinct/≤1-user pair");
inner.by_pair.insert(scoped_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)))
}
fn project(n: u128) -> ProjectId {
ProjectId::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(project(1), ConversationParty::User, agent(1));
assert_eq!(reg.len(), 1);
// Same pair ⇒ same id, no new conversation created.
let c2 = reg.resolve(project(1), 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(project(1), agent(1), agent(2));
let c2 = reg.resolve(project(1), 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(project(1), ConversationParty::User, agent(2));
let a_b = reg.resolve(project(1), 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(project(1), 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(project(1), 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());
}
// ---------------------------------------------------------------------------
// Bloc 1 — déterminisme du registre (§19 : la clé de persistance = id de paire,
// déterministe et **stable au redémarrage**). Scelle l'invariant que Main a
// rattrapé : deux instances neuves du registre (= deux démarrages de l'IDE)
// doivent dériver **le même** id pour la même paire, et cet id doit être
// **exactement** `ConversationId::for_pair`, sinon la clé sous laquelle le
// handoff/log est rangé dériverait au redémarrage.
// ---------------------------------------------------------------------------
#[test]
fn resolve_is_stable_across_registry_restart() {
// Deux instances neuves (by_pair/by_id vides) simulent deux démarrages de
// l'IDE : la même paire User↔Agent doit produire le **même** id.
let a = agent(7);
let first = InMemoryConversationRegistry::new()
.resolve(project(1), ConversationParty::User, a)
.id;
let second = InMemoryConversationRegistry::new()
.resolve(project(1), ConversationParty::User, a)
.id;
assert_eq!(
first, second,
"id de paire stable au redémarrage (registre neuf ⇒ même id)"
);
}
#[test]
fn resolve_is_stable_across_registry_restart_agent_agent() {
// Même garantie pour une paire Agent↔Agent (dérivation XOR commutative).
let x = agent(11);
let y = agent(13);
let first = InMemoryConversationRegistry::new()
.resolve(project(1), x, y)
.id;
let second = InMemoryConversationRegistry::new()
.resolve(project(1), y, x)
.id;
assert_eq!(
first, second,
"id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre"
);
}
#[test]
fn resolve_id_equals_for_pair_user_agent() {
// Alignement de clé : l'id que le registre matérialise == le repli pur
// `for_pair` == `from_uuid(agent)` (la clé que P8a/`resolve_conversation`
// dérivent pour la paire canonique User↔Agent).
let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42));
let party = ConversationParty::agent(agent_id);
let resolved = InMemoryConversationRegistry::new()
.resolve(project(1), ConversationParty::User, party)
.id;
assert_eq!(
resolved,
ConversationId::for_project_pair(project(1), ConversationParty::User, party),
"resolve == for_pair (alignement de clé)"
);
}
#[test]
fn resolve_id_equals_for_pair_agent_agent_commutative() {
// Commutativité et alignement sur `for_pair` pour Agent↔Agent.
let x = agent(101);
let y = agent(202);
let reg = InMemoryConversationRegistry::new();
let id_xy = reg.resolve(project(1), x, y).id;
let id_yx = reg.resolve(project(1), y, x).id;
assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)");
assert_eq!(
id_xy,
ConversationId::for_project_pair(project(1), x, y),
"resolve == for_pair (Agent↔Agent)"
);
assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}");
}
#[test]
fn distinct_pairs_yield_distinct_ids_across_kinds() {
// Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé).
let reg = InMemoryConversationRegistry::new();
let user_a = reg
.resolve(project(1), ConversationParty::User, agent(1))
.id;
let user_b = reg
.resolve(project(1), ConversationParty::User, agent(2))
.id;
let a_b = reg.resolve(project(1), agent(1), agent(2)).id;
assert_ne!(user_a, user_b, "User↔A ≠ User↔B");
assert_ne!(user_a, a_b, "User↔A ≠ A↔B");
assert_ne!(user_b, a_b, "User↔B ≠ A↔B");
}
#[test]
fn same_agent_ids_in_distinct_projects_get_distinct_threads() {
let reg = InMemoryConversationRegistry::new();
let p1 = reg.resolve(project(1), ConversationParty::User, agent(7));
let p2 = reg.resolve(project(2), ConversationParty::User, agent(7));
assert_ne!(p1.id, p2.id);
assert_eq!(reg.len(), 2);
}
}