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:
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user