chore(wip): checkpoint P8/C avant chantier Codex inter-agents

Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:53 +02:00
parent 4509f0db9d
commit fdcf16c387
76 changed files with 3783 additions and 1404 deletions

View File

@ -191,11 +191,7 @@ impl HandoffStore for FsHandoffStore {
deserialize(&content).map(Some)
}
async fn save(
&self,
conversation: ConversationId,
handoff: Handoff,
) -> Result<(), StoreError> {
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
let body = serialize(&handoff);
let dir = self.conversation_dir(conversation);

View File

@ -25,8 +25,8 @@ use std::sync::{Arc, Mutex};
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::input::{AgentBusyState, InputMediator};
use domain::mailbox::{AgentMailbox, PendingReply, Ticket};
use domain::input::{AgentBusyState, InputMediator, SubmitConfig};
use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId};
use domain::ports::{EventBus, PtyHandle, PtyPort};
use crate::mailbox::InMemoryMailbox;
@ -122,10 +122,13 @@ impl MillisClock for SystemMillisClock {
/// In-memory mediated inbox: one FIFO per agent (the mailbox) plus busy state.
///
/// When a [`PtyPort`] is wired (cadrage C3 §5.2), `enqueue` **delivers** the turn —
/// it writes the prefixed task line into the agent's bound [`PtyHandle`]. This is the
/// single, serialized write path that replaces the orchestrator's former ad-hoc PTY
/// write (no more `[IdeA · tâche …]` line emitted from `ask_agent`, no `\r` band-aid).
/// Since ARCHITECTURE §20, `enqueue` **no longer writes the turn into the PTY** (the
/// `\n` band-aid is gone — it never submitted in raw mode and produced a "double chat").
/// On the enqueue that starts a turn (Idle→Busy) it publishes a
/// [`DomainEvent::DelegationReady`] carrying the task text + ticket + the target's
/// submit config; the **frontend** write-portal owns the single physical PTY write. A
/// wired [`PtyPort`] is kept only to observe the output stream for prompt-ready
/// detection (lot C5) and to deliver the `preempt` interrupt byte.
pub struct MediatedInbox {
mailbox: Arc<InMemoryMailbox>,
/// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5).
@ -136,6 +139,10 @@ pub struct MediatedInbox {
pty: Option<Arc<dyn PtyPort>>,
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
handles: Mutex<HashMap<AgentId, PtyHandle>>,
/// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`),
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
submit: Mutex<HashMap<AgentId, SubmitConfig>>,
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
/// watcher thread un-arms its own entry on exit.
@ -153,6 +160,7 @@ impl MediatedInbox {
clock,
pty: None,
handles: Mutex::new(HashMap::new()),
submit: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
@ -181,6 +189,7 @@ impl MediatedInbox {
clock,
pty: Some(pty),
handles: Mutex::new(HashMap::new()),
submit: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
@ -188,7 +197,10 @@ impl MediatedInbox {
/// Convenience constructor over a fresh mailbox and the wall clock.
#[must_use]
pub fn in_memory() -> Self {
Self::new(Arc::new(InMemoryMailbox::new()), Arc::new(SystemMillisClock))
Self::new(
Arc::new(InMemoryMailbox::new()),
Arc::new(SystemMillisClock),
)
}
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> {
@ -197,6 +209,12 @@ impl MediatedInbox {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn submit(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, SubmitConfig>> {
self.submit
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator).
#[must_use]
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
@ -250,10 +268,7 @@ impl MediatedInbox {
let mut window: Vec<u8> = Vec::with_capacity(keep + 1);
for chunk in stream {
window.extend_from_slice(&chunk);
if window
.windows(needle.len())
.any(|w| w == needle.as_slice())
{
if window.windows(needle.len()).any(|w| w == needle.as_slice()) {
// Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO).
tracker.mark_idle(agent);
break;
@ -273,6 +288,17 @@ impl MediatedInbox {
}
}
/// Composes the line delivered into the target's terminal for a delegated 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. The raw `task` follows on
/// the next line so the agent reads the request unchanged.
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
}
impl InputMediator for MediatedInbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
@ -286,29 +312,36 @@ impl InputMediator for MediatedInbox {
since_ms: self.clock.now_ms(),
},
);
// Publish Busy only on the enqueue that **starts** a turn (Idle→Busy); a
// second enqueue while Busy queues behind without re-announcing (cadrage
// C4 §4.2). Published outside the busy mutex (the tracker released it above).
// Publish only on the enqueue that **starts** a turn (Idle→Busy); a second
// enqueue while Busy queues behind without re-announcing (cadrage C4 §4.2,
// ARCHITECTURE §20). Published outside the busy mutex (the tracker released it
// above). On a started turn we emit BOTH the advisory busy beacon AND the new
// `DelegationReady` carrying the task text + ticket + the target's submit
// config: the backend NO LONGER writes the turn into the PTY (the `\n` band-aid
// is gone — it never submitted in raw mode). The frontend write-portal owns the
// physical write (text + submit_sequence) through the single PTY writer.
if started_turn {
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
busy: true,
});
}
}
// Delivery: write the prefixed task line into the agent's bound handle. The
// prefix carries the requester + ticket id so the target replies via
// `idea_reply(result, ticket)`. A best-effort write: a missing handle/port or
// a write error never drops the ticket (the orchestrator's await + timeout
// remain the safety net) — the reply slot is registered regardless.
if let Some(pty) = &self.pty {
if let Some(handle) = self.handles().get(&agent).cloned() {
let line = format!(
"[IdeA · tâche de {} · ticket {}] {}\n",
ticket.requester, ticket_id, ticket.task
);
let _ = pty.write(&handle, line.as_bytes());
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
events.publish(DomainEvent::DelegationReady {
agent_id: agent,
ticket: ticket_id,
// 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 …]`).
text: delegation_preamble(&ticket.requester, ticket_id, &ticket.task),
submit_sequence: submit.sequence,
submit_delay_ms: submit.delay_ms,
});
}
}
self.mailbox.enqueue(agent, ticket)
@ -323,12 +356,15 @@ impl InputMediator for MediatedInbox {
agent: AgentId,
handle: PtyHandle,
prompt_ready_pattern: Option<String>,
submit: SubmitConfig,
) {
// Register the input handle (delivery path) exactly like `bind_handle`, then arm
// the prompt-ready watcher when the profile declares a literal marker (C5). A
// `None`/empty pattern arms nothing: Idle then comes only from the explicit
// signal or the per-turn timeout (safe fallback, never a false Idle).
// Register the input handle exactly like `bind_handle`, stash the target's
// submit config (echoed on `DelegationReady` at the next turn start, §20.3),
// then arm the prompt-ready watcher when the profile declares a literal marker
// (C5). A `None`/empty pattern arms nothing: Idle then comes only from the
// explicit signal or the per-turn timeout (safe fallback, never a false Idle).
self.handles().insert(agent, handle.clone());
self.submit().insert(agent, submit);
if let Some(pattern) = prompt_ready_pattern {
self.arm_prompt_watcher(agent, &handle, pattern);
}
@ -392,7 +428,10 @@ mod tests {
}
fn inbox_at(now_ms: u64) -> MediatedInbox {
MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(now_ms)))
MediatedInbox::new(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(now_ms)),
)
}
/// Records every [`DomainEvent`] published, for busy/idle assertions.
@ -421,6 +460,32 @@ mod tests {
})
.collect()
}
/// The `(ticket, text, submit_sequence, submit_delay_ms)` of every
/// `DelegationReady` published, in order.
#[allow(clippy::type_complexity)]
fn delegation_ready(&self) -> Vec<(TicketId, String, Option<String>, Option<u32>)> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter_map(|e| match e {
DomainEvent::DelegationReady {
ticket,
text,
submit_sequence,
submit_delay_ms,
..
} => Some((
*ticket,
text.clone(),
submit_sequence.clone(),
*submit_delay_ms,
)),
_ => None,
})
.collect()
}
}
#[test]
@ -436,7 +501,11 @@ mod tests {
// Second enqueue while Busy queues behind ⇒ NO new busy event.
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(bus.busy_events(), vec![(a, true)], "no re-announce while busy");
assert_eq!(
bus.busy_events(),
vec![(a, true)],
"no re-announce while busy"
);
// mark_idle on a busy agent ⇒ exactly one Idle(false) event.
inbox.mark_idle(a);
@ -459,6 +528,123 @@ mod tests {
assert_eq!(bus.busy_events(), vec![(a, true)]);
}
// ====================================================================
// §20 — enqueue no longer PTY-writes; it publishes DelegationReady
// ====================================================================
#[test]
fn enqueue_publishes_exactly_one_delegation_ready_on_idle_to_busy() {
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);
// Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket.
inbox.enqueue(a, ticket(10, "do the thing"));
let ready = bus.delegation_ready();
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
);
// A second enqueue while Busy must NOT re-publish (consistent with started_turn).
inbox.enqueue(a, ticket(11, "queued"));
assert_eq!(
bus.delegation_ready().len(),
1,
"no DelegationReady on a re-enqueue while Busy"
);
// After mark_idle, a fresh enqueue starts a new turn ⇒ a second DelegationReady.
inbox.mark_idle(a);
inbox.enqueue(a, ticket(12, "next turn"));
let ready = bus.delegation_ready();
assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady");
assert!(
ready[1].1.ends_with("\nnext turn"),
"second turn delivers its own prefixed task: {:?}",
ready[1].1
);
}
#[test]
fn delegation_ready_carries_bound_submit_config() {
let bus = Arc::new(RecordingBus::default());
let pty = Arc::new(FakePty::new());
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let h = handle(1);
// Bind the target's submit config (resolved from its profile by the service).
inbox.bind_handle_with_prompt(
a,
h,
None,
SubmitConfig::new(Some("\r".to_owned()), Some(42)),
);
inbox.enqueue(a, ticket(10, "task"));
let ready = bus.delegation_ready();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].2.as_deref(), Some("\r"), "submit_sequence echoed");
assert_eq!(ready[0].3, Some(42), "submit_delay_ms echoed");
}
#[test]
fn enqueue_without_bound_submit_yields_none_fields() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
inbox.enqueue(a, ticket(10, "task"));
let ready = bus.delegation_ready();
assert_eq!(ready.len(), 1);
assert_eq!(
ready[0].2, None,
"no bound submit ⇒ None (front applies default)"
);
assert_eq!(ready[0].3, None);
}
#[test]
fn enqueue_does_not_write_into_the_pty() {
// The whole point of §20: the backend stops writing the turn into the PTY.
let pty = Arc::new(FakePty::new());
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
);
let a = agent(1);
let h = handle(1);
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
inbox.enqueue(a, ticket(10, "do X"));
inbox.enqueue(a, ticket(11, "do Y")); // even a second enqueue must not write
assert!(
pty.writes.lock().unwrap().is_empty(),
"enqueue must perform NO pty.write (the `\\n` band-aid is gone)"
);
}
#[tokio::test]
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
let inbox = inbox_at(5);
@ -490,7 +676,7 @@ mod tests {
let a = agent(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind
// Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox.
// Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox.
assert_eq!(
inbox.busy_state(a).ticket(),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
@ -562,9 +748,9 @@ mod tests {
// Lot C5 — prompt-ready detection on the PTY output stream
// ====================================================================
use domain::ids::SessionId;
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec};
use domain::terminal::PtySize;
use domain::ids::SessionId;
/// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then
/// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is
@ -659,7 +845,7 @@ mod tests {
assert!(inbox.busy_state(a).is_busy(), "enqueue starts a turn");
// Arm prompt detection with the literal marker "\n> " (a stable prompt sigil).
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
@ -676,7 +862,7 @@ mod tests {
pty.seed(&h, vec![b"out REA".to_vec(), b"DY done".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()), SubmitConfig::default());
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"a marker split across chunks must still flip to Idle"
@ -692,7 +878,7 @@ mod tests {
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// No pattern: bind without arming a watcher (the safe fallback).
inbox.bind_handle_with_prompt(a, h, None);
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
// Give any (erroneously spawned) watcher a chance to fire — it must not.
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
@ -701,7 +887,11 @@ mod tests {
);
// The FIFO still accepts a second enqueue (forward, never reject).
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2, "enqueue accepted while Busy");
assert_eq!(
inbox.mailbox().pending(&a),
2,
"enqueue accepted while Busy"
);
}
#[test]
@ -713,7 +903,7 @@ mod tests {
pty.seed(&h, vec![b"still thinking, no prompt here\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
// Even after the stream ends, no match ⇒ stays Busy (timeout is the ultimate
// guard, exercised at the orchestrator layer).
std::thread::sleep(std::time::Duration::from_millis(80));
@ -737,7 +927,7 @@ mod tests {
pty.seed(&h, vec![b"anything\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some(String::new()));
inbox.bind_handle_with_prompt(a, h, Some(String::new()), SubmitConfig::default());
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(
inbox.busy_state(a).is_busy(),
@ -757,15 +947,19 @@ mod tests {
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(10)))
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(
10
)))
);
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
// Prompt-ready ⇒ Idle ⇒ the next enqueue can start a fresh turn.
assert!(wait_until(|| !inbox.busy_state(a).is_busy()));
inbox.enqueue(a, ticket(12, "third"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(12))),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(
12
))),
"after prompt-ready Idle, a new enqueue starts the next turn"
);
}
@ -781,8 +975,13 @@ mod tests {
inbox.enqueue(a, ticket(10, "t"));
// Arm twice in a row; the second must be a no-op while the first is live (no
// panic, no double-subscribe). After EOF the agent is un-armed and stays Busy.
inbox.bind_handle_with_prompt(a, h.clone(), Some("ZZZ".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()));
inbox.bind_handle_with_prompt(
a,
h.clone(),
Some("ZZZ".to_owned()),
SubmitConfig::default(),
);
inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()), SubmitConfig::default());
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
}

View File

@ -38,10 +38,10 @@ pub use conversation_log::{
};
pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::ClaudeTranscriptInspector;
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};

View File

@ -192,13 +192,19 @@ mod tests {
let p1 = mb.enqueue(a, ticket(10, "first"));
let p2 = mb.enqueue(a, ticket(11, "second"));
assert_eq!(mb.pending(&a), 2);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
assert_eq!(
mb.head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
// First resolve goes to the FIRST (head) ticket.
mb.resolve(a, "r1".to_owned()).unwrap();
assert_eq!(p1.await.unwrap(), "r1");
// Now the second is at the head.
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
assert_eq!(
mb.head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
);
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
}
@ -238,7 +244,10 @@ mod tests {
// Caller of ticket 10 timed out: retire exactly its head ticket.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(mb.pending(&a), 1);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
assert_eq!(
mb.head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
);
// The cancelled pending resolves to Cancelled (its sender was dropped).
assert_eq!(p1.await, Err(MailboxError::Cancelled));
@ -256,6 +265,9 @@ mod tests {
// Try to cancel a ticket that is NOT the head ⇒ nothing retired.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
assert_eq!(mb.pending(&a), 1);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
assert_eq!(
mb.head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
}
}

View File

@ -219,9 +219,7 @@ impl McpServer {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| {
JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`")
})?
.ok_or_else(|| JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`"))?
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
@ -238,7 +236,10 @@ impl McpServer {
match result {
Ok(outcome) => {
// The text the agent sees: the reply for an `ask`, else the detail.
let text = outcome.reply.clone().unwrap_or_else(|| outcome.detail.clone());
let text = outcome
.reply
.clone()
.unwrap_or_else(|| outcome.detail.clone());
Ok(tool_result_text(&text, false))
}
// A failed IdeA command is reported as a tool execution error

View File

@ -242,9 +242,8 @@ pub fn map_tool_call(
.ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;
// Small helpers to pull optional string fields out of the arguments object.
let s = |key: &str| -> Option<String> {
args.get(key).and_then(Value::as_str).map(str::to_owned)
};
let s =
|key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };
// Translate the tool into the wire-level request shape the watcher already
// uses (v2 `type`), then let `validate` enforce the invariants once.
@ -361,7 +360,9 @@ fn base() -> OrchestratorRequest {
/// missing its node, with a precise field error).
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
let raw = value?.as_str()?;
uuid::Uuid::parse_str(raw).ok().map(domain::NodeId::from_uuid)
uuid::Uuid::parse_str(raw)
.ok()
.map(domain::NodeId::from_uuid)
}
#[cfg(test)]
@ -413,7 +414,9 @@ mod tests {
fn stop_update_and_skill_map_to_their_commands() {
assert_eq!(
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
OrchestratorCommand::StopAgent {
name: "Dev".to_owned()
}
);
assert_eq!(
map(

View File

@ -108,9 +108,7 @@ impl MemoryTransport {
/// signal EOF. Returns the transport and a receiver of everything the server
/// `send`s back.
#[must_use]
pub fn scripted(
messages: Vec<Vec<u8>>,
) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
pub fn scripted(messages: Vec<Vec<u8>>) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
let (tx, rx) = mpsc::unbounded_channel();
(
Self {

View File

@ -165,7 +165,9 @@ mod tests {
assert_eq!(
parsed.events,
vec![
ReplyEvent::TextDelta { text: "un".to_owned() },
ReplyEvent::TextDelta {
text: "un".to_owned()
},
ReplyEvent::ToolActivity {
label: "Read".to_owned()
},
@ -216,8 +218,8 @@ mod tests {
#[test]
fn codex_parse_thread_started_message_and_final() {
let sess = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#)
.expect("ok");
let sess =
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
assert!(sess.events.is_empty());
@ -371,7 +373,11 @@ mod tests {
expected.insert("gemini", false);
expected.insert("aider", false);
assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles");
assert_eq!(
profiles.len(),
4,
"catalogue has the four reference profiles"
);
for profile in &profiles {
let selectable = profile.is_selectable();
assert_eq!(
@ -673,10 +679,9 @@ mod tests {
label: "reasoning".into()
}]
);
let c = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#,
)
.unwrap();
let c =
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
.unwrap();
assert_eq!(
c.events,
vec![ReplyEvent::ToolActivity {
@ -707,7 +712,12 @@ mod tests {
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message"}}"#,
)
.unwrap();
assert_eq!(m.events, vec![ReplyEvent::Final { content: String::new() }]);
assert_eq!(
m.events,
vec![ReplyEvent::Final {
content: String::new()
}]
);
}
/// `thread.started` capte le `thread_id` ; pas d'événement.
@ -730,10 +740,12 @@ mod tests {
.unwrap()
.events
.is_empty());
assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
.unwrap()
.events
.is_empty());
assert!(
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
.unwrap()
.events
.is_empty()
);
}
// ---- Machinerie process via FakeCli ---------------------------------
@ -1160,7 +1172,11 @@ mod tests {
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
assert!(args.contains(&"salut"), "vu: {args:?}");
// `exec` est bien la 1re sous-commande (position 0 de l'argv).
assert_eq!(args.first(), Some(&"exec"), "exec doit ouvrir l'argv, vu: {args:?}");
assert_eq!(
args.first(),
Some(&"exec"),
"exec doit ouvrir l'argv, vu: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}