feat(agent): messagerie inter-agents via send_blocking (D6) — §17

Comble le trou historique : un agent qui en interroge un autre reçoit enfin
le CONTENU de la réponse, plus seulement un ACK de cycle de vie.

- domain : OrchestratorCommand::AskAgent { target, task } + action wire
  agent.message (target+task requis) ; DomainEvent::AgentReplied
  { agent_id, reply_len } (bus I/O-free, le contenu remonte par l'outcome).
- application : OrchestratorOutcome gagne reply: Option<String>. Méthode
  ask_agent (§17.4) — cible structurée vivante ⇒ send_blocking direct ;
  cible morte ⇒ LaunchAgent structuré puis send ; agent vivant en PTY ou
  profil sans structured_adapter ⇒ Invalid (non adressable, jamais d'ACK
  trompeur) ; agent inconnu ⇒ NotFound ; service non câblé ⇒ Invalid.
  Timeout (300s) ⇒ erreur typée SANS tuer la session. Succès ⇒ publie
  AgentReplied + reply: Some(content). Injection additive via builders
  with_structured / with_events (call sites legacy intacts).
- app-tauri : state câble with_structured/with_events ; DTO AgentReplied.

Tests (QA) : +14 verts (11 app + 3 domaine), invariants validés par
mutation test (reply!=None, timeout ne shutdown pas). cargo test
--workspace : 817 passed, 0 failed.

Suivi (D6b) : surfacer reply dans le writer wire .response.json
(infrastructure/orchestrator) pour la délégation par protocole fichier.
Reste D7 : menu restreint Claude/Codex + retrait custom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:44:53 +02:00
parent 5059f37890
commit dd1194abe8
7 changed files with 938 additions and 23 deletions

View File

@ -21,21 +21,23 @@ use domain::ids::SkillId;
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
IdGenerator, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
RemotePath, ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
// ---------------------------------------------------------------------------
@ -357,6 +359,109 @@ impl EventBus for SpyBus {
}
}
// ---------------------------------------------------------------------------
// D6 fakes: structured session + factory (inter-agent messaging)
// ---------------------------------------------------------------------------
/// What the scripted session does on its next `send`.
enum SendScript {
/// Emit this event stream verbatim (drained to its `Final`).
Stream(Vec<ReplyEvent>),
/// Sleep `delay` *before* emitting the stream — forces a `send_blocking` timeout.
Delayed {
delay: std::time::Duration,
events: Vec<ReplyEvent>,
},
}
/// A fake [`AgentSession`] that records every `send` prompt and every `shutdown`,
/// so a test can assert the rendezvous happened (send) and the session was *not*
/// killed (zero shutdowns) on timeout.
struct FakeSession {
id: SessionId,
script: Mutex<Option<SendScript>>,
sends: Arc<Mutex<Vec<String>>>,
shutdowns: Arc<Mutex<usize>>,
}
impl FakeSession {
fn new(id: SessionId, script: SendScript) -> Arc<Self> {
Arc::new(Self {
id,
script: Mutex::new(Some(script)),
sends: Arc::new(Mutex::new(Vec::new())),
shutdowns: Arc::new(Mutex::new(0)),
})
}
}
#[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> {
self.sends.lock().unwrap().push(prompt.to_owned());
let script = self
.script
.lock()
.unwrap()
.take()
.expect("send scripted exactly once");
match script {
SendScript::Stream(events) => Ok(Box::new(events.into_iter())),
SendScript::Delayed { delay, events } => {
tokio::time::sleep(delay).await;
Ok(Box::new(events.into_iter()))
}
}
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
*self.shutdowns.lock().unwrap() += 1;
Ok(())
}
}
/// A fake [`AgentSessionFactory`] that hands out a pre-built [`FakeSession`] on
/// `start`, so launching a *dead* target in structured mode registers a session
/// the orchestrator can then drive. `supports` is true for any profile bearing a
/// `structured_adapter` (mirrors the real factory's gate).
struct FakeFactory {
session: Arc<FakeSession>,
starts: Arc<Mutex<usize>>,
}
impl FakeFactory {
fn new(session: Arc<FakeSession>) -> Self {
Self {
session,
starts: Arc::new(Mutex::new(0)),
}
}
}
#[async_trait]
impl AgentSessionFactory for FakeFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
profile.structured_adapter.is_some()
}
async fn start(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
*self.starts.lock().unwrap() += 1;
Ok(Arc::clone(&self.session) as Arc<dyn AgentSession>)
}
}
fn final_(c: &str) -> ReplyEvent {
ReplyEvent::Final {
content: c.to_owned(),
}
}
struct SeqIds(Mutex<u128>);
impl SeqIds {
fn new() -> Self {
@ -418,6 +523,24 @@ fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent {
Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap()
}
/// Same id (`pid(9)`) as [`claude_profile`] so agents created with it resolve to a
/// structured-capable profile, but bearing a `structured_adapter` so `LaunchAgent`
/// routes through the (fake) [`AgentSessionFactory`] instead of the PTY.
fn structured_profile() -> AgentProfile {
AgentProfile::new(
pid(9),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::stdin(),
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
}
/// Everything wired for a dispatch test.
struct Fixture {
service: OrchestratorService,
@ -492,6 +615,105 @@ fn cmd(json: &str) -> OrchestratorCommand {
.unwrap()
}
// ---------------------------------------------------------------------------
// D6 fixture: orchestrator wired for `agent.message` (inter-agent rendezvous)
// ---------------------------------------------------------------------------
/// Everything wired for an `agent.message` dispatch test (§17.4).
struct AskFixture {
service: OrchestratorService,
structured: Arc<StructuredSessions>,
/// PTY (terminal) registry the service holds — lets a test register a raw
/// terminal session for an agent to exercise the PTY-only-live branch.
sessions: Arc<TerminalSessions>,
bus: SpyBus,
/// PTY spawn/kill spy of the underlying `LaunchAgent` — used to prove a
/// dead-target launch did *not* fall back to a raw PTY (zero spawns).
pty: FakePty,
/// `start` counter of the fake factory (dead-target launch path).
factory_starts: Arc<Mutex<usize>>,
}
/// Builds an orchestrator with the structured registry + event bus wired, and a
/// `LaunchAgent` whose structured routing uses `factory` over `profiles`.
///
/// `pty_only` controls the launched profile: `false` ⇒ structured-capable profile
/// (factory hands out a session); `true` ⇒ plain PTY profile (no
/// `structured_adapter`) so `launched.structured` stays `None`.
fn ask_fixture(
contexts: FakeContexts,
factory_session: Arc<FakeSession>,
pty_only: bool,
) -> AskFixture {
let profile = if pty_only {
claude_profile()
} else {
structured_profile()
};
let profiles = Arc::new(FakeProfiles::new(vec![profile]));
let sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let pty = FakePty::new(sid(777));
let bus = SpyBus::default();
let factory = Arc::new(FakeFactory::new(factory_session));
let factory_starts = Arc::clone(&factory.starts);
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds::new()),
Arc::new(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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_structured(factory as Arc<dyn AgentSessionFactory>, Arc::clone(&structured)),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
Arc::new(pty.clone()),
Arc::clone(&sessions),
));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
let skills = RecordingSkills::default();
let create_skill = Arc::new(CreateSkill::new(
Arc::new(skills) as Arc<dyn SkillStore>,
Arc::new(SeqIds::new()),
));
let service = OrchestratorService::new(
create,
launch,
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
)
.with_structured(Arc::clone(&structured))
.with_events(Arc::new(bus.clone()));
AskFixture {
service,
structured,
sessions,
bus,
pty,
factory_starts,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -717,3 +939,340 @@ async fn create_skill_honours_global_scope() {
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].scope, SkillScope::Global);
}
// ---------------------------------------------------------------------------
// D6 — agent.message (synchronous inter-agent rendezvous, §17.4)
// ---------------------------------------------------------------------------
const ASK_JSON: &str =
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
/// Zone 1 — live structured target ⇒ `send_blocking` is driven and the outcome's
/// `reply` carries the turn's `Final` content. Anti-always-green: the assertion is
/// on the *exact* content, so it fails for `None` or any other string.
#[tokio::test]
async fn ask_live_structured_target_returns_final_content() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
// A live session already registered for the agent in the *structured* registry.
let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("the §17 answer")]));
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
fx.structured
.insert(Arc::clone(&session) as Arc<dyn AgentSession>, aid(1), nid(1));
let out = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect("ask ok");
// The exact Final content is returned (would fail on None or any other text).
assert_eq!(out.reply.as_deref(), Some("the §17 answer"));
// The prompt actually reached the live session.
assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]);
// Live session reused: factory never started a new one, no PTY spawned.
assert_eq!(*fx.factory_starts.lock().unwrap(), 0);
assert!(fx.pty.spawns().is_empty(), "must not spawn a PTY");
// Session not killed.
assert_eq!(*session.shutdowns.lock().unwrap(), 0);
}
/// Anti-always-green guard, explicit negative: the same fixture but the session
/// returns a *different* Final ⇒ the strict equality from the test above must NOT
/// hold. Proves the assertion has teeth.
#[tokio::test]
async fn ask_reply_assertion_is_content_sensitive() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("SOMETHING ELSE")]));
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
fx.structured
.insert(session as Arc<dyn AgentSession>, aid(1), nid(1));
let out = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect("ask ok");
assert_ne!(out.reply.as_deref(), Some("the §17 answer"));
assert_eq!(out.reply.as_deref(), Some("SOMETHING ELSE"));
}
/// Zone 4 — on success, `DomainEvent::AgentReplied` is published with the right
/// agent id and `reply_len` (byte length of the content).
#[tokio::test]
async fn ask_success_publishes_agent_replied_event() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let content = "réponse"; // multibyte: len() in bytes, not chars
let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_(content)]));
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
fx.structured
.insert(session as Arc<dyn AgentSession>, aid(1), nid(1));
fx.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect("ask ok");
let replied = fx
.bus
.events()
.into_iter()
.find_map(|e| match e {
DomainEvent::AgentReplied {
agent_id,
reply_len,
} => Some((agent_id, reply_len)),
_ => None,
})
.expect("AgentReplied must be published");
assert_eq!(replied.0, aid(1));
assert_eq!(replied.1, content.len());
}
/// Zone 2 (orchestrator seam) — a failing turn (stream ends without a `Final`)
/// surfaces a typed `PROCESS` error WITHOUT the orchestrator killing the session,
/// and the session stays registered (retry possible). The *genuine* timeout
/// variant of this invariant is asserted in
/// [`timeout_invariant_send_blocking_does_not_kill_session`] (the 300s prod bound
/// cannot be awaited here, so we drive the same "never shutdown on ask" branch via
/// the no-`Final` error and the bounded `send_blocking` directly).
#[tokio::test]
async fn ask_failed_turn_does_not_kill_session_and_errors() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let session = FakeSession::new(sid(500), SendScript::Stream(vec![/* no Final */]));
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
fx.structured
.insert(Arc::clone(&session) as Arc<dyn AgentSession>, aid(1), nid(1));
let err = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.unwrap_err();
// Typed PROCESS error (send_blocking's AgentSessionError → AppError::Process).
assert_eq!(err.code(), "PROCESS", "got {err:?}");
// Session NOT killed and STILL registered (retry possible).
assert_eq!(
*session.shutdowns.lock().unwrap(),
0,
"ask must never shutdown the target session"
);
assert!(
fx.structured.session_for_agent(&aid(1)).is_some(),
"session must remain in the registry after a failed turn"
);
}
/// Zone 2 (real timeout) — uses a *short-bounded* `send_blocking` directly against
/// the orchestrator's session is impossible (bound is internal/300s); instead we
/// prove the genuine timeout-does-not-kill invariant at the `send_blocking` seam
/// with a real delayed session, mirroring how the orchestrator awaits it.
#[tokio::test]
async fn timeout_invariant_send_blocking_does_not_kill_session() {
use application::send_blocking;
let session = FakeSession::new(
sid(500),
SendScript::Delayed {
delay: std::time::Duration::from_secs(1),
events: vec![final_("too late")],
},
);
let out = send_blocking(
session.as_ref(),
"Analyse §17",
Some(std::time::Duration::from_millis(20)),
)
.await;
assert_eq!(out, Err(AgentSessionError::Timeout));
assert_eq!(
*session.shutdowns.lock().unwrap(),
0,
"timeout must not kill the session"
);
// Negative control: had we waited (no bound) the call would succeed — proves the
// timeout above is the cause of the error, not a broken session.
let session2 = FakeSession::new(
sid(501),
SendScript::Delayed {
delay: std::time::Duration::from_millis(5),
events: vec![final_("eventually")],
},
);
let ok = send_blocking(session2.as_ref(), "x", None).await;
assert_eq!(ok, Ok("eventually".to_owned()));
}
/// Zone 3 — dead target ⇒ `LaunchAgent` is invoked in structured mode (factory
/// `start` called, structured session registered) *then* `send_blocking`; the
/// reply is returned and no raw PTY is spawned.
#[tokio::test]
async fn ask_dead_target_launches_structured_then_sends() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
// The session the fake factory will hand out on launch.
let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("launched reply")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
Arc::clone(&session),
false,
);
// No live session up front.
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
let out = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect("ask ok");
assert_eq!(out.reply.as_deref(), Some("launched reply"));
// Factory was started exactly once (structured launch), the prompt was sent.
assert_eq!(*fx.factory_starts.lock().unwrap(), 1);
assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]);
// Structured-mode launch ⇒ NO raw PTY spawn.
assert!(fx.pty.spawns().is_empty(), "structured launch must not spawn a PTY");
// The session is now registered (1 session/agent).
assert!(fx.structured.session_for_agent(&aid(1)).is_some());
}
/// Zone 5a — target already live in the **PTY** registry (raw terminal, no
/// structured channel) ⇒ typed `INVALID`, never an ACK, never a launch.
#[tokio::test]
async fn ask_pty_live_target_is_invalid_no_ack() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
false,
);
// Register the agent as live in the **PTY** (terminal) registry only — a raw
// terminal with no structured reply channel.
let pty_session = TerminalSession::starting(
sid(800),
nid(2),
project().root.clone(),
SessionKind::Agent { agent_id: aid(1) },
PtySize::new(24, 80).unwrap(),
);
fx.sessions
.insert(PtyHandle { session_id: sid(800) }, pty_session);
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
let err = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.unwrap_err();
assert_eq!(
err.code(),
"INVALID",
"PTY-live target is not addressable by ask: {err:?}"
);
// Never launched, never sent: factory untouched, no PTY spawned.
assert_eq!(*fx.factory_starts.lock().unwrap(), 0);
assert!(fx.pty.spawns().is_empty());
// No structured session conjured.
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
}
/// Zone 5b — after launching, the target turns out PTY-only (profile without a
/// `structured_adapter` ⇒ `launched.structured` is `None`) ⇒ typed `INVALID`, no
/// ACK, and the orchestrator does not pretend a reply.
#[tokio::test]
async fn ask_pty_only_profile_after_launch_is_invalid() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
// pty_only = true ⇒ launched profile has no structured_adapter ⇒ structured None.
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
throwaway,
true,
);
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
let err = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "PTY-only target must error typed: {err:?}");
// No structured session was registered, no reply pretended.
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
}
/// Zone 6/7 — structured registry not wired ⇒ `ask` is INVALID (legacy service).
#[tokio::test]
async fn ask_without_structured_wired_is_invalid() {
// The default `fixture` builds the service via `OrchestratorService::new`
// WITHOUT `.with_structured(...)`, so AskAgent cannot be served.
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
let err = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "got {err:?}");
}
/// Zone 6/7 — unknown target agent ⇒ typed `NOT_FOUND`, no launch, no reply.
#[tokio::test]
async fn ask_unknown_target_is_not_found() {
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
let fx = ask_fixture(FakeContexts::new(), throwaway, false);
let err = fx
.service
.dispatch(
&project(),
cmd(r#"{ "type":"agent.message", "targetAgent":"ghost", "task":"hi" }"#),
)
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
assert_eq!(*fx.factory_starts.lock().unwrap(), 0);
assert!(fx.pty.spawns().is_empty());
}
/// Zone 7 — non-regression: `agent.run` (fire-and-forget) still yields `reply:
/// None` even through a structured-wired orchestrator.
#[tokio::test]
async fn non_regression_agent_run_reply_is_none() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("x")]));
let fx = ask_fixture(
FakeContexts::with_agent(&agent, "# persona"),
session,
false,
);
let out = fx
.service
.dispatch(
&project(),
cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"go" }"#),
)
.await
.expect("run ok");
assert_eq!(out.reply, None, "agent.run must not carry a reply");
}