feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
194
crates/infrastructure/src/conversation/mod.rs
Normal file
194
crates/infrastructure/src/conversation/mod.rs
Normal file
@ -0,0 +1,194 @@
|
||||
//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2).
|
||||
//!
|
||||
//! The driven side of conversation-by-pair: a `HashMap<ConversationId, Conversation>`
|
||||
//! plus a pair→id index, so `resolve` is **lazy get-or-create** and the same
|
||||
//! unordered pair `{a, b}` always maps to the same [`ConversationId`].
|
||||
//!
|
||||
//! ## Concurrency
|
||||
//!
|
||||
//! A single **synchronous** [`Mutex`] guards both maps; it is held only for the O(1)
|
||||
//! lookups/mutations and **never across an `.await`** (this registry is fully sync,
|
||||
//! cf. the `ask_locks` discipline of `service.rs`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::conversation::{
|
||||
Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession,
|
||||
SessionRef,
|
||||
};
|
||||
|
||||
/// Order-insensitive key for a conversation pair `{a, b}`.
|
||||
///
|
||||
/// Normalised so that `{a, b}` and `{b, a}` hash/compare equal — this is what makes
|
||||
/// `resolve(a, b)` and `resolve(b, a)` resolve to the same conversation.
|
||||
fn pair_key(a: ConversationParty, b: ConversationParty) -> (ConversationParty, ConversationParty) {
|
||||
// Stable, total ordering over parties so the smaller end is always `left` in the
|
||||
// key. `User` sorts before any agent; agents order by their UUID.
|
||||
fn rank(p: ConversationParty) -> (u8, u128) {
|
||||
match p {
|
||||
ConversationParty::User => (0, 0),
|
||||
ConversationParty::Agent { agent_id } => (1, agent_id.as_uuid().as_u128()),
|
||||
}
|
||||
}
|
||||
if rank(a) <= rank(b) {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory, pair-keyed conversation registry (the production [`ConversationRegistry`]).
|
||||
#[derive(Default)]
|
||||
pub struct InMemoryConversationRegistry {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
by_id: HashMap<ConversationId, Conversation>,
|
||||
by_pair: HashMap<(ConversationParty, ConversationParty), ConversationId>,
|
||||
}
|
||||
|
||||
impl InMemoryConversationRegistry {
|
||||
/// Creates an empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of conversations currently held (test/inspection helper).
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.lock().by_id.len()
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lock().by_id.is_empty()
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationRegistry for InMemoryConversationRegistry {
|
||||
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation {
|
||||
let key = pair_key(a, b);
|
||||
let mut inner = self.lock();
|
||||
if let Some(id) = inner.by_pair.get(&key).copied() {
|
||||
// Existing thread for this pair — return its current snapshot.
|
||||
return inner
|
||||
.by_id
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.expect("by_pair id always present in by_id");
|
||||
}
|
||||
// Lazy create: mint a fresh Dormant conversation for the pair. The pair is
|
||||
// valid by construction at call sites (resolve is only asked for real pairs);
|
||||
// `try_new` still guards the invariants, and on the (impossible) error we fall
|
||||
// back to a normalised pair to never panic in production.
|
||||
let id = ConversationId::new_random();
|
||||
let conv = Conversation::try_new(id, key.0, key.1)
|
||||
.expect("pair_key yields a valid distinct/≤1-user pair");
|
||||
inner.by_pair.insert(key, id);
|
||||
inner.by_id.insert(id, conv.clone());
|
||||
conv
|
||||
}
|
||||
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef) {
|
||||
let mut inner = self.lock();
|
||||
if let Some(conv) = inner.by_id.get_mut(&id) {
|
||||
conv.session = ConversationSession::Live {
|
||||
handle_ref: session,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
|
||||
let mut inner = self.lock();
|
||||
if let Some(conv) = inner.by_id.get_mut(&id) {
|
||||
conv.session = ConversationSession::Dormant;
|
||||
conv.resumable_id = resumable_id;
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, id: ConversationId) -> Option<Conversation> {
|
||||
self.lock().by_id.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ids::{AgentId, SessionId};
|
||||
|
||||
fn agent(n: u128) -> ConversationParty {
|
||||
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_lazy_get_or_create() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
let c = reg.resolve(ConversationParty::User, agent(1));
|
||||
assert_eq!(reg.len(), 1);
|
||||
// Same pair ⇒ same id, no new conversation created.
|
||||
let c2 = reg.resolve(ConversationParty::User, agent(1));
|
||||
assert_eq!(c.id, c2.id);
|
||||
assert_eq!(reg.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_pair_unordered_yields_same_id() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let c1 = reg.resolve(agent(1), agent(2));
|
||||
let c2 = reg.resolve(agent(2), agent(1)); // swapped order
|
||||
assert_eq!(c1.id, c2.id, "unordered pair identity");
|
||||
assert_eq!(reg.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_pairs_get_distinct_ids() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let user_b = reg.resolve(ConversationParty::User, agent(2));
|
||||
let a_b = reg.resolve(agent(1), agent(2));
|
||||
assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads");
|
||||
assert_eq!(reg.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_resolve_is_dormant() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let c = reg.resolve(ConversationParty::User, agent(1));
|
||||
assert_eq!(c.session, ConversationSession::Dormant);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_session_makes_it_live_then_suspend_restores_dormant() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
let c = reg.resolve(ConversationParty::User, agent(1));
|
||||
let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99)));
|
||||
reg.bind_session(c.id, sref);
|
||||
let live = reg.get(c.id).unwrap();
|
||||
assert!(live.session.is_live());
|
||||
assert_eq!(live.session, ConversationSession::Live { handle_ref: sref });
|
||||
|
||||
reg.suspend(c.id, Some("sess-abc".to_owned()));
|
||||
let dormant = reg.get(c.id).unwrap();
|
||||
assert_eq!(dormant.session, ConversationSession::Dormant);
|
||||
assert_eq!(dormant.resumable_id.as_deref(), Some("sess-abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_unknown_is_none() {
|
||||
let reg = InMemoryConversationRegistry::new();
|
||||
assert!(reg.get(ConversationId::new_random()).is_none());
|
||||
}
|
||||
}
|
||||
230
crates/infrastructure/src/fileguard/mod.rs
Normal file
230
crates/infrastructure/src/fileguard/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
789
crates/infrastructure/src/input/mod.rs
Normal file
789
crates/infrastructure/src/input/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
261
crates/infrastructure/src/mailbox/mod.rs
Normal file
261
crates/infrastructure/src/mailbox/mod.rs
Normal 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))));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }));
|
||||
|
||||
@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user