feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP

Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

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

View File

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