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);
|
||||
}
|
||||
|
||||
@ -71,7 +71,8 @@ impl TempDir {
|
||||
}
|
||||
/// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
|
||||
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join("providers.json.tmp")
|
||||
self.conversation_dir(conversation)
|
||||
.join("providers.json.tmp")
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
@ -119,10 +120,13 @@ async fn append_writes_one_valid_json_line_per_turn() {
|
||||
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
|
||||
for line in &lines {
|
||||
let parsed: ConversationTurn =
|
||||
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||
let parsed: ConversationTurn = serde_json::from_str(line)
|
||||
.unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||
// camelCase shape leaks through to the file (sanity on the persisted format).
|
||||
assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}");
|
||||
assert!(
|
||||
line.contains("\"atMs\""),
|
||||
"expected camelCase atMs in {line:?}"
|
||||
);
|
||||
let _ = parsed;
|
||||
}
|
||||
}
|
||||
@ -144,7 +148,9 @@ async fn persists_across_a_fresh_instance_restart() {
|
||||
(2, TurnRole::Response, "b"),
|
||||
(3, TurnRole::Prompt, "c"),
|
||||
] {
|
||||
log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap();
|
||||
log.append(c, turn(c, turn_id(id), role, txt))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,7 +190,10 @@ async fn conversations_land_in_disjoint_files() {
|
||||
assert_ne!(p1, p2, "the two conversations must use distinct files");
|
||||
|
||||
// No cross-leak through the API, and the c2 file holds only c2's single line.
|
||||
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c1, None).await.unwrap()),
|
||||
vec!["c1-a", "c1-b"]
|
||||
);
|
||||
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||
let c2_raw = std::fs::read_to_string(&p2).unwrap();
|
||||
assert_eq!(
|
||||
@ -192,7 +201,10 @@ async fn conversations_land_in_disjoint_files() {
|
||||
1,
|
||||
"c2 file must not contain c1's turns"
|
||||
);
|
||||
assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file");
|
||||
assert!(
|
||||
!c2_raw.contains("c1-"),
|
||||
"no c1 content leaked into c2's file"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -228,11 +240,21 @@ async fn corrupted_line_is_skipped_without_panic() {
|
||||
// A fresh instance must skip the junk line and return only the two valid turns.
|
||||
let log = FsConversationLog::new(&tmp.project_path());
|
||||
let all = log.read(c, None).await.unwrap();
|
||||
assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped");
|
||||
assert_eq!(
|
||||
texts(&all),
|
||||
vec!["good-1", "good-2"],
|
||||
"garbage line skipped"
|
||||
);
|
||||
// `last` must be just as tolerant.
|
||||
assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 5).await.unwrap()),
|
||||
vec!["good-1", "good-2"]
|
||||
);
|
||||
// And the cursor still works across the corrupted tail.
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
|
||||
vec!["good-2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -260,7 +282,11 @@ async fn good_line_appended_after_corruption_is_still_read() {
|
||||
.unwrap();
|
||||
|
||||
let all = log.read(c, None).await.unwrap();
|
||||
assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive");
|
||||
assert_eq!(
|
||||
texts(&all),
|
||||
vec!["before", "after"],
|
||||
"junk in the middle is skipped, both reals survive"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -274,7 +300,11 @@ async fn missing_conversation_reads_empty() {
|
||||
|
||||
// Nothing was ever appended: no .ideai dir at all.
|
||||
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
|
||||
assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty());
|
||||
assert!(log
|
||||
.read(conv_id(99), Some(turn_id(1)))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
@ -293,8 +323,14 @@ async fn read_cursor_is_strictly_exclusive() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]);
|
||||
assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
|
||||
vec!["b", "c"]
|
||||
);
|
||||
assert_eq!(
|
||||
texts(&log.read(c, Some(turn_id(2))).await.unwrap()),
|
||||
vec!["c"]
|
||||
);
|
||||
// Cursor on the last id => nothing after.
|
||||
assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
|
||||
// Unknown cursor => empty (cohérent avec le double in-memory de P1).
|
||||
@ -317,9 +353,15 @@ async fn last_contract_zero_and_bounds() {
|
||||
// last(n < len) => the n last, in insertion order.
|
||||
assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
|
||||
// last(n > len) => everything.
|
||||
assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 10).await.unwrap()),
|
||||
vec!["a", "b", "c", "d"]
|
||||
);
|
||||
// last(n == len) => everything.
|
||||
assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(
|
||||
texts(&log.last(c, 4).await.unwrap()),
|
||||
vec!["a", "b", "c", "d"]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -352,7 +394,11 @@ async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
|
||||
// Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
|
||||
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
|
||||
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}");
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
2,
|
||||
"two concurrent appends => two lines, got: {raw:?}"
|
||||
);
|
||||
for line in &lines {
|
||||
serde_json::from_str::<ConversationTurn>(line)
|
||||
.unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
|
||||
@ -391,15 +437,23 @@ async fn handoff_round_trip_multiline_summary_with_objective() {
|
||||
|
||||
// summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
|
||||
// pour éprouver la fidélité octet-pour-octet du corps.
|
||||
let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||
let summary =
|
||||
"# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||
let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));
|
||||
|
||||
store.save(c, handoff.clone()).await.unwrap();
|
||||
let loaded = store.load(c).await.unwrap();
|
||||
|
||||
assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact");
|
||||
assert_eq!(
|
||||
loaded,
|
||||
Some(handoff.clone()),
|
||||
"round-trip doit redonner le Handoff exact"
|
||||
);
|
||||
let loaded = loaded.unwrap();
|
||||
assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet");
|
||||
assert_eq!(
|
||||
loaded.summary_md, summary,
|
||||
"summary_md conservé octet pour octet"
|
||||
);
|
||||
assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
|
||||
assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
|
||||
}
|
||||
@ -428,8 +482,14 @@ async fn handoff_round_trip_without_objective() {
|
||||
|
||||
// Sanity sur le format brut : pas de ligne `objective:` quand None.
|
||||
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||
assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}");
|
||||
assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}");
|
||||
assert!(
|
||||
!raw.contains("objective:"),
|
||||
"aucune clé objective ne doit être écrite, got: {raw:?}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("upTo: "),
|
||||
"upTo toujours présent, got: {raw:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -464,8 +524,15 @@ async fn handoff_save_is_atomic_no_tmp_left_behind() {
|
||||
"le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
|
||||
);
|
||||
// Le fichier final existe et est complet/relisible.
|
||||
assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister");
|
||||
assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet");
|
||||
assert!(
|
||||
tmp.handoff_path(c).exists(),
|
||||
"handoff.md final doit exister"
|
||||
);
|
||||
assert_eq!(
|
||||
store.load(c).await.unwrap(),
|
||||
Some(handoff),
|
||||
"contenu final lisible et complet"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -485,10 +552,20 @@ async fn handoff_overwrite_keeps_only_the_last() {
|
||||
store.save(c, second.clone()).await.unwrap();
|
||||
|
||||
// load renvoie le dernier, aucune trace du premier.
|
||||
assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save");
|
||||
assert_eq!(
|
||||
store.load(c).await.unwrap(),
|
||||
Some(second),
|
||||
"load doit renvoyer le dernier save"
|
||||
);
|
||||
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||
assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister");
|
||||
assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister");
|
||||
assert!(
|
||||
!raw.contains("première version"),
|
||||
"le contenu du premier save ne doit pas subsister"
|
||||
);
|
||||
assert!(
|
||||
!raw.contains("obj-1"),
|
||||
"l'objective du premier save ne doit pas subsister"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -515,11 +592,20 @@ async fn handoff_is_isolated_per_conversation() {
|
||||
// Deux fichiers distincts sur disque.
|
||||
let p1 = tmp.handoff_path(c1);
|
||||
let p2 = tmp.handoff_path(c2);
|
||||
assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts");
|
||||
assert_ne!(
|
||||
p1, p2,
|
||||
"deux conversations => deux fichiers handoff distincts"
|
||||
);
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2");
|
||||
assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2");
|
||||
assert!(
|
||||
!raw2.contains("résumé c1"),
|
||||
"aucune fuite de c1 dans le handoff de c2"
|
||||
);
|
||||
assert!(
|
||||
!raw2.contains("obj-c1"),
|
||||
"aucune fuite de l'objective de c1 dans c2"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -537,11 +623,23 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||
// (4) `upTo` non-UUID.
|
||||
// (5) ligne de front-matter sans `:`.
|
||||
let cases: [(u128, &str, &str); 5] = [
|
||||
(101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"),
|
||||
(102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"),
|
||||
(
|
||||
101,
|
||||
"pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n",
|
||||
"fence ouvrant absent",
|
||||
),
|
||||
(
|
||||
102,
|
||||
"---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n",
|
||||
"fence fermant absent",
|
||||
),
|
||||
(103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
|
||||
(104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
|
||||
(105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"),
|
||||
(
|
||||
105,
|
||||
"---\nligne sans deux-points\n---\ncorps\n",
|
||||
"ligne front-matter sans `:`",
|
||||
),
|
||||
];
|
||||
|
||||
for (n, content, label) in cases {
|
||||
@ -575,7 +673,10 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||
|
||||
/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
|
||||
fn turn_lines(summary_md: &str) -> Vec<&str> {
|
||||
summary_md.lines().filter(|l| l.starts_with("- **")).collect()
|
||||
summary_md
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("- **"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
|
||||
@ -595,7 +696,8 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||
// Objectif extrait du 1er Prompt.
|
||||
assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
|
||||
assert!(
|
||||
h.summary_md.contains("**Objectif :** Implémente la feature X"),
|
||||
h.summary_md
|
||||
.contains("**Objectif :** Implémente la feature X"),
|
||||
"ligne d'objectif attendue, got:\n{}",
|
||||
h.summary_md
|
||||
);
|
||||
@ -603,7 +705,12 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||
assert_eq!(h.up_to, turn_id(3));
|
||||
// Les 3 tours rendus, un par ligne, avec le bon label.
|
||||
let lines = turn_lines(&h.summary_md);
|
||||
assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
3,
|
||||
"3 lignes-tours attendues, got:\n{}",
|
||||
h.summary_md
|
||||
);
|
||||
assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
|
||||
assert_eq!(lines[1], "- **Tool:** ran grep");
|
||||
assert_eq!(lines[2], "- **Response:** fait");
|
||||
@ -626,11 +733,18 @@ async fn fold_is_incremental_does_not_re_pass_old_turns() {
|
||||
|
||||
// Le 2e appel ne re-fournit QUE t6.
|
||||
let h2 = s
|
||||
.fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")])
|
||||
.fold(
|
||||
Some(h1.clone()),
|
||||
&[turn(c, turn_id(6), TurnRole::Response, "r6")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let lines = turn_lines(&h2.summary_md);
|
||||
assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément");
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
6,
|
||||
"t1..t6 reconstitués depuis prev + incrément"
|
||||
);
|
||||
assert_eq!(lines[0], "- **Prompt:** obj un");
|
||||
assert_eq!(lines[5], "- **Response:** r6");
|
||||
assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
|
||||
@ -686,7 +800,10 @@ async fn fold_keeps_existing_objective_over_new_prompt() {
|
||||
);
|
||||
|
||||
let h = s
|
||||
.fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")])
|
||||
.fold(
|
||||
Some(prev),
|
||||
&[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
|
||||
@ -752,10 +869,13 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
|
||||
// Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
|
||||
let lines = turn_lines(&h1.summary_md);
|
||||
assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme");
|
||||
assert_eq!(
|
||||
lines[1],
|
||||
"- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||
lines.len(),
|
||||
2,
|
||||
"2 lignes-tours seulement, pas de ligne fantôme"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1], "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||
"texte multi-lignes + marqueur aplati en une ligne"
|
||||
);
|
||||
|
||||
@ -765,10 +885,17 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
// (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
|
||||
// la compte comme UNE seule ligne — la fenêtre reste cohérente.
|
||||
let h2 = s
|
||||
.fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")])
|
||||
.fold(
|
||||
Some(h1.clone()),
|
||||
&[turn(c, turn_id(3), TurnRole::Response, "suite")],
|
||||
)
|
||||
.await;
|
||||
let lines2 = turn_lines(&h2.summary_md);
|
||||
assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)");
|
||||
assert_eq!(
|
||||
lines2.len(),
|
||||
3,
|
||||
"fenêtre cohérente après repli (pas de split parasite)"
|
||||
);
|
||||
assert_eq!(lines2[2], "- **Response:** suite");
|
||||
assert_eq!(h2.up_to, turn_id(3));
|
||||
}
|
||||
@ -902,8 +1029,16 @@ async fn provider_corrupted_file_yields_serialization_error_no_panic() {
|
||||
|
||||
// Cas de contenus non-désérialisables en map<String,String>.
|
||||
let cases: [(u128, &str, &str); 3] = [
|
||||
(201, "{ ceci n'est pas du json", "JSON syntaxiquement invalide"),
|
||||
(202, "[\"pas\", \"un\", \"objet\"]", "JSON valide mais pas un objet map"),
|
||||
(
|
||||
201,
|
||||
"{ ceci n'est pas du json",
|
||||
"JSON syntaxiquement invalide",
|
||||
),
|
||||
(
|
||||
202,
|
||||
"[\"pas\", \"un\", \"objet\"]",
|
||||
"JSON valide mais pas un objet map",
|
||||
),
|
||||
(203, "{\"claude\": 123}", "valeur non-String"),
|
||||
];
|
||||
|
||||
@ -986,8 +1121,14 @@ async fn provider_is_isolated_per_conversation() {
|
||||
store.set(c2, "claude", "c2-id").await.unwrap();
|
||||
|
||||
// Chacune relit la sienne.
|
||||
assert_eq!(store.get(c1, "claude").await.unwrap(), Some("c1-id".to_string()));
|
||||
assert_eq!(store.get(c2, "claude").await.unwrap(), Some("c2-id".to_string()));
|
||||
assert_eq!(
|
||||
store.get(c1, "claude").await.unwrap(),
|
||||
Some("c1-id".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
store.get(c2, "claude").await.unwrap(),
|
||||
Some("c2-id".to_string())
|
||||
);
|
||||
|
||||
// Deux fichiers distincts sur disque, sans fuite de contenu.
|
||||
let p1 = tmp.providers_path(c1);
|
||||
@ -995,5 +1136,8 @@ async fn provider_is_isolated_per_conversation() {
|
||||
assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
|
||||
assert!(p1.exists() && p2.exists());
|
||||
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||
assert!(!raw2.contains("c1-id"), "aucune fuite de c1 dans le providers.json de c2");
|
||||
assert!(
|
||||
!raw2.contains("c1-id"),
|
||||
"aucune fuite de c1 dans le providers.json de c2"
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,9 +27,9 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::NodeId;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::ids::NodeId;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
@ -52,11 +52,11 @@ use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@ -296,7 +296,6 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct NoopBus;
|
||||
impl EventBus for NoopBus {
|
||||
@ -468,7 +467,9 @@ fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec<u8> {
|
||||
|
||||
/// Extracts the single text content block of a successful `tools/call` result.
|
||||
fn result_text(result: &Value) -> &str {
|
||||
result["content"][0]["text"].as_str().expect("text content block")
|
||||
result["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("text content block")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -485,15 +486,15 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let response = server.handle_raw(&raw).await.expect("tools/list owes a reply");
|
||||
let response = server
|
||||
.handle_raw(&raw)
|
||||
.await
|
||||
.expect("tools/list owes a reply");
|
||||
assert!(response.error.is_none(), "got error: {:?}", response.error);
|
||||
let result = response.result.expect("result");
|
||||
|
||||
let tools = result["tools"].as_array().expect("tools array");
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
|
||||
for expected in [
|
||||
"idea_list_agents",
|
||||
@ -509,7 +510,10 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
"idea_memory_read",
|
||||
"idea_memory_write",
|
||||
] {
|
||||
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
"missing tool {expected}; got {names:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
@ -520,7 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
||||
// Every tool advertises an object input schema.
|
||||
for t in tools {
|
||||
assert_eq!(
|
||||
t["inputSchema"]["type"], json!("object"),
|
||||
t["inputSchema"]["type"],
|
||||
json!("object"),
|
||||
"tool {} must declare an object input schema",
|
||||
t["name"]
|
||||
);
|
||||
@ -544,7 +549,11 @@ async fn launch_agent_call_creates_and_launches_the_agent() {
|
||||
json!({ "target": "dev-backend", "profile": "claude-code" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("result");
|
||||
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||
|
||||
@ -630,7 +639,11 @@ async fn ask_agent_returns_target_reply_inline() {
|
||||
let contexts = FakeContexts::new();
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
|
||||
let ask_server = server(Arc::clone(&service));
|
||||
let ask = tokio::spawn(async move {
|
||||
@ -655,7 +668,10 @@ async fn ask_agent_returns_target_reply_inline() {
|
||||
// requester, which `for_requester` injects as the `from` of the Reply command.
|
||||
let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string());
|
||||
let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" }));
|
||||
let reply_resp = reply_server.handle_raw(&reply_raw).await.expect("reply owed");
|
||||
let reply_resp = reply_server
|
||||
.handle_raw(&reply_raw)
|
||||
.await
|
||||
.expect("reply owed");
|
||||
assert_eq!(reply_resp.result.expect("result")["isError"], json!(false));
|
||||
|
||||
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
|
||||
@ -663,7 +679,11 @@ async fn ask_agent_returns_target_reply_inline() {
|
||||
.expect("ask completes after reply")
|
||||
.expect("join ok");
|
||||
assert_eq!(response.id, json!(7), "id must be echoed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("result");
|
||||
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||
assert_eq!(
|
||||
@ -751,7 +771,10 @@ async fn failed_command_is_tool_error_not_transport_error_and_server_survives()
|
||||
}))
|
||||
.unwrap();
|
||||
let again = server.handle_raw(&raw).await.expect("server still serves");
|
||||
assert!(again.result.is_some(), "server must survive a failed command");
|
||||
assert!(
|
||||
again.result.is_some(),
|
||||
"server must survive a failed command"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -898,7 +921,10 @@ async fn scripted_session_over_memory_transport_round_trips() {
|
||||
// Exactly two responses (init + list); the notification produced none.
|
||||
let first = rx.recv().await.expect("init response");
|
||||
let second = rx.recv().await.expect("list response");
|
||||
assert!(rx.recv().await.is_none(), "notification must not be answered");
|
||||
assert!(
|
||||
rx.recv().await.is_none(),
|
||||
"notification must not be answered"
|
||||
);
|
||||
|
||||
let first: Value = serde_json::from_slice(&first).unwrap();
|
||||
let second: Value = serde_json::from_slice(&second).unwrap();
|
||||
@ -927,7 +953,11 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() {
|
||||
json!({ "target": "dev-backend", "profile": "claude-code" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
assert_eq!(response.result.expect("result")["isError"], json!(false));
|
||||
|
||||
// Exactly one OrchestratorRequestProcessed event, tagged as the MCP door.
|
||||
@ -951,7 +981,10 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() {
|
||||
assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp");
|
||||
assert_eq!(action, "idea_launch_agent", "action mirrors the tool name");
|
||||
assert!(*ok, "the launch succeeded");
|
||||
assert_eq!(requester_id, "mcp", "stable mcp label until per-session bind");
|
||||
assert_eq!(
|
||||
requester_id, "mcp",
|
||||
"stable mcp label until per-session bind"
|
||||
);
|
||||
}
|
||||
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
|
||||
}
|
||||
@ -976,9 +1009,15 @@ async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
|
||||
.collect();
|
||||
assert_eq!(processed.len(), 1, "a failed call still beacons; got {events:?}");
|
||||
assert_eq!(
|
||||
processed.len(),
|
||||
1,
|
||||
"a failed call still beacons; got {events:?}"
|
||||
);
|
||||
match processed[0] {
|
||||
DomainEvent::OrchestratorRequestProcessed { ok, source, action, .. } => {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
ok, source, action, ..
|
||||
} => {
|
||||
assert!(!*ok, "the dispatch failed → ok = false");
|
||||
assert_eq!(*source, OrchestrationSource::Mcp);
|
||||
assert_eq!(action, "idea_stop_agent");
|
||||
@ -1001,7 +1040,11 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() {
|
||||
json!({ "target": "dev-backend", "profile": "claude-code" }),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "transport error: {:?}", response.error);
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
assert_eq!(response.result.expect("result")["isError"], json!(false));
|
||||
// The command really ran (agent created) — proof the no-op sink path is live.
|
||||
assert_eq!(contexts.entries().len(), 1);
|
||||
|
||||
@ -16,10 +16,10 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::NodeId;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ids::NodeId;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||
@ -295,7 +295,6 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct NoopBus;
|
||||
impl EventBus for NoopBus {
|
||||
@ -471,11 +470,7 @@ fn build_service_with_mailbox(
|
||||
}
|
||||
|
||||
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
|
||||
fn seed_live_pty(
|
||||
sessions: &TerminalSessions,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
) {
|
||||
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
|
||||
sessions.insert(
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
@ -650,7 +645,11 @@ async fn ask_request_surfaces_reply_alongside_detail() {
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
|
||||
// Target already live in the PTY registry, so the ask reuses its terminal.
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
|
||||
let req = tmp.0.join("ask-req.json");
|
||||
std::fs::write(
|
||||
@ -770,7 +769,8 @@ async fn non_ask_request_omits_reply_key() {
|
||||
#[test]
|
||||
fn legacy_response_without_reply_round_trips_without_reintroducing_key() {
|
||||
// A response payload exactly as a pre-D6b IdeA would have written it.
|
||||
let legacy = r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;
|
||||
let legacy =
|
||||
r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;
|
||||
|
||||
let parsed: OrchestratorResponse =
|
||||
serde_json::from_str(legacy).expect("legacy response must still parse");
|
||||
@ -873,7 +873,12 @@ async fn watcher_publishes_processed_event_tagged_file_source() {
|
||||
|
||||
let event = found.expect("watcher must publish a processed beacon within the poll budget");
|
||||
match event {
|
||||
DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
source,
|
||||
ok,
|
||||
requester_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(source, OrchestrationSource::File, "fs door must tag File");
|
||||
assert!(ok, "the spawn request succeeded");
|
||||
assert_eq!(requester_id, "Main", "requester id = request subdirectory");
|
||||
|
||||
Reference in New Issue
Block a user