agent conversation fix

This commit is contained in:
2026-06-27 12:42:37 +02:00
parent c2d1a669c5
commit 1fc7869160
38 changed files with 1173 additions and 1549 deletions

View File

@ -762,29 +762,16 @@ impl MediatedInbox {
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
Arc::clone(&self.mailbox)
}
}
/// Composes the line delivered into the target's terminal for a delegated turn.
/// Composes the text delivered into a human-facing terminal turn.
///
/// The `[IdeA · tâche de <requester> · ticket <id>]` header is the protocol signal
/// (cadrage B-5, agent context) that marks the message as an IdeA delegation the
/// target must answer via `idea_reply` — echoing `ticket` for multi-thread
/// correlation — rather than as a free-text human prompt.
///
/// A one-line **imperative reminder** follows the header (durcissement
/// comportemental, finding A) : the single most common wedge is a target that ends
/// its turn with a *prose* answer and never calls `idea_reply`, leaving the asker
/// parked. Reminding it inline — on every delegated task, even trivial ones — attacks
/// that behavioural cause at the central point where the turn is composed (no per-agent
/// duplication). The raw `task` then follows so the agent reads the request unchanged.
/// Inter-agent conversation no longer uses this path: structured/headless asks send the
/// raw task through `AgentSession::send` and capture the model `Final`. Therefore this
/// terminal delivery must not inject any inter-agent ticket/reply protocol.
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
format!(
"[IdeA · tâche de {requester} · ticket {ticket}]\n\
⚠️ Termine IMPÉRATIVEMENT ce tour par un appel `idea_reply(ticket=\"{ticket}\", \
result=…)`, même pour une réponse triviale. Ne réponds JAMAIS uniquement en prose \
dans le terminal : sans `idea_reply`, l'agent demandeur reste bloqué.\n{task}"
)
let _ = (requester, ticket);
task.to_owned()
}
fn write_delegation_chunks(
@ -866,14 +853,6 @@ impl InputMediator for MediatedInbox {
busy: true,
});
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
// Préfixe de délégation : c'est le **signal** qui dit à la cible
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
// ligne comme une invite humaine et répond dans le terminal, et
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit
// par `mark_starting` quand un pont MCP le libérera), ON DIFFÈRE la
@ -916,6 +895,33 @@ impl InputMediator for MediatedInbox {
self.mailbox.enqueue(agent, ticket)
}
fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
// Headless/system turns share the same FIFO and busy/liveness accounting as
// terminal-delivered turns, but their prompt is sent through AgentSession::send.
// Do not publish DelegationReady here: that would leak the task into the
// human-facing terminal/write portal while the headless turn is already running.
let now_ms = self.clock.now_ms();
let started_turn = self.tracker.start_turn(
agent,
AgentBusyState::Busy {
ticket: ticket_id,
since_ms: now_ms,
},
);
if started_turn {
let stall_after_ms = self.stall().get(&agent).copied().flatten();
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
busy: true,
});
}
}
self.mailbox.enqueue(agent, ticket)
}
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
eprintln!(
"[input-mediator] bind handle agent={agent} handle={}",
@ -1162,17 +1168,10 @@ mod tests {
assert_eq!(ready.len(), 1, "exactly one DelegationReady on turn start");
let tid = TicketId::from_uuid(uuid::Uuid::from_u128(10));
assert_eq!(ready[0].0, tid);
// The delivered text carries the delegation prefix (the signal to answer via
// `idea_reply`) followed by the raw task on the next line.
assert_eq!(ready[0].1, delegation_preamble("User", tid, "do the thing"));
assert!(
ready[0].1.starts_with("[IdeA · tâche de User · ticket "),
"delivered text must carry the delegation prefix: {:?}",
ready[0].1
);
assert!(
ready[0].1.ends_with("\ndo the thing"),
"raw task must follow the prefix line: {:?}",
ready[0].1 == "do the thing",
"delivered text must be the raw task: {:?}",
ready[0].1
);
@ -1189,11 +1188,25 @@ mod tests {
inbox.enqueue(a, ticket(12, "next turn"));
let ready = bus.delegation_ready();
assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady");
assert_eq!(ready[1].1, "next turn");
}
#[test]
fn enqueue_silent_marks_busy_without_delivery() {
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_silent(a, ticket(10, "headless turn"));
assert!(inbox.busy_state(a).is_busy());
assert_eq!(bus.busy_events(), vec![(a, true)]);
assert!(
ready[1].1.ends_with("\nnext turn"),
"second turn delivers its own prefixed task: {:?}",
ready[1].1
bus.delegation_ready().is_empty(),
"headless bookkeeping must not leak a prompt to the terminal"
);
assert_eq!(inbox.mailbox.pending(&a), 1);
}
#[test]
@ -1548,7 +1561,10 @@ mod tests {
.await
.expect("must NOT hang until the long timeout")
.expect("a turn outcome, not a closed channel");
assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply);
assert_eq!(
res,
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
);
assert_eq!(
inbox.mailbox().pending(&a),
0,
@ -1589,7 +1605,10 @@ mod tests {
let pending = inbox.enqueue(a, ticket(10, "task"));
inbox.turn_ended(a);
// turn_ended a marqué Idle et armé la grâce ; on répond pendant la grâce.
assert!(!inbox.busy_state(a).is_busy(), "turn_ended marks idle (grace armed)");
assert!(
!inbox.busy_state(a).is_busy(),
"turn_ended marks idle (grace armed)"
);
inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap();
let res = tokio::time::timeout(Duration::from_secs(2), pending)
@ -1616,7 +1635,10 @@ mod tests {
.await
.expect("no hang")
.expect("outcome");
assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply);
assert_eq!(
res,
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
);
// A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest.
assert!(
@ -1685,11 +1707,7 @@ mod tests {
1,
"release_cold_start ⇒ exactement une DelegationReady"
);
assert!(
ready[0].1.ends_with("\ncold task"),
"le texte différé porte la tâche brute: {:?}",
ready[0].1
);
assert_eq!(ready[0].1, "cold task");
}
/// **Régression de la race cold-start (ordre INVERSE)** : `release_cold_start`
@ -1724,11 +1742,7 @@ mod tests {
1,
"release AVANT enqueue ⇒ exactement une DelegationReady (jamais zéro, jamais deux)"
);
assert!(
ready[0].1.ends_with("\ncold task"),
"le texte livré porte la tâche brute: {:?}",
ready[0].1
);
assert_eq!(ready[0].1, "cold task");
assert!(
inbox.busy_state(a).is_busy(),
"le tour froid livré ⇒ l'agent reste Busy (idle viendra d'idea_reply)"
@ -1798,7 +1812,7 @@ mod tests {
1,
"agent chaud ⇒ DelegationReady immédiate (zéro régression)"
);
assert!(ready[0].1.ends_with("\nwarm task"));
assert_eq!(ready[0].1, "warm task");
}
/// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le
@ -1849,7 +1863,7 @@ mod tests {
2,
"second tour livré immédiatement (plus de gate)"
);
assert!(ready[1].1.ends_with("\nsecond"));
assert_eq!(ready[1].1, "second");
}
// ====================================================================

View File

@ -24,9 +24,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use serde::Deserialize;
use domain::ports::{
ConversationDetails, FileSystem, FsError, InspectError, SessionInspector,
};
use domain::ports::{ConversationDetails, FileSystem, FsError, InspectError, SessionInspector};
use domain::profile::AgentProfile;
use domain::project::ProjectPath;

View File

@ -203,7 +203,10 @@ mod tests {
.ok_or_else(|| FsError::NotFound(path.0.clone()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.files.lock().unwrap().insert(path.0.clone(), data.to_vec());
self.files
.lock()
.unwrap()
.insert(path.0.clone(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
@ -282,7 +285,8 @@ mod tests {
let fs = Arc::new(FakeFs::default());
// Baseline: one pre-existing turn end ⇒ must NOT fire for it.
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let _h = watcher.watch(agent(1), None, cwd, cb);
@ -307,20 +311,25 @@ mod tests {
&transcript_path("engine-1.jsonl"),
&format!("{}{}", line("turn_duration"), line("turn_duration")),
);
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let _h = watcher.watch(agent(1), None, cwd, cb);
// No new turn end appended ⇒ no fire, ever (baseline absorbs the existing ones).
tokio::time::sleep(Duration::from_millis(120)).await;
assert!(log.lock().unwrap().is_empty(), "baseline must absorb pre-existing turn ends");
assert!(
log.lock().unwrap().is_empty(),
"baseline must absorb pre-existing turn ends"
);
}
#[tokio::test]
async fn cold_start_missing_folder_then_first_turn_fires() {
let fs = Arc::new(FakeFs::default());
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let _h = watcher.watch(agent(1), None, cwd, cb);
@ -337,7 +346,8 @@ mod tests {
#[tokio::test]
async fn dropping_handle_stops_polling() {
let fs = Arc::new(FakeFs::default());
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let watcher =
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let h = watcher.watch(agent(1), None, cwd, cb);
@ -347,6 +357,9 @@ mod tests {
tokio::time::sleep(Duration::from_millis(40)).await;
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
tokio::time::sleep(Duration::from_millis(120)).await;
assert!(log.lock().unwrap().is_empty(), "a dropped handle stops firing");
assert!(
log.lock().unwrap().is_empty(),
"a dropped handle stops firing"
);
}
}

View File

@ -48,12 +48,11 @@ pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::{transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher};
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{
resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer,
MemoryTransport, StdioTransport,
pub use inspector::{
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
};
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
REQUESTS_SUBDIR,
@ -78,7 +77,6 @@ pub use store::{
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};

View File

@ -121,25 +121,6 @@ pub mod error_codes {
pub const INVALID_PARAMS: i32 = -32_602;
/// Internal server error (a dispatched IdeA command failed).
pub const INTERNAL_ERROR: i32 = -32_603;
/// **Server-defined** (JSON-RPC reserved range 32000..=32099): the synchronous
/// `idea_ask_agent` rendezvous expired against the server-side safety net without
/// the target ever rendering a reply. **Distinct** from [`INTERNAL_ERROR`] on
/// purpose — it is not an internal fault but a *typed, retryable* outcome
/// (semantics mirror [`application::AppError::TargetReturnedNoReply`]): the caller
/// may re-ask, reminding the target to answer via `idea_reply`. The accompanying
/// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`.
pub const RENDEZVOUS_NO_REPLY: i32 = -32_001;
/// **Server-defined** (JSON-RPC reserved range -32000..=-32099): the synchronous
/// `idea_ask_agent` rendezvous hit the **absolute ceiling** while the target was
/// **still actively working** (its transcript kept growing). **Distinct** from
/// [`RENDEZVOUS_NO_REPLY`]: the target is *not* silent -- it simply outran the hard
/// ceiling. It is therefore **non-retryable** (a blind re-ask would pile a second
/// heavy turn on a target already mid-task); the caller must inspect the target's
/// in-progress work/branch before re-soliciting lightly. The accompanying `data`
/// carries `{ "code": "RENDEZVOUS_CEILING_ACTIVE", "retryable": false }`.
pub const RENDEZVOUS_CEILING_ACTIVE: i32 = -32_002;
}
/// Errors a [`Transport`] may surface.

View File

@ -36,8 +36,6 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use server::{
resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer,
};
pub use server::McpServer;
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -4,8 +4,7 @@
//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the
//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build
//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It
//! invents **no semantics** and **never re-routes** the reply — for `idea_ask_agent`
//! it returns `outcome.reply` inline, exactly what `dispatch` produced.
//! invents **no semantics** and **never re-routes** replies.
//!
//! It implements the strict MCP minimum over JSON-RPC 2.0:
//! - `initialize` → advertise protocol version + tool capability,
@ -17,10 +16,8 @@
//! at the composition root (M3), exactly like the watcher — no application logic is
//! duplicated here.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Instant;
use application::OrchestratorService;
use domain::{DomainEvent, OrchestrationSource, Project};
@ -36,72 +33,6 @@ use super::tools::{self, ToolMapError};
/// The MCP protocol version this server speaks (advertised on `initialize`).
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// **Inactivity window (silence budget)** for the synchronous `idea_ask_agent`
/// rendezvous — the size of one *no-progress* probe window, NOT an absolute cap.
///
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
/// layer). Rather than a flat outer timeout (which wrongly fired on a single long
/// delegated turn that never returns to its prompt nor emits `turn_duration`), the
/// adapter runs an **inactivity watchdog**: it races the dispatch against this window
/// and, on each window expiry, probes whether the target made progress (its transcript
/// grew). Progress ⇒ the window is re-armed (extension) up to the absolute
/// [`ASK_RENDEZVOUS_CEILING`]; genuine silence ⇒ the no-reply error fires. 600 s by
/// default, overridable per project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other
/// tool (`idea_reply`, `idea_list_agents`, …) is left untouched — they don't rendezvous.
///
/// **Fallback (no activity probe).** When no probe is wired (legacy call sites, tests
/// that shrink the bound), this degrades to the previous *flat* timeout: the first
/// window expiry with no probe is treated as a no-reply expiry — zero regression.
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600);
/// **Absolute ceiling (hard cap)** of the `idea_ask_agent` rendezvous, so an extending
/// inactivity window can never park a `tools/call` forever even against a target that
/// stays *busy* indefinitely. Reaching it while the target is still actively working
/// yields the distinct, **non-retryable** [`rendezvous_ceiling_active_error`] (never a
/// blind-retry suggestion). Generous by default (4 h), overridable per project via
/// `IDEA_ASK_RENDEZVOUS_CEILING_MS`.
const ASK_RENDEZVOUS_CEILING: Duration = Duration::from_secs(4 * 60 * 60);
/// A monotonic **activity probe** of a target agent, keyed by its display name.
///
/// Returns an opaque, monotonically non-decreasing **activity token** (in practice the
/// cumulative byte size of the target's transcript `.jsonl` files): a strictly larger
/// token between two probes means the target made progress (it is alive and working,
/// even within a single long turn that never emits `turn_duration`). `None` ⇒ the
/// target/transcript could not be resolved (cold start, unknown name) — treated as "no
/// observable progress" so a truly stuck call still expires. Async because resolving the
/// token reads the filesystem; built at the composition root (the only layer that knows
/// the name→run-dir mapping), keeping this adapter free of `AgentId`.
pub type AskActivityProbe =
Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = Option<u64>> + Send>> + Send + Sync>;
/// Resolves the effective `idea_ask_agent` **inactivity window** from an optional
/// configured override in **milliseconds**: `Some(ms)` ⇒ that window, `None`/absent ⇒
/// the [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is treated as "no override"
/// so a misconfigured zero can never collapse the window to an instant expiry. Pure and
/// unit-testable without wiring.
#[must_use]
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
match override_ms {
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
_ => ASK_RENDEZVOUS_TIMEOUT,
}
}
/// Resolves the effective `idea_ask_agent` **absolute ceiling** from an optional
/// configured override in **milliseconds**: `Some(ms)` ⇒ that ceiling, `None`/absent ⇒
/// the [`ASK_RENDEZVOUS_CEILING`] default (4 h). `Some(0)` is treated as "no override".
/// Pure and unit-testable without wiring.
#[must_use]
pub fn resolve_ask_rendezvous_ceiling(override_ms: Option<u32>) -> Duration {
match override_ms {
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
_ => ASK_RENDEZVOUS_CEILING,
}
}
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
///
/// Cheap to clone the dependencies it holds; one instance serves one project's
@ -130,21 +61,6 @@ pub struct McpServer {
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
/// composition root parse le `requester`. `None` ⇒ no-op.
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
/// **Inactivity window (silence budget)** of the `idea_ask_agent` rendezvous (see
/// [`ASK_RENDEZVOUS_TIMEOUT`]). One probe window, re-armed on observed progress.
/// Carried per instance so tests can shrink it to a few milliseconds without
/// polluting the public API; production code uses the resolved default.
ask_rendezvous_timeout: Duration,
/// **Absolute ceiling** of the `idea_ask_agent` rendezvous (see
/// [`ASK_RENDEZVOUS_CEILING`]): the extending inactivity window never parks a call
/// past this. Per instance so tests can shrink it; production uses the resolved
/// default (4 h).
ask_rendezvous_ceiling: Duration,
/// Optional monotonic **activity probe** of the target (see [`AskActivityProbe`]),
/// injected by the composition root. Drives window extension: progress between two
/// probes re-arms the window. `None` ⇒ no observability ⇒ the rendezvous degrades to
/// a single flat window (legacy behaviour, zero regression).
activity_probe: Option<AskActivityProbe>,
}
impl McpServer {
@ -159,9 +75,6 @@ impl McpServer {
events: None,
requester: String::new(),
ready_sink: None,
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
ask_rendezvous_ceiling: ASK_RENDEZVOUS_CEILING,
activity_probe: None,
}
}
@ -202,51 +115,9 @@ impl McpServer {
events: self.events.clone(),
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
ask_rendezvous_ceiling: self.ask_rendezvous_ceiling,
activity_probe: self.activity_probe.clone(),
}
}
/// Overrides the `idea_ask_agent` **inactivity window** (silence budget, default
/// [`ASK_RENDEZVOUS_TIMEOUT`] = 600 s).
///
/// A genuine **configuration knob** (project/profile level, modelled on the
/// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root
/// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies
/// it here. This is the size of one *no-progress* window: as long as the activity
/// probe sees the target advancing, the window is re-armed up to the absolute
/// [`ASK_RENDEZVOUS_CEILING`]. Absent override ⇒ the 600 s default. Tests also use it
/// to shrink the window to milliseconds.
#[must_use]
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
self.ask_rendezvous_timeout = timeout;
self
}
/// Overrides the `idea_ask_agent` **absolute ceiling** (hard cap, default
/// [`ASK_RENDEZVOUS_CEILING`] = 4 h) past which the extending inactivity window never
/// parks a call, even against a perpetually-busy target. The composition root resolves
/// an optional override with [`resolve_ask_rendezvous_ceiling`]. Tests use it to shrink
/// the ceiling to milliseconds. Additive: callers that don't set it keep the default.
#[must_use]
pub fn with_ask_rendezvous_ceiling(mut self, ceiling: Duration) -> Self {
self.ask_rendezvous_ceiling = ceiling;
self
}
/// Attaches the monotonic **activity probe** (see [`AskActivityProbe`]) driving the
/// inactivity-window extension: between two window expiries, a strictly larger token
/// means the target is alive and working, so the window is re-armed instead of
/// expiring. Built by the composition root (the only layer mapping a display name to a
/// run-dir transcript). Additive: without a probe the rendezvous degrades to a single
/// flat window (legacy behaviour, zero regression).
#[must_use]
pub fn with_activity_probe(mut self, probe: AskActivityProbe) -> Self {
self.activity_probe = Some(probe);
self
}
/// Serves JSON-RPC messages from `transport`, tagging every processed
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
///
@ -264,16 +135,14 @@ impl McpServer {
/// method yields a JSON-RPC error response, **never a panic** and never a
/// dropped connection. Notifications (no `id`) are processed without a reply.
///
/// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks —
/// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a
/// synchronous rendezvous — must **not** stall the read loop: otherwise a single
/// in-flight ask parks the whole connection and every later call (even a
/// rendezvous-free `idea_list_agents`) is never even read. So each inbound message
/// is handled on its own spawned task that owns a cheap per-call clone of the
/// server; finished responses are funnelled back through an `mpsc` channel and
/// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order
/// completion is fine (the full-duplex bridge correlates by id). Notifications
/// (`handle_raw` ⇒ `None`) emit nothing.
/// **Full-duplex / non-blocking.** A slow request must **not** stall the read
/// loop: otherwise a single long-running tool call parks the whole connection and
/// every later call is never even read. So each inbound message is handled on its
/// own spawned task that owns a cheap per-call clone of the server; finished
/// responses are funnelled back through an `mpsc` channel and written by the same
/// loop. Responses carry their JSON-RPC `id`, so out-of-order completion is fine
/// (the full-duplex bridge correlates by id). Notifications (`handle_raw` ⇒
/// `None`) emit nothing.
pub async fn serve<T: Transport>(&self, transport: &mut T) {
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
// Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an
@ -433,8 +302,7 @@ impl McpServer {
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
/// tool result. The `idea_ask_agent` reply is returned **inline** — never
/// re-routed.
/// tool result.
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
let name = params
.get("name")
@ -445,8 +313,6 @@ impl McpServer {
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
// C'est l'amorce de la trace `[mcp]` begin → (armed) → (expired) → end qui
// permet de voir un `idea_ask_agent` entrer sans jamais ressortir (blocage).
let started = Instant::now();
let requester_label = if self.requester.is_empty() {
"mcp".to_owned()
@ -467,8 +333,8 @@ impl McpServer {
task_len={task_len}",
);
// `idea_reply` needs the connected peer's identity as `from`; every other tool
// ignores `requester`. The handshake-provided requester is the source of truth.
// The handshake-provided requester is still passed to the mapper for tools that
// need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
Ok(command) => command,
Err(e) => {
@ -481,39 +347,7 @@ impl McpServer {
}
};
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous (the
// target's `idea_reply`). Bound *only* that path with the **inactivity watchdog**
// (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]):
// race the dispatch against an inactivity window; on each window expiry, probe
// whether the target progressed (transcript grew). Progress ⇒ re-arm the window
// (extension) up to the absolute ceiling; genuine silence ⇒ the retryable no-reply
// error; ceiling reached while still active ⇒ the distinct non-retryable ceiling
// error. Every other tool dispatches unbounded — they never rendezvous.
let dispatch = self.service.dispatch(&self.project, command);
let result = if name == "idea_ask_agent" {
application::diag!(
"[mcp] ask armed requester={requester_label} target={arg_target} \
task_len={task_len} window_ms={} ceiling_ms={}",
self.ask_rendezvous_timeout.as_millis(),
self.ask_rendezvous_ceiling.as_millis(),
);
match self
.run_ask_rendezvous(dispatch, &arg_target, &requester_label, started)
.await
{
// RISQUE #1 (Architect) : sur NoReply/CeilingActive, le futur `dispatch` a déjà
// été **droppé** dans `run_ask_rendezvous` (il le possède et le pin), ce Drop
// libère le `BusyTurnGuard` RAII et purge le `Busy`/ticket de la cible (aucune
// fuite `Busy`).
AskRendezvousOutcome::Resolved(result) => result,
AskRendezvousOutcome::NoReply => return Err(rendezvous_no_reply_error(&arg_target)),
AskRendezvousOutcome::CeilingActive => {
return Err(rendezvous_ceiling_active_error(&arg_target))
}
}
} else {
dispatch.await
};
let result = self.service.dispatch(&self.project, command).await;
// Surface the processed delegation on the bus, tagged as the MCP door — the
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
@ -572,77 +406,6 @@ impl McpServer {
});
}
}
/// Drives the `idea_ask_agent` synchronous rendezvous under the **inactivity
/// watchdog** (cf. [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]).
///
/// Thin adapter over the testable free function [`run_inactivity_watchdog`]: it
/// supplies this server's window, ceiling and (optional) activity probe, and maps the
/// watchdog verdict to an [`AskRendezvousOutcome`]. With no probe wired the watchdog
/// degrades to a single flat window (legacy behaviour, zero regression).
async fn run_ask_rendezvous(
&self,
dispatch: impl Future<Output = Result<application::OrchestratorOutcome, application::AppError>>,
target: &str,
requester_label: &str,
started: Instant,
) -> AskRendezvousOutcome {
let probe = self.activity_probe.clone();
let target_owned = target.to_owned();
let probe_fn = move || {
let probe = probe.clone();
let target = target_owned.clone();
async move {
match &probe {
Some(p) => p(target).await,
None => None,
}
}
};
// DRY : on réutilise l'**unique** implémentation du watchdog (couche application),
// la même que celle qui gouverne réellement le tour délégué dans le service. Ici
// c'est le filet de sécurité externe de l'adaptateur MCP.
let window_ms = self.ask_rendezvous_timeout.as_millis();
let ceiling_ms = self.ask_rendezvous_ceiling.as_millis();
let (t_ext, r_ext) = (target.to_owned(), requester_label.to_owned());
let (t_exp, r_exp) = (target.to_owned(), requester_label.to_owned());
let (t_ceil, r_ceil) = (target.to_owned(), requester_label.to_owned());
match application::run_inactivity_watchdog(
dispatch,
self.ask_rendezvous_timeout,
self.ask_rendezvous_ceiling,
started,
self.activity_probe.is_some(),
probe_fn,
move |elapsed| {
application::diag!(
"[mcp] ask window extended (signe de vie) requester={r_ext} target={t_ext} \
window_ms={window_ms} elapsed_ms={}",
elapsed.as_millis(),
);
},
move |elapsed| {
application::diag!(
"[mcp] ask EXPIRED (no progress) requester={r_exp} target={t_exp} \
window_ms={window_ms} elapsed_ms={}",
elapsed.as_millis(),
);
},
move |elapsed| {
application::diag!(
"[mcp] ask CEILING active requester={r_ceil} target={t_ceil} \
ceiling_ms={ceiling_ms} elapsed_ms={}",
elapsed.as_millis(),
);
},
)
.await
{
application::WatchdogOutcome::Resolved(r) => AskRendezvousOutcome::Resolved(r),
application::WatchdogOutcome::NoReply => AskRendezvousOutcome::NoReply,
application::WatchdogOutcome::CeilingActive => AskRendezvousOutcome::CeilingActive,
}
}
}
/// Builds an MCP `tools/call` result with a single text content block.
@ -653,71 +416,6 @@ fn tool_result_text(text: &str, is_error: bool) -> Value {
})
}
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous expires
/// against the server-side safety net (the `Elapsed` branch of the outer `timeout`).
///
/// Mirrors [`application::AppError::TargetReturnedNoReply`] one-for-one — same message
/// and same machine-readable `code` (`"TARGET_RETURNED_NO_REPLY"`), flagged `retryable`
/// — but as a JSON-RPC protocol error under the **distinct**
/// [`error_codes::RENDEZVOUS_NO_REPLY`] code (never the opaque `INTERNAL_ERROR`), since
/// at expiry no [`OrchestratorOutcome`](application::OrchestratorOutcome) exists to fold
/// into a tool result. The `data` object lets a caller branch without parsing the
/// message.
fn rendezvous_no_reply_error(target: &str) -> JsonRpcError {
let message = format!(
"agent {target} returned to its prompt without calling idea_reply (no answer was \
rendered) before the rendezvous timeout; retry the request and ensure the agent \
replies via idea_reply"
);
JsonRpcError {
code: error_codes::RENDEZVOUS_NO_REPLY,
message,
data: Some(json!({
"code": "TARGET_RETURNED_NO_REPLY",
"retryable": true,
"target": target,
})),
}
}
/// Outcome of the inactivity-watchdog rendezvous ([`McpServer::run_ask_rendezvous`]),
/// folded into a `tools/call` result by the caller.
enum AskRendezvousOutcome {
/// The dispatch completed (success or a typed `AppError`) — returned unchanged.
Resolved(Result<application::OrchestratorOutcome, application::AppError>),
/// The inactivity window elapsed with no observable progress ⇒ the retryable
/// [`rendezvous_no_reply_error`]. Also the flat-window fallback when no probe is wired.
NoReply,
/// The absolute ceiling was reached while the target was still actively working ⇒ the
/// distinct, non-retryable [`rendezvous_ceiling_active_error`].
CeilingActive,
}
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous reaches the
/// **absolute ceiling** while the target is **still actively working** (its transcript
/// kept growing across probes).
///
/// **Distinct** from [`rendezvous_no_reply_error`]: the target is not silent, so a blind
/// retry would stack a second heavy turn on a target already mid-task. Carried under the
/// dedicated [`error_codes::RENDEZVOUS_CEILING_ACTIVE`] code with `data.retryable = false`
/// so a caller can branch without parsing the message.
fn rendezvous_ceiling_active_error(target: &str) -> JsonRpcError {
let message = format!(
"target {target} is still actively working but the rendezvous ceiling was reached; \
do not retry blindly — check the target's in-progress work/branch (it may still \
finish and call idea_reply), then re-solicit lightly if needed"
);
JsonRpcError {
code: error_codes::RENDEZVOUS_CEILING_ACTIVE,
message,
data: Some(json!({
"code": "RENDEZVOUS_CEILING_ACTIVE",
"retryable": false,
"target": target,
})),
}
}
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
match err {
@ -729,49 +427,3 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
// NOTE: the inactivity-watchdog **algorithm** now lives in (and is unit-tested by)
// the `application` crate (`application::run_inactivity_watchdog`), the single source
// of truth that also governs the delegated turn. This adapter only maps its verdict to
// an `AskRendezvousOutcome`, so we keep here only the adapter-specific concerns: the
// error builders and the override resolvers.
/// The two error builders carry distinct JSON-RPC codes and the documented
/// `data.retryable` flags (no-reply ⇒ retryable; ceiling-active ⇒ not).
#[test]
fn error_builders_carry_distinct_codes_and_retryable_flags() {
let no_reply = rendezvous_no_reply_error("architect");
assert_eq!(no_reply.code, error_codes::RENDEZVOUS_NO_REPLY);
assert_eq!(no_reply.data.as_ref().unwrap()["retryable"], serde_json::json!(true));
assert_eq!(
no_reply.data.as_ref().unwrap()["code"],
serde_json::json!("TARGET_RETURNED_NO_REPLY")
);
let ceiling = rendezvous_ceiling_active_error("architect");
assert_eq!(ceiling.code, error_codes::RENDEZVOUS_CEILING_ACTIVE);
assert_eq!(ceiling.data.as_ref().unwrap()["retryable"], serde_json::json!(false));
assert_eq!(
ceiling.data.as_ref().unwrap()["code"],
serde_json::json!("RENDEZVOUS_CEILING_ACTIVE")
);
assert_ne!(no_reply.code, ceiling.code);
}
/// The resolvers honour an override and fall back to their finite defaults; `Some(0)`
/// is treated as "no override" (never an instant collapse).
#[test]
fn resolvers_apply_override_else_finite_default() {
assert_eq!(resolve_ask_rendezvous_timeout(Some(1234)), Duration::from_millis(1234));
assert_eq!(resolve_ask_rendezvous_timeout(Some(0)), ASK_RENDEZVOUS_TIMEOUT);
assert_eq!(resolve_ask_rendezvous_timeout(None), ASK_RENDEZVOUS_TIMEOUT);
assert_eq!(resolve_ask_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999));
assert_eq!(resolve_ask_rendezvous_ceiling(Some(0)), ASK_RENDEZVOUS_CEILING);
assert_eq!(resolve_ask_rendezvous_ceiling(None), ASK_RENDEZVOUS_CEILING);
}
}

View File

@ -8,12 +8,17 @@
//! validation — the very same path the filesystem watcher takes. No tool
//! validates anything itself; no tool reaches a use case directly.
//!
//! ## Agent discovery (`idea_list_agents`)
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
//!
//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is
//! served through the **same** `OrchestratorService::dispatch` as every other tool
//! (no use-case is reached directly). Its success carries the agent list inline as
//! a JSON array in the outcome's reply — see [`tool_returns_reply`].
//!
//! Delegation maps to [`OrchestratorCommand::AskAgent`]. The MCP tool is only an
//! ergonomic IdeA entrypoint: the application orchestrator drives the target's
//! structured/headless session and returns the target turn's `Final` inline. The
//! target never receives a model-managed ticket and never has to call `idea_reply`.
use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
use serde_json::{json, Value};
@ -44,9 +49,8 @@ pub enum ToolMapError {
}
/// Whether a successful call of `tool` carries an inline reply payload back to the
/// 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.
/// caller. `idea_ask_agent` is a synchronous delegation tool: its payload is the
/// target agent's captured final answer.
#[must_use]
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(
@ -79,35 +83,20 @@ pub fn catalogue() -> Vec<ToolDef> {
},
ToolDef {
name: "idea_ask_agent",
description: "Ask another IdeA agent a task and wait for its reply (synchronous \
inter-agent rendezvous). Returns the target agent's answer inline.",
description: "Ask another IdeA agent to handle a task and wait for its final answer. \
IdeA launches or reattaches the target through its structured/headless \
profile, captures the target turn's Final, and returns it inline. The \
target does not call a reply tool or manage tickets.",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target agent display name." },
"task": { "type": "string", "description": "The task/message to send." }
"task": { "type": "string", "description": "Task or question to send to the target agent." }
},
"required": ["target", "task"],
"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 \
@ -275,9 +264,9 @@ pub fn catalogue() -> Vec<ToolDef> {
///
/// `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.
/// cadrage v5 §1.4): `idea_ask_agent` carries it as `requestedBy` for A↔B routing
/// and cycle detection. Self-keyed tools also use it as their identity; the model
/// never supplies another agent's id.
///
/// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
@ -303,27 +292,6 @@ pub fn map_tool_call(
request_type: Some("agent.list".to_owned()),
..base()
},
"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"),
@ -333,6 +301,13 @@ pub fn map_tool_call(
node_id: parse_node_id(args.get("nodeId")),
..base()
},
"idea_ask_agent" => OrchestratorRequest {
request_type: Some("agent.message".to_owned()),
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
task: s("task"),
..base()
},
"idea_stop_agent" => OrchestratorRequest {
request_type: Some("agent.stop".to_owned()),
target_agent: s("target"),
@ -408,33 +383,7 @@ pub fn map_tool_call(
other => return Err(ToolMapError::UnknownTool(other.to_owned())),
};
let command = request.validate()?;
// Diagnostics mapping beacon (best-effort) pour les deux tools du rendezvous :
// `idea_ask_agent` et `idea_reply`. Longueurs et présence de ticket uniquement —
// jamais le corps `task`/`result` ni de secret. Permet de corréler un `[mcp]`
// begin avec la commande effectivement construite (kind/target/requester).
match name {
"idea_ask_agent" => application::diag!(
"[mcp] mapped kind=ask_agent target={} task_len={} requester={}",
s("target").as_deref().unwrap_or("-"),
s("task").map_or(0, |t| t.len()),
if requester.is_empty() { "-" } else { requester },
),
"idea_reply" => application::diag!(
"[mcp] mapped kind=reply result_len={} ticket={} requester={}",
s("result").map_or(0, |r| r.len()),
if s("ticket").is_some() {
"present"
} else {
"absent"
},
if requester.is_empty() { "-" } else { requester },
),
_ => {}
}
Ok(command)
request.validate().map_err(ToolMapError::Invalid)
}
/// An all-`None` request to spread over; keeps each arm above to just its fields.
@ -487,21 +436,39 @@ mod tests {
}
#[test]
fn ask_agent_maps_to_ask_command() {
let cmd = map(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
)
.unwrap();
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
let requester = uuid::Uuid::from_u128(42).to_string();
assert_eq!(
cmd,
map_tool_call(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
&requester,
)
.unwrap(),
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `map` passes an empty requester ⇒ no machine requester carried.
requester: None,
requester: Some(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(&requester).unwrap()
)),
}
);
assert_eq!(
map_tool_call(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
"",
),
Ok(OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
requester: None,
})
);
assert_eq!(
map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ),
Err(ToolMapError::UnknownTool("idea_reply".to_owned()))
);
}
#[test]
@ -553,7 +520,7 @@ mod tests {
#[test]
fn missing_required_field_surfaces_validation_error() {
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
let err = map("idea_update_context", &json!({ "target": "Architect" }));
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
@ -574,84 +541,13 @@ mod tests {
#[test]
fn non_object_arguments_are_rejected() {
let err = map("idea_ask_agent", &json!("nope"));
let err = map("idea_launch_agent", &json!("nope"));
assert_eq!(
err,
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
Err(ToolMapError::BadArguments("idea_launch_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.
@ -721,8 +617,8 @@ mod tests {
}
#[test]
fn reply_is_not_an_inline_reply_tool() {
// ACK only: the result is routed to the awaiting requester, not echoed.
fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
assert!(tool_returns_reply("idea_ask_agent"));
assert!(!tool_returns_reply("idea_reply"));
}

View File

@ -165,9 +165,9 @@ impl CodexExecSession {
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
/// <prompt>`.
/// - Reprise (id connu) : `codex exec --json
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root> resume
/// <thread_id> <prompt>`.
///
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
@ -178,10 +178,7 @@ impl CodexExecSession {
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
let mut args = vec!["exec".to_owned()];
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
args.push("resume".to_owned());
args.push(id.clone());
}
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
args.push("--json".to_owned());
args.push("--skip-git-repo-check".to_owned());
args.push("--sandbox".to_owned());
@ -190,6 +187,10 @@ impl CodexExecSession {
args.push("--add-dir".to_owned());
args.push(root.clone());
}
if let Some(id) = conversation_id {
args.push("resume".to_owned());
args.push(id);
}
args.push(prompt.to_owned());
SpawnLine {
command: self.command.clone(),

View File

@ -1020,8 +1020,8 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
/// Idem Codex : `codex exec resume <thread_id> --json --skip-git-repo-check <prompt>`
/// (format RÉEL : sous-commande `resume`, pas un flag `--resume`).
/// Idem Codex : `codex exec --json --skip-git-repo-check resume <thread_id> <prompt>`.
/// Les options globales de `exec` doivent précéder la sous-commande `resume`.
#[tokio::test]
async fn codex_resume_command_carries_resume_subcommand() {
let (cmd, argv) = make_recording_fake(&[
@ -1045,6 +1045,21 @@ mod tests {
assert!(args.contains(&"--json"), "vu: {args:?}");
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
assert!(args.contains(&"vas-y"), "vu: {args:?}");
let resume_pos = args.iter().position(|a| *a == "resume").expect("resume");
let id_pos = args.iter().position(|a| *a == "cx-id").expect("id");
let json_pos = args.iter().position(|a| *a == "--json").expect("json");
let skip_pos = args
.iter()
.position(|a| *a == "--skip-git-repo-check")
.expect("skip");
assert!(
json_pos < resume_pos && resume_pos < id_pos,
"exec options must precede resume, then the session id, vu: {args:?}"
);
assert!(
skip_pos < resume_pos && resume_pos < id_pos,
"exec options must precede resume, then the session id, vu: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
@ -1311,7 +1326,7 @@ mod tests {
// d'écriture Codex : la commande générée porte EXACTEMENT
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
// --add-dir, <project-root>, <prompt>]
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
// (`resume <id>` après les options `exec` pour une reprise). Le flag `--ask-for-approval never`
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
// qu'aucune régression ne réintroduise un flag inconnu de la sous-commande.
@ -1358,9 +1373,9 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
/// REPRISE (seed d'id) : argv EXACT `[exec, --json, --skip-git-repo-check,
/// --sandbox, workspace-write, --add-dir, <project-root>, resume, <id>, <prompt>]`.
/// Toujours pas de `--ask-for-approval`.
#[tokio::test]
async fn codex_resume_command_carries_exact_args() {
let (cmd, argv) = make_recording_fake(&[
@ -1383,17 +1398,17 @@ mod tests {
args,
vec![
"exec",
"resume",
"cx-id",
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"resume",
"cx-id",
"vas-y",
],
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
"argv reprise doit être exact (options exec avant resume, sans --ask-for-approval), vu: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);

View File

@ -286,4 +286,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
FsEmbedderProfileStore::delete(self, id).await
}
}