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

View File

@ -0,0 +1,230 @@
//! [`RwFileGuard`] — the [`FileGuard`] adapter (lot C6).
//!
//! A reader/writer lock **per [`GuardedResource`]**, backed by one
//! [`tokio::sync::RwLock`] per resource (created lazily and shared via `Arc`). N
//! concurrent readers **or** one exclusive writer per resource; different resources
//! are independent (their locks are distinct).
//!
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
//! **before** taking any lock: a `who` that is not the orchestrator
//! ([`domain::may_write_directly`] returning `false`) is rejected with
//! [`GuardError::Forbidden`] — it must *propose* instead.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! This guard serialises access **inside the IdeA path** (the MCP tools / use cases).
//! It is **cooperative**: it does not sandbox an agent that keeps a raw shell — real
//! airtightness (revoking raw fs access to these paths) is an OS-sandbox concern
//! (Landlock) and is **out of scope** here.
//!
//! ## Concurrency
//!
//! The resource → lock registry lives behind a **synchronous** [`Mutex`], held only
//! for the O(1) get-or-insert of an `Arc<RwLock<()>>` and **never across an
//! `.await`**. The actual wait happens on the tokio `RwLock`'s `owned` guard, whose
//! lifetime is `'static` (it owns its `Arc`), so it can be boxed into the domain's
//! RAII [`ReadLease`]/[`WriteLease`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::RwLock;
use domain::conversation::ConversationParty;
use domain::fileguard::{
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
};
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
///
/// Held at the composition root as `Arc<dyn FileGuard>`; cloneable bookkeeping is
/// interior (the registry is a shared `Mutex<HashMap<…>>`).
#[derive(Debug, Default)]
pub struct RwFileGuard {
/// Lazily-created per-resource locks. The unit `()` payload is irrelevant — the
/// lock's *mode* (read vs write) is the whole point; the lease only proves the
/// slot is held until drop.
locks: Mutex<HashMap<GuardedResource, Arc<RwLock<()>>>>,
}
impl RwFileGuard {
/// Builds an empty guard (no resource is contended until first acquired).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns the shared lock for `res`, creating it on first use. The registry
/// mutex is held only for this O(1) get-or-insert, never across an `.await`.
fn lock_for(&self, res: &GuardedResource) -> Arc<RwLock<()>> {
let mut registry = self
.locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
registry
.entry(res.clone())
.or_insert_with(|| Arc::new(RwLock::new(())))
.clone()
}
}
#[async_trait]
impl FileGuard for RwFileGuard {
async fn acquire_read(
&self,
_who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError> {
// Reading is never forbidden (cooperative contract): serialise behind any
// in-flight writer of the same resource, then hand back the RAII lease.
let lock = self.lock_for(&res);
let guard = lock.read_owned().await;
Ok(ReadLease::new(Box::new(guard)))
}
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
// Single-writer rule for the global project context: refuse *before* taking
// any lock so a forbidden writer never blocks readers.
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
let guard = lock.write_owned().await;
Ok(WriteLease::new(Box::new(guard)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::AgentId;
use domain::memory::MemorySlug;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
fn mem(slug: &str) -> GuardedResource {
GuardedResource::Memory(MemorySlug::new(slug).unwrap())
}
#[tokio::test]
async fn many_readers_share_one_resource_concurrently() {
let guard = RwFileGuard::new();
// Three read leases held *at once* on the same resource must all be granted.
let a = guard
.acquire_read(ConversationParty::User, mem("note"))
.await
.unwrap();
let b = guard
.acquire_read(agent_party(1), mem("note"))
.await
.unwrap();
let c = guard
.acquire_read(agent_party(2), mem("note"))
.await
.unwrap();
// If reads were exclusive this would have deadlocked above; reaching here is
// the proof. Keep the leases alive until now.
drop((a, b, c));
}
#[tokio::test]
async fn writer_excludes_other_writers_serialising_them() {
let guard = Arc::new(RwFileGuard::new());
let counter = Arc::new(AtomicUsize::new(0));
let observed_max = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let guard = Arc::clone(&guard);
let counter = Arc::clone(&counter);
let observed_max = Arc::clone(&observed_max);
handles.push(tokio::spawn(async move {
let _lease = guard
.acquire_write(ConversationParty::User, mem("shared"))
.await
.unwrap();
// While holding the write lease, at most one task may be inside.
let inside = counter.fetch_add(1, Ordering::SeqCst) + 1;
observed_max.fetch_max(inside, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await;
counter.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(
observed_max.load(Ordering::SeqCst),
1,
"a write lease must be exclusive — never two writers inside at once"
);
}
#[tokio::test]
async fn agent_writing_project_context_is_forbidden() {
let guard = RwFileGuard::new();
let err = guard
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
.await
.unwrap_err();
assert_eq!(err, GuardError::Forbidden);
}
#[tokio::test]
async fn orchestrator_may_write_project_context() {
let guard = RwFileGuard::new();
let lease = guard
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
.await;
assert!(lease.is_ok());
}
#[tokio::test]
async fn write_lease_releases_on_drop_letting_the_next_writer_in() {
let guard = Arc::new(RwFileGuard::new());
{
let _lease = guard
.acquire_write(ConversationParty::User, mem("r"))
.await
.unwrap();
// Held here; a concurrent writer would block.
} // <- RAII release at end of scope.
// After the scope, a fresh writer acquires immediately (bounded wait).
let again = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(ConversationParty::User, mem("r")),
)
.await
.expect("lease must have released on drop")
.unwrap();
drop(again);
}
#[tokio::test]
async fn distinct_resources_do_not_block_each_other() {
let guard = RwFileGuard::new();
let _w1 = guard
.acquire_write(ConversationParty::User, mem("alpha"))
.await
.unwrap();
// A writer on a *different* resource is independent — must not block.
let w2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(ConversationParty::User, mem("beta")),
)
.await
.expect("independent resources must not contend")
.unwrap();
drop(w2);
}
}

View File

@ -0,0 +1,789 @@
//! [`MediatedInbox`] — the [`InputMediator`] adapter (lot C2).
//!
//! The single convergence point of an agent's input. It **composes** the existing
//! [`InMemoryMailbox`] (FIFO + one-shot reply — the correlation engine) and adds the
//! two things the mailbox alone does not express:
//!
//! - a **busy/turn** bookkeeping per agent ([`AgentBusyState`]), so the front can be
//! told when an agent is processing;
//! - a **preempt** signal distinct from `enqueue` (Interrompre ≠ Envoyer): it does
//! **not** queue anything and correlates no ticket.
//!
//! It does **not** spawn a second queue: the FIFO is the mailbox's. The first
//! enqueue while `Idle` starts a turn (agent → `Busy`); `mark_idle` ends it and lets
//! the next ticket start. In doubt we stay `Busy` but **keep accepting** enqueues
//! (forward, never reject — cf. cadrage §6 fallback).
//!
//! ## Concurrency
//!
//! Busy state lives behind a **synchronous** [`Mutex`], held only for O(1) reads and
//! mutations and **never across an `.await`** (the await is the caller's, on the
//! returned [`PendingReply`]). The mailbox owns its own locking.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::input::{AgentBusyState, InputMediator};
use domain::mailbox::{AgentMailbox, PendingReply, Ticket};
use domain::ports::{EventBus, PtyHandle, PtyPort};
use crate::mailbox::InMemoryMailbox;
/// Shared busy/idle bookkeeping for one set of agents.
///
/// Extracted so the **prompt-ready watcher** (a detached thread observing an agent's
/// PTY output, lot C5) can flip an agent back to `Idle` without holding the whole
/// [`MediatedInbox`]: it only needs the busy map + the event bus. This is the single
/// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
struct BusyTracker {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
events: Option<Arc<dyn EventBus>>,
}
impl BusyTracker {
fn new(events: Option<Arc<dyn EventBus>>) -> Self {
Self {
busy: Mutex::new(HashMap::new()),
events,
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> {
self.busy
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.lock()
.get(&agent)
.copied()
.unwrap_or(AgentBusyState::Idle)
}
/// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually
/// started (so the caller publishes `AgentBusyChanged{busy:true}` only once).
fn start_turn(&self, agent: AgentId, state: AgentBusyState) -> bool {
let mut busy = self.lock();
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
if entry.is_busy() {
false
} else {
*entry = state;
true
}
}
/// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real
/// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a
/// no-op and emits nothing.
fn mark_idle(&self, agent: AgentId) {
let was_busy = {
let mut busy = self.lock();
busy.insert(agent, AgentBusyState::Idle)
.is_some_and(|s| s.is_busy())
};
if was_busy {
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
busy: false,
});
}
}
}
}
/// Supplies the epoch-millis stamp used for `AgentBusyState::Busy { since_ms }`.
///
/// Injectable so tests are deterministic and the adapter stays decoupled from the
/// wall clock (the composition root wires the real clock).
pub trait MillisClock: Send + Sync {
/// Current time as milliseconds since the Unix epoch.
fn now_ms(&self) -> u64;
}
/// Wall-clock implementation of [`MillisClock`] (composition-root default).
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemMillisClock;
impl MillisClock for SystemMillisClock {
fn now_ms(&self) -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
}
/// In-memory mediated inbox: one FIFO per agent (the mailbox) plus busy state.
///
/// When a [`PtyPort`] is wired (cadrage C3 §5.2), `enqueue` **delivers** the turn —
/// it writes the prefixed task line into the agent's bound [`PtyHandle`]. This is the
/// single, serialized write path that replaces the orchestrator's former ad-hoc PTY
/// write (no more `[IdeA · tâche …]` line emitted from `ask_agent`, no `\r` band-aid).
pub struct MediatedInbox {
mailbox: Arc<InMemoryMailbox>,
/// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5).
tracker: Arc<BusyTracker>,
clock: Arc<dyn MillisClock>,
/// Optional PTY port: present ⇒ the inbox owns the turn-delivery write **and** can
/// observe an agent's output stream for prompt-ready detection (lot C5).
pty: Option<Arc<dyn PtyPort>>,
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
handles: Mutex<HashMap<AgentId, PtyHandle>>,
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
/// watcher thread un-arms its own entry on exit.
watched: Arc<Mutex<HashSet<AgentId>>>,
}
impl MediatedInbox {
/// Builds an inbox over a shared [`InMemoryMailbox`] with the given clock, without
/// turn delivery (the orchestrator writes the turn itself).
#[must_use]
pub fn new(mailbox: Arc<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> Self {
Self {
mailbox,
tracker: Arc::new(BusyTracker::new(None)),
clock,
pty: None,
handles: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Wires an [`EventBus`] so busy/idle transitions publish
/// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder
/// additive: callers that do not wire a bus stay silent.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
self.tracker = Arc::new(BusyTracker::new(Some(events)));
self
}
/// Builds an inbox that **delivers** the turn through `pty` to each agent's bound
/// handle (cadrage C3 §5.2). Use [`MediatedInbox::bind_handle`] to register the
/// agent's live handle before/at enqueue time.
#[must_use]
pub fn with_pty(
mailbox: Arc<InMemoryMailbox>,
clock: Arc<dyn MillisClock>,
pty: Arc<dyn PtyPort>,
) -> Self {
Self {
mailbox,
tracker: Arc::new(BusyTracker::new(None)),
clock,
pty: Some(pty),
handles: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Convenience constructor over a fresh mailbox and the wall clock.
#[must_use]
pub fn in_memory() -> Self {
Self::new(Arc::new(InMemoryMailbox::new()), Arc::new(SystemMillisClock))
}
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> {
self.handles
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator).
#[must_use]
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
Arc::clone(&self.mailbox)
}
fn watched(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
self.watched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Arms the **prompt-ready watcher** for `agent` (lot C5).
///
/// Requires a wired [`PtyPort`] **and** a non-empty literal `pattern`. Spawns a
/// detached thread that consumes the handle's output stream and calls
/// [`BusyTracker::mark_idle`] the **first** time `pattern` appears as a substring of
/// the cumulative output, then exits (one-shot per arming). A sliding buffer keeps
/// the last `pattern.len()-1` bytes so a marker split across two chunks still
/// matches. No watcher is armed when the port is absent or the pattern is empty
/// (no detection ⇒ Idle only via explicit signal/timeout — the safe fallback).
///
/// Re-arming the same agent is a no-op while a watcher is already live (tracked in
/// `watched`), so re-binding a handle never spawns duplicate watchers.
fn arm_prompt_watcher(&self, agent: AgentId, handle: &PtyHandle, pattern: String) {
if pattern.is_empty() {
return;
}
let Some(pty) = self.pty.clone() else {
return;
};
// Already watching this agent ⇒ keep the live watcher (avoid duplicates).
if !self.watched().insert(agent) {
return;
}
let stream = match pty.subscribe_output(handle) {
Ok(s) => s,
Err(_) => {
// Could not subscribe (unknown handle): un-arm so a later bind retries.
self.watched().remove(&agent);
return;
}
};
let tracker = Arc::clone(&self.tracker);
let watched = Arc::clone(&self.watched);
let needle = pattern.into_bytes();
std::thread::spawn(move || {
// Cumulative tail kept small: just enough to catch a marker split across two
// chunks (keep the last needle.len()-1 bytes between reads).
let keep = needle.len().saturating_sub(1);
let mut window: Vec<u8> = Vec::with_capacity(keep + 1);
for chunk in stream {
window.extend_from_slice(&chunk);
if window
.windows(needle.len())
.any(|w| w == needle.as_slice())
{
// Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO).
tracker.mark_idle(agent);
break;
}
if window.len() > keep {
let drop_to = window.len() - keep;
window.drain(..drop_to);
}
}
// Watcher done (matched or stream closed at EOF): un-arm so a future bind
// (e.g. after a relaunch) can re-arm a fresh watcher.
watched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&agent);
});
}
}
impl InputMediator for MediatedInbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
// If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already
// Busy, we still accept (queue grows; the turn advances on mark_idle) — never
// reject the sender (forward fallback).
let started_turn = self.tracker.start_turn(
agent,
AgentBusyState::Busy {
ticket: ticket_id,
since_ms: self.clock.now_ms(),
},
);
// Publish Busy only on the enqueue that **starts** a turn (Idle→Busy); a
// second enqueue while Busy queues behind without re-announcing (cadrage
// C4 §4.2). Published outside the busy mutex (the tracker released it above).
if started_turn {
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
busy: true,
});
}
}
// Delivery: write the prefixed task line into the agent's bound handle. The
// prefix carries the requester + ticket id so the target replies via
// `idea_reply(result, ticket)`. A best-effort write: a missing handle/port or
// a write error never drops the ticket (the orchestrator's await + timeout
// remain the safety net) — the reply slot is registered regardless.
if let Some(pty) = &self.pty {
if let Some(handle) = self.handles().get(&agent).cloned() {
let line = format!(
"[IdeA · tâche de {} · ticket {}] {}\n",
ticket.requester, ticket_id, ticket.task
);
let _ = pty.write(&handle, line.as_bytes());
}
}
self.mailbox.enqueue(agent, ticket)
}
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
self.handles().insert(agent, handle);
}
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
prompt_ready_pattern: Option<String>,
) {
// Register the input handle (delivery path) exactly like `bind_handle`, then arm
// the prompt-ready watcher when the profile declares a literal marker (C5). A
// `None`/empty pattern arms nothing: Idle then comes only from the explicit
// signal or the per-turn timeout (safe fallback, never a false Idle).
self.handles().insert(agent, handle.clone());
if let Some(pattern) = prompt_ready_pattern {
self.arm_prompt_watcher(agent, &handle, pattern);
}
}
fn delivers_turn(&self, agent: AgentId) -> bool {
self.pty.is_some() && self.handles().get(&agent).is_some()
}
fn preempt(&self, agent: AgentId) {
// Interrompre: signals the running turn to stop. It is NOT an enqueue and
// correlates **no** ticket (we never pop/resolve a pending caller — preempt
// must never silently answer one). The only effect is a best-effort interrupt
// byte written into the agent's bound PTY handle: ESC (`\x1b`), the stop key
// CLI agents honour. A missing handle/port is a no-op (the agent simply has no
// live stream to interrupt). The busy state is left untouched: it returns to
// Idle through `mark_idle` (prompt-ready / explicit signal), not here.
if let Some(pty) = &self.pty {
if let Some(handle) = self.handles().get(&agent).cloned() {
let _ = pty.write(&handle, b"\x1b");
}
}
}
fn mark_idle(&self, agent: AgentId) {
// Single authority (also used by the prompt-ready watcher): real Busy→Idle only,
// publishing AgentBusyChanged{busy:false} once.
self.tracker.mark_idle(agent);
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.tracker.busy_state(agent)
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::conversation::ConversationId;
use domain::mailbox::TicketId;
/// Deterministic clock for assertions on `since_ms`.
struct FixedClock(u64);
impl MillisClock for FixedClock {
fn now_ms(&self) -> u64 {
self.0
}
}
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::from_human(
TicketId::from_uuid(uuid::Uuid::from_u128(n)),
ConversationId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
"User",
task,
)
}
fn inbox_at(now_ms: u64) -> MediatedInbox {
MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(now_ms)))
}
/// Records every [`DomainEvent`] published, for busy/idle assertions.
#[derive(Default)]
struct RecordingBus(Mutex<Vec<DomainEvent>>);
impl EventBus for RecordingBus {
fn publish(&self, event: DomainEvent) {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}
fn subscribe(&self) -> domain::ports::EventStream {
unreachable!("RecordingBus is publish-only for these tests")
}
}
impl RecordingBus {
fn busy_events(&self) -> Vec<(AgentId, bool)> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter_map(|e| match e {
DomainEvent::AgentBusyChanged { agent_id, busy } => Some((*agent_id, *busy)),
_ => None,
})
.collect()
}
}
#[test]
fn busy_event_fires_on_turn_start_and_idle_on_mark_idle() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// First enqueue starts a turn ⇒ exactly one Busy(true) event.
inbox.enqueue(a, ticket(10, "first"));
assert_eq!(bus.busy_events(), vec![(a, true)]);
// Second enqueue while Busy queues behind ⇒ NO new busy event.
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(bus.busy_events(), vec![(a, true)], "no re-announce while busy");
// mark_idle on a busy agent ⇒ exactly one Idle(false) event.
inbox.mark_idle(a);
assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]);
// mark_idle on an already-idle agent ⇒ no spurious event.
inbox.mark_idle(a);
assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]);
}
#[test]
fn preempt_emits_no_busy_event() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a);
// Only the enqueue's Busy(true); preempt does not toggle busy state.
assert_eq!(bus.busy_events(), vec![(a, true)]);
}
#[tokio::test]
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
let inbox = inbox_at(5);
let a = agent(1);
let pending = inbox.enqueue(a, ticket(10, "do X"));
// Resolve through the shared mailbox (the orchestrator's path).
inbox.mailbox().resolve(a, "done".to_owned()).unwrap();
assert_eq!(pending.await.unwrap(), "done");
}
#[test]
fn first_enqueue_marks_busy_with_ticket_and_stamp() {
let inbox = inbox_at(1234);
let a = agent(1);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
inbox.enqueue(a, ticket(10, "t"));
assert_eq!(
inbox.busy_state(a),
AgentBusyState::Busy {
ticket: TicketId::from_uuid(uuid::Uuid::from_u128(10)),
since_ms: 1234,
}
);
}
#[test]
fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() {
let inbox = inbox_at(1);
let a = agent(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind
// Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox.
assert_eq!(
inbox.busy_state(a).ticket(),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
assert_eq!(inbox.mailbox().pending(&a), 2, "forward, never reject");
}
#[test]
fn mark_idle_returns_to_idle_so_next_turn_can_start() {
let inbox = inbox_at(1);
let a = agent(1);
inbox.enqueue(a, ticket(10, "t"));
assert!(inbox.busy_state(a).is_busy());
inbox.mark_idle(a);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
// A subsequent enqueue starts a fresh turn.
inbox.enqueue(a, ticket(11, "t2"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
);
}
#[tokio::test]
async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() {
let inbox = inbox_at(1);
let a = agent(1);
let pending = inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a);
// preempt did not pop/resolve the ticket: still pending in the mailbox.
assert_eq!(inbox.mailbox().pending(&a), 1);
// Nothing answered the caller via preempt.
inbox.mailbox().resolve(a, "real".to_owned()).unwrap();
assert_eq!(pending.await.unwrap(), "real");
}
#[test]
fn two_enqueues_same_agent_serialise_in_one_fifo() {
let inbox = inbox_at(1);
let a = agent(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2);
assert_eq!(
inbox.mailbox().head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))),
"FIFO order preserved"
);
}
#[test]
fn different_agents_are_independent_not_blocking() {
let inbox = inbox_at(1);
let a = agent(1);
let b = agent(2);
inbox.enqueue(a, ticket(10, "a"));
inbox.enqueue(b, ticket(20, "b"));
assert!(inbox.busy_state(a).is_busy());
assert!(inbox.busy_state(b).is_busy());
// Marking A idle leaves B untouched.
inbox.mark_idle(a);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
assert!(inbox.busy_state(b).is_busy());
assert_eq!(inbox.mailbox().pending(&a), 1);
assert_eq!(inbox.mailbox().pending(&b), 1);
}
// ====================================================================
// Lot C5 — prompt-ready detection on the PTY output stream
// ====================================================================
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec};
use domain::terminal::PtySize;
use domain::ids::SessionId;
/// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then
/// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is
/// recorded; `spawn`/`resize`/`kill`/`scrollback` are unused stubs for these tests.
struct FakePty {
chunks: Mutex<HashMap<SessionId, Vec<Vec<u8>>>>,
writes: Mutex<Vec<Vec<u8>>>,
}
impl FakePty {
fn new() -> Self {
Self {
chunks: Mutex::new(HashMap::new()),
writes: Mutex::new(Vec::new()),
}
}
/// Seeds the chunks a later `subscribe_output(handle)` will replay.
fn seed(&self, handle: &Handle, chunks: Vec<Vec<u8>>) {
self.chunks
.lock()
.unwrap()
.insert(handle.session_id.clone(), chunks);
}
}
#[async_trait::async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<Handle, PtyError> {
Ok(Handle {
session_id: SessionId::new_random(),
})
}
fn write(&self, _handle: &Handle, data: &[u8]) -> Result<(), PtyError> {
self.writes.lock().unwrap().push(data.to_vec());
Ok(())
}
fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> {
Ok(())
}
fn subscribe_output(&self, handle: &Handle) -> Result<OutputStream, PtyError> {
let chunks = self
.chunks
.lock()
.unwrap()
.get(&handle.session_id)
.cloned()
.unwrap_or_default();
Ok(Box::new(chunks.into_iter()))
}
fn scrollback(&self, _handle: &Handle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) })
}
}
fn handle(n: u128) -> Handle {
Handle {
session_id: SessionId::from_uuid(uuid::Uuid::from_u128(n)),
}
}
/// Spins until `cond` holds or the deadline passes (the watcher runs on its own
/// thread, so the transition is observed asynchronously).
fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
cond()
}
fn inbox_with(pty: Arc<FakePty>) -> MediatedInbox {
MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
pty as Arc<dyn PtyPort>,
)
}
#[test]
fn prompt_pattern_present_and_output_contains_it_flips_busy_to_idle() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(1);
pty.seed(&h, vec![b"working...\n".to_vec(), b"done\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "task"));
assert!(inbox.busy_state(a).is_busy(), "enqueue starts a turn");
// Arm prompt detection with the literal marker "\n> " (a stable prompt sigil).
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"prompt marker in output ⇒ Busy→Idle"
);
}
#[test]
fn prompt_marker_split_across_two_chunks_still_matches() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(2);
// The marker "READY" straddles two chunks: "REA" | "DY done".
pty.seed(&h, vec![b"out REA".to_vec(), b"DY done".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()));
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"a marker split across chunks must still flip to Idle"
);
}
#[test]
fn no_pattern_in_profile_never_marks_idle_even_with_output() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(3);
pty.seed(&h, vec![b"lots of output\n> $ done\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// No pattern: bind without arming a watcher (the safe fallback).
inbox.bind_handle_with_prompt(a, h, None);
// Give any (erroneously spawned) watcher a chance to fire — it must not.
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
inbox.busy_state(a).is_busy(),
"no pattern ⇒ never a false Idle; the agent stays Busy"
);
// The FIFO still accepts a second enqueue (forward, never reject).
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2, "enqueue accepted while Busy");
}
#[test]
fn pattern_absent_from_output_keeps_agent_busy_but_queue_accepts() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(4);
// Output never contains the marker ⇒ watcher runs to EOF without matching.
pty.seed(&h, vec![b"still thinking, no prompt here\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
// Even after the stream ends, no match ⇒ stays Busy (timeout is the ultimate
// guard, exercised at the orchestrator layer).
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
inbox.busy_state(a).is_busy(),
"marker absent from output ⇒ stays Busy (no false Idle)"
);
inbox.enqueue(a, ticket(11, "queued"));
assert_eq!(
inbox.mailbox().pending(&a),
2,
"in doubt → keep accepting (forward, never reject)"
);
}
#[test]
fn empty_pattern_arms_nothing() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(5);
pty.seed(&h, vec![b"anything\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some(String::new()));
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(
inbox.busy_state(a).is_busy(),
"an empty pattern must arm no watcher (treated as no detection)"
);
}
#[test]
fn prompt_match_advances_fifo_to_next_ticket() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(6);
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
// Two tickets queued; the turn is on the first.
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
// Prompt-ready ⇒ Idle ⇒ the next enqueue can start a fresh turn.
assert!(wait_until(|| !inbox.busy_state(a).is_busy()));
inbox.enqueue(a, ticket(12, "third"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(12))),
"after prompt-ready Idle, a new enqueue starts the next turn"
);
}
#[test]
fn rebinding_same_agent_does_not_spawn_duplicate_watcher() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(7);
// A stream that never matches and never ends quickly: empty ⇒ immediate EOF.
pty.seed(&h, vec![b"noise".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// Arm twice in a row; the second must be a no-op while the first is live (no
// panic, no double-subscribe). After EOF the agent is un-armed and stays Busy.
inbox.bind_handle_with_prompt(a, h.clone(), Some("ZZZ".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()));
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
}
}

View File

@ -13,11 +13,15 @@
#![warn(missing_docs)]
pub mod clock;
pub mod conversation;
pub mod eventbus;
pub mod fileguard;
pub mod fs;
pub mod git;
pub mod id;
pub mod input;
pub mod inspector;
pub mod mailbox;
pub mod orchestrator;
pub mod process;
pub mod pty;
@ -27,11 +31,15 @@ pub mod session;
pub mod store;
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use inspector::ClaudeTranscriptInspector;
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -0,0 +1,261 @@
//! [`InMemoryMailbox`] — the [`AgentMailbox`] adapter (Option 1, lot B-1).
//!
//! The driven side of the inter-agent rendezvous: a per-agent FIFO of
//! [`Ticket`]s, each paired with a one-shot reply channel. `enqueue` appends a
//! ticket and hands the caller a [`PendingReply`] over the receiver; `resolve`
//! feeds the target's `idea_reply` result into the **head** ticket's sender.
//!
//! All the concrete machinery the domain refuses to name lives here: the
//! [`std::collections::VecDeque`] queue, its [`std::sync::Mutex`], and the
//! [`tokio::sync::oneshot`] channel that bridges the resolving call to the awaiting
//! one. The domain port ([`domain::mailbox`]) stays I/O-free.
//!
//! ## Concurrency
//!
//! The map is guarded by a **synchronous** [`Mutex`] held only for the O(1)
//! enqueue/resolve/cancel mutations — **never across an `.await`** (the await is the
//! caller's, on the returned [`PendingReply`], outside the lock). Two `ask`s for the
//! **same** target serialise positionally in that target's `VecDeque`; two `ask`s
//! for **different** targets touch different queues and never contend on the data,
//! only briefly on the map mutex.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use tokio::sync::oneshot;
use domain::ids::AgentId;
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
/// One queued request plus the sender that resolves its awaiting [`PendingReply`].
struct Slot {
ticket: Ticket,
reply: oneshot::Sender<String>,
}
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
#[derive(Default)]
pub struct InMemoryMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>,
}
impl InMemoryMailbox {
/// Creates an empty mailbox.
#[must_use]
pub fn new() -> Self {
Self {
queues: Mutex::new(HashMap::new()),
}
}
/// Number of tickets currently queued for `agent` (test/inspection helper).
#[must_use]
pub fn pending(&self, agent: &AgentId) -> usize {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(agent)
.map_or(0, VecDeque::len)
}
/// The id of the ticket currently at the head of `agent`'s queue, if any
/// (test/inspection helper).
#[must_use]
pub fn head_ticket(&self, agent: &AgentId) -> Option<TicketId> {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(agent)
.and_then(|q| q.front())
.map(|slot| slot.ticket.id)
}
}
impl AgentMailbox for InMemoryMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let (tx, rx) = oneshot::channel::<String>();
{
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
queues
.entry(agent)
.or_default()
.push_back(Slot { ticket, reply: tx });
}
// The await happens in the application layer, outside the map lock. A closed
// channel (sender dropped without a value, e.g. the head was cancelled or the
// session ended) maps to a typed `Cancelled` rather than a raw recv error.
PendingReply::new(Box::pin(async move {
rx.await.map_err(|_| MailboxError::Cancelled)
}))
}
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
// Pop the head slot under the lock, then send **outside** any await (the send
// is non-blocking). If the receiver already went away (caller timed out), the
// ticket is still correctly retired from the head — the queue advances.
let slot = {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let queue = queues
.get_mut(&agent)
.filter(|q| !q.is_empty())
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.pop_front().expect("non-empty queue has a head")
};
// A dropped receiver (the awaiting caller timed out and went away) is fine:
// the reply is simply discarded, the head ticket is already retired.
let _ = slot.reply.send(result);
Ok(())
}
fn resolve_ticket(
&self,
agent: AgentId,
ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
// Remove the slot whose ticket id matches, anywhere in the queue (multi-thread
// correlation), then send outside any await. A missing match is a typed
// NoPendingRequest — never a panic.
let slot = {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let queue = queues
.get_mut(&agent)
.filter(|q| !q.is_empty())
.ok_or(MailboxError::NoPendingRequest(agent))?;
let pos = queue
.iter()
.position(|s| s.ticket.id == ticket_id)
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.remove(pos).expect("position just found is in range")
};
let _ = slot.reply.send(result);
Ok(())
}
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(queue) = queues.get_mut(&agent) {
// Only retire the head, and only if it is *this* ticket: a head that has
// since changed (already resolved, or someone else's ticket now in front)
// must not be dropped. Idempotent and positional.
if queue.front().map(|s| s.ticket.id) == Some(ticket_id) {
queue.pop_front();
}
if queue.is_empty() {
queues.remove(&agent);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task)
}
#[tokio::test]
async fn enqueue_then_resolve_wakes_the_pending_reply() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let pending = mb.enqueue(a, ticket(10, "do X"));
mb.resolve(a, "done X".to_owned()).expect("resolve ok");
let reply = pending.await.expect("reply received");
assert_eq!(reply, "done X");
assert_eq!(mb.pending(&a), 0, "queue drained after resolve");
}
#[tokio::test]
async fn two_asks_same_target_resolve_fifo_head_first() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let p1 = mb.enqueue(a, ticket(10, "first"));
let p2 = mb.enqueue(a, ticket(11, "second"));
assert_eq!(mb.pending(&a), 2);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
// First resolve goes to the FIRST (head) ticket.
mb.resolve(a, "r1".to_owned()).unwrap();
assert_eq!(p1.await.unwrap(), "r1");
// Now the second is at the head.
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
}
#[tokio::test]
async fn different_targets_do_not_block_each_other() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let b = agent(2);
let pa = mb.enqueue(a, ticket(10, "task a"));
let _pb = mb.enqueue(b, ticket(20, "task b"));
// Resolving B leaves A untouched; resolving A then completes pa.
mb.resolve(b, "rb".to_owned()).unwrap();
assert_eq!(mb.pending(&a), 1, "A's queue is independent of B's");
mb.resolve(a, "ra".to_owned()).unwrap();
assert_eq!(pa.await.unwrap(), "ra");
}
#[test]
fn resolve_without_pending_is_a_typed_error() {
let mb = InMemoryMailbox::new();
let a = agent(1);
assert_eq!(
mb.resolve(a, "orphan".to_owned()),
Err(MailboxError::NoPendingRequest(a))
);
}
#[tokio::test]
async fn cancel_head_retires_the_head_and_advances_the_queue() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let p1 = mb.enqueue(a, ticket(10, "stuck"));
let p2 = mb.enqueue(a, ticket(11, "next"));
// Caller of ticket 10 timed out: retire exactly its head ticket.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(mb.pending(&a), 1);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
// The cancelled pending resolves to Cancelled (its sender was dropped).
assert_eq!(p1.await, Err(MailboxError::Cancelled));
// The next ticket is now resolvable normally.
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
}
#[test]
fn cancel_head_is_a_noop_when_head_is_a_different_ticket() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let _p1 = mb.enqueue(a, ticket(10, "head"));
// Try to cancel a ticket that is NOT the head ⇒ nothing retired.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
assert_eq!(mb.pending(&a), 1);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
}
}

View File

@ -225,7 +225,10 @@ impl McpServer {
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
let command = tools::map_tool_call(&name, &arguments).map_err(map_err_to_jsonrpc)?;
// `idea_reply` needs the connected peer's identity as `from`; every other tool
// ignores `requester`. The handshake-provided requester is the source of truth.
let command =
tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?;
let result = self.service.dispatch(&self.project, command).await;
// Surface the processed delegation on the bus, tagged as the MCP door — the

View File

@ -44,11 +44,15 @@ pub enum ToolMapError {
}
/// Whether a successful call of `tool` carries an inline reply payload back to the
/// caller: `idea_ask_agent` (the target's `Final`) and `idea_list_agents` (the
/// agent list as a JSON array).
/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent
/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to
/// the awaiting requester, not echoed inline), so it is **not** in this set.
#[must_use]
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(tool, "idea_ask_agent" | "idea_list_agents")
matches!(
tool,
"idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read"
)
}
/// The full catalogue advertised on `tools/list`.
@ -82,6 +86,23 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_reply",
description: "Render the result of the task you are currently processing (the one IdeA \
delegated to you as `[IdeA · tâche … · ticket <id>]`). Call this — never \
answer in plain text — so IdeA can hand your answer back to the agent that \
asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \
reply exactly, even when you handle several requests.",
input_schema: json!({
"type": "object",
"properties": {
"result": { "type": "string", "description": "The result/answer to deliver to the requester." },
"ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket <id>]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." }
},
"required": ["result"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
@ -124,6 +145,62 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_read",
description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \
Omit `target` for the global project context, or pass an agent's display \
name for that agent's `.md`. Reading is shared (never blocks other readers).",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Agent display name. Omit for the global project context." }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_propose",
description: "Propose new Markdown content for an IdeA-owned context (under the file \
guard). For an agent's `.md` this writes directly; for the **global** \
project context it is only a *proposal* (the global context is \
single-writer — reserved to the orchestrator — so your change is recorded \
for validation, not applied).",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." },
"content": { "type": "string", "description": "The proposed Markdown body." }
},
"required": ["content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_read",
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
index, or pass a note slug for one note.",
input_schema: json!({
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_write",
description: "Write (create or replace) a project memory note under the file guard. \
Memory is shared across the project's agents.",
input_schema: json!({
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Memory note slug (kebab-case)." },
"content": { "type": "string", "description": "The Markdown body to store." }
},
"required": ["slug", "content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_create_skill",
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
@ -146,6 +223,10 @@ pub fn catalogue() -> Vec<ToolDef> {
/// validation authority.
///
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation
/// uses the handshake identity, never a model-managed value. Every other tool
/// ignores it.
///
/// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
@ -154,6 +235,7 @@ pub fn catalogue() -> Vec<ToolDef> {
pub fn map_tool_call(
name: &str,
arguments: &Value,
requester: &str,
) -> Result<OrchestratorCommand, ToolMapError> {
let args = arguments
.as_object()
@ -173,10 +255,25 @@ pub fn map_tool_call(
},
"idea_ask_agent" => OrchestratorRequest {
request_type: Some("agent.message".to_owned()),
// The **requester** is the connected peer's handshake identity (never a
// tool argument), injected as `requestedBy` so `validate` can route the
// ask on the A↔B conversation and feed the wait-for guard (cadrage C3).
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
task: s("task"),
..base()
},
"idea_reply" => OrchestratorRequest {
request_type: Some("agent.reply".to_owned()),
// `from` is the handshake identity, not a tool argument: inject the peer's
// requester id as `requestedBy` so `validate` builds `Reply { from, .. }`.
requested_by: Some(requester.to_owned()),
// The agent echoes the `ticket` it received in the `[IdeA · … · ticket
// <id>]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3).
ticket: s("ticket"),
result: s("result"),
..base()
},
"idea_launch_agent" => OrchestratorRequest {
request_type: Some("agent.run".to_owned()),
target_agent: s("target"),
@ -197,6 +294,33 @@ pub fn map_tool_call(
context: s("context"),
..base()
},
"idea_context_read" => OrchestratorRequest {
request_type: Some("context.read".to_owned()),
// The requester (handshake identity) drives the read lease's `who`.
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
..base()
},
"idea_context_propose" => OrchestratorRequest {
request_type: Some("context.propose".to_owned()),
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
content: s("content"),
..base()
},
"idea_memory_read" => OrchestratorRequest {
request_type: Some("memory.read".to_owned()),
requested_by: Some(requester.to_owned()),
slug: s("slug"),
..base()
},
"idea_memory_write" => OrchestratorRequest {
request_type: Some("memory.write".to_owned()),
requested_by: Some(requester.to_owned()),
slug: s("slug"),
content: s("content"),
..base()
},
"idea_create_skill" => OrchestratorRequest {
request_type: Some("skill.create".to_owned()),
name: s("name"),
@ -225,6 +349,10 @@ fn base() -> OrchestratorRequest {
node_id: None,
attach_to_cell: None,
scope: None,
result: None,
ticket: None,
content: None,
slug: None,
}
}
@ -241,9 +369,17 @@ mod tests {
use super::*;
use domain::OrchestratorVisibility;
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
const REQ: &str = "11111111-1111-1111-1111-111111111111";
/// Maps a tool call with an empty requester (the default for tools that ignore it).
fn map(name: &str, args: &Value) -> Result<OrchestratorCommand, ToolMapError> {
map_tool_call(name, args, "")
}
#[test]
fn ask_agent_maps_to_ask_command() {
let cmd = map_tool_call(
let cmd = map(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
)
@ -253,13 +389,15 @@ mod tests {
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `map` passes an empty requester ⇒ no machine requester carried.
requester: None,
}
);
}
#[test]
fn launch_agent_maps_to_spawn_background_by_default() {
let cmd = map_tool_call("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::SpawnAgent {
@ -274,11 +412,11 @@ mod tests {
#[test]
fn stop_update_and_skill_map_to_their_commands() {
assert_eq!(
map_tool_call("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
);
assert_eq!(
map_tool_call(
map(
"idea_update_context",
&json!({ "target": "Dev", "context": "# body" })
)
@ -289,7 +427,7 @@ mod tests {
}
);
assert_eq!(
map_tool_call(
map(
"idea_create_skill",
&json!({ "name": "deploy", "context": "# steps" })
)
@ -304,19 +442,19 @@ mod tests {
#[test]
fn missing_required_field_surfaces_validation_error() {
let err = map_tool_call("idea_ask_agent", &json!({ "target": "Architect" }));
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn list_agents_maps_to_list_command() {
let cmd = map_tool_call("idea_list_agents", &json!({})).unwrap();
let cmd = map("idea_list_agents", &json!({})).unwrap();
assert_eq!(cmd, OrchestratorCommand::ListAgents);
}
#[test]
fn unknown_tool_is_rejected() {
let err = map_tool_call("idea_made_up_tool", &json!({}));
let err = map("idea_made_up_tool", &json!({}));
assert_eq!(
err,
Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
@ -325,10 +463,155 @@ mod tests {
#[test]
fn non_object_arguments_are_rejected() {
let err = map_tool_call("idea_ask_agent", &json!("nope"));
let err = map("idea_ask_agent", &json!("nope"));
assert_eq!(
err,
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
);
}
#[test]
fn reply_maps_with_handshake_requester_as_from() {
// `from` comes from the handshake requester, NOT a tool argument.
let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::Reply {
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
ticket: None,
result: "the answer".to_owned(),
}
);
}
#[test]
fn reply_echoes_ticket_when_provided() {
let tkt = "22222222-2222-2222-2222-222222222222";
let cmd = map_tool_call(
"idea_reply",
&json!({ "result": "done", "ticket": tkt }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::Reply {
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
ticket: Some(domain::mailbox::TicketId::from_uuid(
uuid::Uuid::parse_str(tkt).unwrap()
)),
result: "done".to_owned(),
}
);
}
#[test]
fn ask_agent_injects_handshake_requester() {
let cmd = map_tool_call(
"idea_ask_agent",
&json!({ "target": "B", "task": "go" }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::AskAgent {
target: "B".to_owned(),
task: "go".to_owned(),
requester: Some(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn reply_without_result_is_a_validation_error() {
let err = map_tool_call("idea_reply", &json!({}), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn reply_with_blank_or_missing_requester_is_a_validation_error() {
// No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic).
let err = map_tool_call("idea_reply", &json!({ "result": "x" }), "");
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
// A non-uuid requester is rejected too.
let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid");
assert!(matches!(err2, Err(ToolMapError::Invalid(_))));
}
#[test]
fn context_read_maps_with_requester_party() {
// Global (no target) with a uuid requester ⇒ Agent party.
let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::ReadContext {
target: None,
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn context_propose_requires_content() {
let ok = map_tool_call(
"idea_context_propose",
&json!({ "target": "Dev", "content": "# body" }),
REQ,
)
.unwrap();
assert_eq!(
ok,
OrchestratorCommand::ProposeContext {
target: Some("Dev".to_owned()),
content: "# body".to_owned(),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn memory_read_and_write_map_to_their_commands() {
assert_eq!(
map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(),
OrchestratorCommand::ReadMemory {
slug: Some("note-a".to_owned()),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
assert_eq!(
map_tool_call(
"idea_memory_write",
&json!({ "slug": "note-a", "content": "hi" }),
REQ
)
.unwrap(),
OrchestratorCommand::WriteMemory {
slug: "note-a".to_owned(),
content: "hi".to_owned(),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
// memory.write without content ⇒ validation error.
let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn reply_is_not_an_inline_reply_tool() {
// ACK only: the result is routed to the awaiting requester, not echoed.
assert!(!tool_returns_reply("idea_reply"));
}
}

View File

@ -32,12 +32,15 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::ids::NodeId;
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -47,9 +50,12 @@ use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock,
};
use infrastructure::{McpServer, MemoryTransport};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use serde_json::{json, Value};
@ -290,35 +296,6 @@ impl PtyPort for FakePty {
}
}
/// A structured session whose turn deterministically ends on a `Final` carrying a
/// fixed reply — lets us exercise `idea_ask_agent` without a real CLI.
struct FakeSession {
id: SessionId,
reply: String,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)]
struct NoopBus;
@ -354,6 +331,9 @@ fn project() -> Project {
/// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live
/// PTY session for an agent (needed to make `stop_agent` succeed).
fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<TerminalSessions>) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
// d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
@ -364,7 +344,12 @@ fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<Termi
"{agentRunDir}",
None,
)
.unwrap()])));
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
@ -405,18 +390,48 @@ fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<Termi
(service, sessions)
}
/// Like [`build_service`] but with the **structured sessions** registry wired, so
/// `idea_ask_agent` can find a live structured target. Returns the service and the
/// shared registry so a test can pre-insert a replying session.
fn build_service_with_structured(
/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// « Terminal + MCP », B-3/B-4), so `idea_ask_agent` blocks on the mailbox and
/// `idea_reply` resolves it. Returns the service, the shared mailbox (to observe
/// pending tickets) and the PTY session registry (to pre-seed a live target).
fn build_service_with_mailbox(
contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) {
let structured = Arc::new(StructuredSessions::new());
let (base, _sessions) = build_service(contexts);
) -> (
Arc<OrchestratorService>,
Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
) {
let mailbox = Arc::new(InMemoryMailbox::new());
let (base, sessions) = build_service(contexts);
let input = Arc::new(MediatedInbox::with_pty(
Arc::clone(&mailbox),
Arc::new(SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>;
let conversations = Arc::new(InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>;
let service = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_structured(Arc::clone(&structured));
(Arc::new(service), structured)
.with_input_mediator(
input,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.with_conversations(conversations);
(Arc::new(service), mailbox, sessions)
}
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(7)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
fn server(service: Arc<OrchestratorService>) -> McpServer {
@ -461,7 +476,7 @@ fn result_text(result: &Value) -> &str {
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tools_list_advertises_the_six_idea_tools_with_schemas() {
async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
@ -483,14 +498,24 @@ async fn tools_list_advertises_the_six_idea_tools_with_schemas() {
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_reply",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_create_skill",
// FileGuard-mediated context/memory tools (cadrage C7).
"idea_context_read",
"idea_context_propose",
"idea_memory_read",
"idea_memory_write",
] {
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
}
assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}");
assert_eq!(
tools.len(),
11,
"exactly the eleven idea_* tools (7 base + 4 FileGuard C7); got {names:?}"
);
// Every tool advertises an object input schema.
for t in tools {
@ -598,28 +623,47 @@ async fn update_context_and_create_skill_calls_succeed() {
#[tokio::test]
async fn ask_agent_returns_target_reply_inline() {
// Option 1 (B-3/B-4): `idea_ask_agent` writes the task into the target's live
// terminal and blocks on the mailbox; the target's `idea_reply` (carrying its
// handshake identity as requester) resolves it, and the ask returns the result
// inline. We drive both over the MCP server, the ask on a spawned task.
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(contexts);
structured.insert(
Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(4242)),
reply: "the answer is 42".to_owned(),
}),
agent_id,
NodeId::from_uuid(Uuid::from_u128(7)),
);
let server = server(service);
let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
let raw = tools_call(
7,
"idea_ask_agent",
json!({ "target": "architect", "task": "What is the answer?" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
let ask_server = server(Arc::clone(&service));
let ask = tokio::spawn(async move {
let raw = tools_call(
7,
"idea_ask_agent",
json!({ "target": "architect", "task": "What is the answer?" }),
);
ask_server.handle_raw(&raw).await.expect("reply owed")
});
// Wait until the ask has enqueued its ticket (it is now blocked awaiting a reply).
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket");
// The target replies via idea_reply — its identity comes from the handshake
// requester, which `for_requester` injects as the `from` of the Reply command.
let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string());
let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" }));
let reply_resp = reply_server.handle_raw(&reply_raw).await.expect("reply owed");
assert_eq!(reply_resp.result.expect("result")["isError"], json!(false));
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
.expect("ask completes after reply")
.expect("join ok");
assert_eq!(response.id, json!(7), "id must be echoed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
assert_eq!(
@ -629,6 +673,22 @@ async fn ask_agent_returns_target_reply_inline() {
);
}
/// `idea_reply` with no in-flight ask for the connected peer ⇒ the IdeA command
/// fails, surfaced as a tool execution error (`isError: true`), never a panic.
#[tokio::test]
async fn reply_without_pending_ask_is_a_tool_error() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let reply_server = server(service).for_requester(agent_id.to_string());
let raw = tools_call(9, "idea_reply", json!({ "result": "orphan" }));
let resp = reply_server.handle_raw(&raw).await.expect("reply owed");
// Mapped + dispatched, but the command failed ⇒ tool error, transport intact.
assert!(resp.error.is_none(), "no transport error: {:?}", resp.error);
assert_eq!(resp.result.expect("result")["isError"], json!(true));
}
// ---------------------------------------------------------------------------
// 4. idea_list_agents returns the agent list inline (JSON array)
// ---------------------------------------------------------------------------
@ -785,11 +845,10 @@ async fn initialize_answers_minimal_handshake() {
async fn ask_agent_missing_task_is_rejected_by_shared_validation_no_dispatch() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
// No structured session inserted: if validation wrongly let this through to
// dispatch, ask_agent would try to launch/contact the target and the error
// would differ. A validation rejection here is INVALID_PARAMS, before dispatch.
let (service, structured) = build_service_with_structured(contexts);
let _ = &structured; // intentionally empty
// If validation wrongly let this through to dispatch, ask_agent would try to
// launch/contact the target and the error would differ. A validation rejection
// here is INVALID_PARAMS, before dispatch.
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let server = server(service);
let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" }));

View File

@ -21,23 +21,29 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ids::NodeId;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
process_request_file, FsOrchestratorWatcher, InMemoryMailbox, OrchestratorResponse,
};
use infrastructure::{process_request_file, FsOrchestratorWatcher, OrchestratorResponse};
// --- temp dir (mirror local_fs.rs) ---
struct TempDir(PathBuf);
@ -289,36 +295,6 @@ impl PtyPort for FakePty {
}
}
/// A structured [`AgentSession`] whose turn deterministically ends on a `Final`
/// carrying a fixed reply. Lets us exercise the `ask`/`agent.message` path
/// (`OrchestratorOutcome.reply = Some(..)`) without a real CLI.
struct FakeSession {
id: SessionId,
reply: String,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)]
struct NoopBus;
@ -351,6 +327,9 @@ fn project() -> Project {
}
fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
// d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
@ -361,7 +340,12 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
"{agentRunDir}",
None,
)
.unwrap()])));
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
@ -401,24 +385,107 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
))
}
/// Like [`build_service`] but with the **structured sessions** registry wired
/// (`with_structured`), so `agent.message`/`ask` can find a live structured target.
/// Returns the service and the shared registry so a test can pre-insert a session.
fn build_service_with_structured(
/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// « Terminal + MCP », B-3/B-4), so an `agent.message` blocks on the mailbox and an
/// `agent.reply` resolves it. Returns the service, the shared mailbox (to observe
/// pending tickets) and the PTY registry (to pre-seed a live target).
fn build_service_with_mailbox(
contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) {
let structured = Arc::new(StructuredSessions::new());
// `build_service` already assembles every use case over the same fakes; we only
// need to fold the structured registry onto the resulting service.
let base = build_service(contexts);
// `OrchestratorService` is not `Clone`; rebuild via the builder on a fresh one is
// overkill, so we reconstruct minimally is not possible — instead, the service
// exposes `with_structured` as a consuming builder. We rely on `Arc::try_unwrap`
// since `build_service` returns a freshly-created `Arc` with refcount 1.
let service = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_structured(Arc::clone(&structured));
(Arc::new(service), structured)
) -> (
Arc<OrchestratorService>,
Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
// d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::stdin(),
None,
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds(Mutex::new(1))),
bus.clone(),
));
let launch = Arc::new(LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
let create_skill = Arc::new(CreateSkill::new(
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))),
));
let service = Arc::new(
OrchestratorService::new(
create,
launch,
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
)
.with_input_mediator(
Arc::new(infrastructure::MediatedInbox::with_pty(
Arc::clone(&mailbox),
Arc::new(infrastructure::SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.with_conversations(
Arc::new(infrastructure::InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>,
),
);
(service, mailbox, sessions)
}
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
fn seed_live_pty(
sessions: &TerminalSessions,
agent_id: AgentId,
session_id: SessionId,
) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(7)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
@ -573,19 +640,17 @@ async fn unknown_action_yields_error_response() {
/// alongside `ok: true`.
#[tokio::test]
async fn ask_request_surfaces_reply_alongside_detail() {
// Option 1 (B-3/B-4): an `agent.message` request blocks awaiting the target's
// `idea_reply`. Over the file protocol we drive the ask request on a task, wait
// for the mailbox to hold a ticket, then process a separate `agent.reply` request
// (carrying the target's id as `requestedBy`, the handshake identity) which
// resolves it. The ask's `*.response.json` then carries reply + detail.
let tmp = TempDir::new();
let contexts = FakeContexts::new();
// Seed an existing target agent and a live structured session that replies.
let agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(contexts.clone());
structured.insert(
Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(4242)),
reply: "the answer is 42".to_owned(),
}),
agent_id,
NodeId::from_uuid(Uuid::from_u128(7)),
);
let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
// Target already live in the PTY registry, so the ask reuses its terminal.
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
let req = tmp.0.join("ask-req.json");
std::fs::write(
@ -594,11 +659,42 @@ async fn ask_request_surfaces_reply_alongside_detail() {
)
.unwrap();
let response = process_request_file(&req, &project(), &service).await;
let svc = Arc::clone(&service);
let proj = project();
let ask_req = req.clone();
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
// Wait until the ask has enqueued its ticket (blocked awaiting the reply).
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket");
// The target renders its result via an `agent.reply` request whose `requestedBy`
// is its own id (the handshake identity the MCP server would inject as `from`).
let reply_req = tmp.0.join("reply-req.json");
std::fs::write(
&reply_req,
format!(
r#"{{ "type": "agent.reply", "requestedBy": "{agent_id}", "result": "the answer is 42" }}"#
)
.as_bytes(),
)
.unwrap();
let reply_resp = process_request_file(&reply_req, &project(), &service).await;
assert!(reply_resp.ok, "reply request ok: {reply_resp:?}");
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
.expect("ask completes after reply")
.expect("join ok");
assert!(response.ok, "expected ok, got {response:?}");
assert_eq!(response.action.as_deref(), Some("agent.message"));
// reply carries the target's Final content...
// reply carries the target's rendered result...
assert_eq!(response.reply.as_deref(), Some("the answer is 42"));
// ...and detail still summarises what IdeA did (the two coexist).
assert!(