Skip to main content

wcode_harness/
actor.rs

1//! In-process session actor — one mailbox, one stream.
2//!
3//! [`SessionActor`] turns an [`Agent`] into a task with an inbox and a
4//! subscribable outbox, so a caller drives the session entirely through
5//! [`Request`]s and observes it through [`AgentEvent`]s — never by holding the
6//! `Agent` itself:
7//!
8//! ```text
9//!   caller  (REPL / TUI / future peer session — all identical here)
10//!     │  send(Request)                          ▲  subscribe() → recv()
11//!     ▼                                         │  (own cursor per subscriber)
12//!   inbox: mpsc::Unbounded<Request>       outbox: broadcast<AgentEvent>
13//!     │                                         ▲  events.send(ev)
14//!     └───────────► SessionActor task ◄─────────┘
15//!                   owns the Agent; serves one Request at a time;
16//!                   `deferred: VecDeque<Request>` parks mid-run commands
17//! ```
18//!
19//! This is the seam the whole protocol design hangs on. A REPL, a TUI, and
20//! (later) a peer session are all just callers that hold a [`SessionHandle`];
21//! they differ only in how they render the stream and which address they hold,
22//! not in the mechanism they speak. Today the inbox and outbox are in-process
23//! channels; the shapes are chosen so that a socket ([`Frame`]s over NDJSON,
24//! stage S2) can replace the transport without changing this API.
25//!
26//! ## Flow
27//!
28//! `serve()` — no run in flight: pull ONE Request, service it, repeat.
29//!
30//! ```text
31//!   deferred non-empty? ──yes──► take from deferred (FIFO, order kept)
32//!        │ no
33//!        ▼
34//!   inbox.recv().await ──► match Request:
35//!        Submit    {..} ─► run(...)              → enters the in-run map below
36//!        Notify    {..} ─► agent.notify(tagged)  → append, no turn
37//!        Interrupt {..} ─► agent.steer(tagged)   → queued for the next turn
38//!        Wake      {..} ─► run(tagged)           → a turn even when idle
39//!        Cancel         ─► (no-op — nothing is running)
40//!        SetModel  {..} ─► agent.set_model(..)
41//!        SetEffort {..} ─► agent.set_effort(..)
42//!        Compact   {..} ─► agent.compact(..).await
43//!        GetHistory     ─► reply with the context
44//!        Unknown        ─► (ignored)
45//! ```
46//!
47//! `run()` — the Agent is borrowed for the whole run, so it is reached only
48//! through clones taken *before* the borrow.
49//!
50//! ```text
51//!   steer     = agent.steer_sender()
52//!   follow_up = agent.follow_up_sender()
53//!   cancel    = agent.cancel_token()
54//!
55//!   loop { tokio::select! { biased; ... } }   ← biased: inbox polled FIRST
56//!        ├─ Some(req) = inbox.recv() ─► Cancel   ─► cancel.cancel()         ┐ to the
57//!        │                              Steer    ─► steer.send(msg)         │ live run
58//!        │                              FollowUp ─► follow_up.send(msg)     │
59//!        │                              other    ─► deferred.push_back(req) ┘ waits
60//!        ├─ result  = &mut run  ──────► run finished → break
61//!        └─ Some(ev) = sink_rx.recv() ► events.send(ev)  (fan-out to subscribers)
62//!   }
63//!   then: drain leftover sink_rx → events.send ; on Err(run) → Error event
64//! ```
65//!
66//! [`Frame`]: crate::protocol::Frame
67//!
68//! ## Delivery rules
69//!
70//! A run holds the agent's `&mut` for its whole life, so the actor splits the
71//! inbox by what each request needs:
72//!
73//! | request | while a run is in flight |
74//! |---------|--------------------------|
75//! | `Cancel` | delivered at once — the token is cancelled; the loop aborts |
76//! | `Interrupt` / `Wake` | forwarded to the run's channels at once |
77//! | `SetModel` / `SetEffort` / `Compact` | deferred, applied when the run ends |
78//! | another `Submit` | deferred, becomes the next run |
79//!
80//! The interactive path therefore never waits on a run. The deferred set is
81//! exactly the requests that need `&mut Agent` — you cannot swap the model or
82//! compact the context out from under a run — and deferring them is also the
83//! documented meaning: `set_model`/`set_effort` "take effect on the next run".
84//!
85//! One caveat lives in the kernel, not here — the actor hands a `Cancel` over at
86//! once, but the *loop* is what acts on it, and its latency depends on where the
87//! run is:
88//!
89//! ```text
90//! where the run is when Cancel arrives          when it stops
91//! ---------------------------------------------  -----------------------
92//! model streaming     (loop races the token)     immediately
93//! between turns       (checked at turn start)    immediately
94//! inside a tool call  (no race on the future)    when the tool returns *
95//! ```
96//!
97//! `*` unless the tool watches `ToolContext::cancel` itself (e.g. `bash` kills
98//! its child) — then it can stop early.
99//!
100//! ## Requests and replies
101//!
102//! `send` is fire-and-forget; `ask` awaits a reply. The reply is an
103//! [`AgentEvent`] reply variant — [`AgentEvent::History`] for `GetHistory`,
104//! [`AgentEvent::Ack`]/[`AgentEvent::Error`] for `SetModel`/`SetEffort`/
105//! [`AgentEvent::Compaction`] for `Compact` — correlated in-process by a
106//! one-shot channel. Replies are never fanned out on the event stream; the wire
107//! (S2) will carry the same variants correlated by the envelope's `reply_to`.
108//!
109//! Scope so far: every request variant that maps onto an existing [`Agent`]
110//! method is serviced. Still to come (S1b): pointing the CLI at a handle.
111
112use std::collections::VecDeque;
113use std::fmt;
114
115use tokio::sync::{broadcast, mpsc, oneshot, watch};
116
117use crate::agent::Agent;
118use crate::compaction::CompactOutcome;
119use crate::event::AgentEvent;
120use crate::hooks::HooksSet;
121use crate::loop_::LoopError;
122use crate::message::{AgentMessage, StopReason};
123use crate::protocol::{MemberState, Request, SessionId};
124
125/// Default outbox buffer: how far a subscriber may lag before it starts losing
126/// events (`broadcast` semantics — the slowest reader drops, it never blocks the
127/// run).
128pub const EVENT_BUFFER: usize = 1024;
129
130/// Handle to a running [`SessionActor`].
131///
132/// Cloning gives another caller the same mailbox and stream. When the last
133/// handle drops, the session shuts down and its [`Agent`] is dropped.
134#[derive(Clone)]
135pub struct SessionHandle {
136    inbox: mpsc::UnboundedSender<Message>,
137    events: broadcast::Sender<AgentEvent>,
138    /// The run-state cell — the ONE server-side liveness fact (bug 2). A `watch`
139    /// (not a bare `AtomicU8`) so a registry/server task can WAKE on a transition
140    /// and re-push the roster; `send_replace` keeps the latest value even with no
141    /// receiver yet. `Clone` rides freely (the sender is shared).
142    state: watch::Sender<MemberState>,
143}
144
145impl SessionHandle {
146    /// Submit a request to the session's inbox.
147    ///
148    /// Fails only if the session has shut down (every handle was dropped and the
149    /// actor task has ended). A `Submit` runs to completion before the next
150    /// non-interrupt request is serviced, but `Interrupt`/`Wake`/`Cancel` sent
151    /// during a run are handled immediately (see the actor's delivery rules).
152    pub fn send(&self, request: Request) -> Result<(), SessionClosed> {
153        self.tell(None, request)
154    }
155
156    /// Like [`SessionHandle::send`], but attributed to a sender address so the
157    /// recipient can attribute and policy-check the message (a peer session
158    /// passes its own `"agent:<id>"` address).
159    pub fn send_from(&self, from: SessionId, request: Request) -> Result<(), SessionClosed> {
160        self.tell(Some(from), request)
161    }
162
163    fn tell(&self, from: Option<SessionId>, request: Request) -> Result<(), SessionClosed> {
164        self.inbox
165            .send(Message::Tell { from, request })
166            .map_err(|_| SessionClosed)
167    }
168
169    /// Send a request and await its reply.
170    ///
171    /// `ask` is for requests that expect a reply: `GetHistory` (→
172    /// [`AgentEvent::History`]), `SetModel`/`SetEffort` (→ [`AgentEvent::Ack`]
173    /// or [`AgentEvent::Error`]), `Compact` (→ [`AgentEvent::Compaction`],
174    /// [`AgentEvent::Ack`], or [`AgentEvent::Error`]). It also works for
175    /// `Submit`, replying when the run completes. Interrupts
176    /// (`Interrupt`/`Wake`/`Cancel`) want no reply — use `send`; an `ask` on one
177    /// is deferred like any command while a run is in flight.
178    ///
179    /// Fails only if the session has shut down.
180    pub async fn ask(&self, request: Request) -> Result<AgentEvent, SessionClosed> {
181        self.question(None, request).await
182    }
183
184    /// Like [`SessionHandle::ask`], attributed to a sender address.
185    pub async fn ask_from(
186        &self,
187        from: SessionId,
188        request: Request,
189    ) -> Result<AgentEvent, SessionClosed> {
190        self.question(Some(from), request).await
191    }
192
193    async fn question(
194        &self,
195        from: Option<SessionId>,
196        request: Request,
197    ) -> Result<AgentEvent, SessionClosed> {
198        let (reply_tx, reply_rx) = oneshot::channel();
199        self.inbox
200            .send(Message::Ask {
201                from,
202                request,
203                reply: reply_tx,
204            })
205            .map_err(|_| SessionClosed)?;
206        reply_rx.await.map_err(|_| SessionClosed)
207    }
208
209    /// The session's current run-state. Read-only, lock-free (a `watch` borrow),
210    /// so a `peers` listing / roster read over N members is cheap.
211    pub fn state(&self) -> MemberState {
212        *self.state.borrow()
213    }
214
215    /// A live view of the run-state, so a registry/server task can wake on a
216    /// transition instead of polling. Seeded with the current value (like
217    /// `Registry::subscribe`).
218    pub fn state_rx(&self) -> watch::Receiver<MemberState> {
219        self.state.subscribe()
220    }
221    /// Subscribe to the session's event stream. Each subscriber gets its own
222    /// receiver; a lagging subscriber drops events rather than stalling the run.
223    pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
224        self.events.subscribe()
225    }
226}
227
228/// Returned when a [`SessionHandle::send`] targets a session that has ended.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct SessionClosed;
231
232impl fmt::Display for SessionClosed {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str("session actor has shut down")
235    }
236}
237
238impl std::error::Error for SessionClosed {}
239
240/// A mailbox item: fire-and-forget, or a request that expects a reply.
241enum Message {
242    /// No reply wanted. `from` is the sender's address — `None` for a local
243    /// caller (the human), `Some(peer)` when another session sends.
244    Tell {
245        from: Option<SessionId>,
246        request: Request,
247    },
248    /// The reply to the request is sent here when it is serviced.
249    Ask {
250        from: Option<SessionId>,
251        request: Request,
252        reply: oneshot::Sender<AgentEvent>,
253    },
254}
255
256/// Spawns the actor task that owns an [`Agent`].
257///
258/// The `Agent` moves into a background task; all interaction returns through the
259/// [`SessionHandle`]. The task exits when the last handle drops.
260pub struct SessionActor;
261
262impl SessionActor {
263    pub fn spawn(agent: Agent) -> SessionHandle {
264        let (inbox_tx, inbox_rx) = mpsc::unbounded_channel();
265        let (events_tx, _) = broadcast::channel(EVENT_BUFFER);
266        let events = events_tx.clone();
267        let (state_tx, _state_rx) = watch::channel(MemberState::Idle);
268        tokio::spawn(serve(agent, inbox_rx, events_tx, state_tx.clone()));
269        SessionHandle {
270            inbox: inbox_tx,
271            events,
272            state: state_tx,
273        }
274    }
275}
276
277/// The actor loop: pull one request at a time, service it, repeat.
278async fn serve(
279    mut agent: Agent,
280    mut inbox: mpsc::UnboundedReceiver<Message>,
281    events: broadcast::Sender<AgentEvent>,
282    state: watch::Sender<MemberState>,
283) {
284    // Requests that arrive *during* a run but cannot be applied to it (a
285    // second `Submit`, a model swap) wait here and are serviced once the run
286    // ends, preserving their order.
287    let mut deferred: VecDeque<Message> = VecDeque::new();
288
289    loop {
290        let message = match deferred.pop_front() {
291            Some(message) => message,
292            None => match inbox.recv().await {
293                Some(message) => message,
294                None => break, // every handle dropped: shut down
295            },
296        };
297
298        dispatch(&mut agent, message, &mut inbox, &events, &mut deferred, &state).await;
299    }
300}
301
302/// Prefix an inbound peer message with its sender, so the model sees who sent
303/// it and knows the address to reply to (§13.13).
304fn tag(sender: &SessionId, content: &str) -> String {
305    format!("[message from {sender}]\n{content}")
306}
307
308/// Run the inbound policy on an A2A message (the mirror of
309/// `before_tool_call`). Returns `Some(reason)` if the message was dropped; the
310/// request may have been rewritten in place by the hook either way.
311async fn gate_inbound(hooks: &HooksSet, message: &mut Message) -> Option<String> {
312    let (from, request) = match message {
313        Message::Tell { from, request } | Message::Ask { from, request, .. } => (from, request),
314    };
315    if !crate::protocol::is_inbound(request) {
316        return None;
317    }
318    hooks.before_inbound(from.as_ref(), request).await
319}
320
321/// Service one mailbox item: run the request, then answer an `Ask` with the
322/// reply it produced (a `Tell` drops the reply on the floor).
323async fn dispatch(
324    agent: &mut Agent,
325    mut message: Message,
326    inbox: &mut mpsc::UnboundedReceiver<Message>,
327    events: &broadcast::Sender<AgentEvent>,
328    deferred: &mut VecDeque<Message>,
329    state: &watch::Sender<MemberState>,
330) {
331    // Inbound peer-message policy (S4-1): a drop skips delivery entirely; a
332    // rewrite (in place) is what gets serviced.
333    if let Some(reason) = gate_inbound(agent.hooks(), &mut message).await {
334        if let Message::Ask { reply, .. } = message {
335            let _ = reply.send(AgentEvent::Error {
336                message: format!("inbound blocked: {reason}"),
337            });
338        }
339        return;
340    }
341
342    let (from, request, reply) = match message {
343        Message::Tell { from, request } => (from, request, None),
344        Message::Ask { from, request, reply } => (from, request, Some(reply)),
345    };
346    let sender = from.unwrap_or_else(SessionId::user);
347
348    let event = match request {
349        Request::Submit { text } => match run(agent, &text, inbox, events, deferred, state).await {
350            Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
351            Err(e) => AgentEvent::Error {
352                message: e.to_string(),
353            },
354        },
355        // A2A delivery verbs (S4-1). `Notify` appends with no turn;
356        // `Interrupt`/`Wake` are the canonical interrupt/continue forms.
357        Request::Notify { content } => match agent.notify(tag(&sender, &content)) {
358            Ok(_) => {
359                let _ = events.send(AgentEvent::MessageReceived {
360                    from: sender.clone(),
361                    content,
362                });
363                AgentEvent::Ack
364            }
365            Err(e) => AgentEvent::Error {
366                message: e.to_string(),
367            },
368        },
369        Request::Interrupt { content } => {
370            agent.steer(AgentMessage::user_text(tag(&sender, &content)));
371            let _ = events.send(AgentEvent::MessageReceived {
372                from: sender.clone(),
373                content,
374            });
375            AgentEvent::Ack
376        }
377        // A `Wake` runs even when idle: start a turn with the (tagged) content,
378        // like `Submit` — otherwise a worker handed a task would only queue a
379        // follow-up and never act on it.
380        Request::Wake { content } => {
381            let _ = events.send(AgentEvent::MessageReceived {
382                from: sender.clone(),
383                content: content.clone(),
384            });
385            match run(agent, &tag(&sender, &content), inbox, events, deferred, state).await {
386                Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
387                Err(e) => AgentEvent::Error {
388                    message: e.to_string(),
389                },
390            }
391        }
392        // An idle cancel is a no-op (see the delivery rules): the token is
393        // minted fresh at the end of each run, so cancelling an idle session
394        // would otherwise abort the next run on its first poll.
395        Request::Cancel => AgentEvent::Ack,
396        Request::SetModel { model } => match agent.set_model(model) {
397            Ok(()) => AgentEvent::Ack,
398            Err(e) => AgentEvent::Error {
399                message: e.to_string(),
400            },
401        },
402        Request::SetEffort { effort } => match agent.set_effort(effort) {
403            Ok(()) => AgentEvent::Ack,
404            Err(e) => AgentEvent::Error {
405                message: e.to_string(),
406            },
407        },
408        Request::Compact { instructions } => match agent.compact(instructions.as_deref()).await {
409            Ok(CompactOutcome::NothingToDo) => AgentEvent::Ack,
410            Ok(CompactOutcome::Done {
411                summarized, kept, ..
412            }) => AgentEvent::Compaction { summarized, kept },
413            Err(e) => AgentEvent::Error { message: e },
414        },
415        // `/btw`: a tool-free side call. `side_ask` takes `&self`, so no borrow
416        // conflict with the run. A `SideAsk` arriving mid-run is deferred to a
417        // turn boundary like any other inbox request (see the run loop's
418        // `deferred.push_back`) — queued, not refused.
419        Request::SideAsk { text } => match agent.side_ask(&text).await {
420            Ok(answer) => AgentEvent::SideAnswer {
421                text: answer.text,
422                usage: answer.usage,
423            },
424            Err(e) => AgentEvent::Error {
425                message: e.to_string(),
426            },
427        },
428        // Toggle plan mode: flips the shared handle (the `PlanModeHooks` reads
429        // the same one) and recomposes the system prompt. Infallible.
430        Request::SetPlanMode { on } => {
431            agent.set_plan_mode(on);
432            AgentEvent::Ack
433        }
434        Request::GetHistory => AgentEvent::History {
435            messages: agent.messages().to_vec(),
436        },
437        // A served peer is defined by a socket server's injected handler; an
438        // in-process session has no factory, so it can only refuse.
439        Request::Define { .. } => AgentEvent::Error {
440            message: "agent definition is served, not in-process".into(),
441        },
442        Request::ListSessions | Request::Unknown => AgentEvent::Ack,
443    };
444
445    if let Some(reply) = reply {
446        let _ = reply.send(event);
447    }
448}
449
450/// Drive one `Agent::run`, forwarding its events to the outbox while still
451/// servicing the inbox so `Interrupt`/`Wake`/`Cancel` reach the in-flight run.
452async fn run(
453    agent: &mut Agent,
454    text: &str,
455    inbox: &mut mpsc::UnboundedReceiver<Message>,
456    events: &broadcast::Sender<AgentEvent>,
457    deferred: &mut VecDeque<Message>,
458    state: &watch::Sender<MemberState>,
459) -> Result<StopReason, LoopError> {
460    // State transitions live at the run boundary — this `run()` is the in-flight
461    // window. The event stream's `AgentStart`/`AgentEnd` are the OTHER place this
462    // could be derived; the actor-owned cell is authoritative because it is set
463    // synchronously before the first await, so a reader never misses `Running`.
464    // A `Cancel` ends on `Ok(StopReason::Aborted)` -> `Done`: an abort is not a
465    // failure, matching the TUI.
466    state.send_replace(MemberState::Running);
467    let (sink_tx, mut sink_rx) = mpsc::unbounded_channel::<AgentEvent>();
468    // Sender/token clones taken *before* `run` borrows the agent: steering and
469    // cancelling the in-flight run must not need the `&mut`.
470    let steer = agent.steer_sender();
471    let follow_up = agent.follow_up_sender();
472    let cancel = agent.cancel_token();
473    let hooks = agent.hooks().clone();
474
475    let mut run = Box::pin(agent.run(text, sink_tx));
476    let result = loop {
477        // Biased: service the inbox *before* advancing the run. An `Interrupt` must
478        // be in the agent's steering channel before the run reaches its next
479        // turn-boundary drain, or it is silently a turn late. Polling the run
480        // first would let it cross that boundary while the request still sits
481        // here.
482        tokio::select! {
483            biased;
484            Some(mut message) = inbox.recv() => {
485                if let Some(reason) = gate_inbound(&hooks, &mut message).await {
486                    if let Message::Ask { reply, .. } = message {
487                        let _ = reply.send(AgentEvent::Error {
488                            message: format!("inbound blocked: {reason}"),
489                        });
490                    }
491                    continue;
492                }
493                match message {
494                    Message::Tell {
495                        request: Request::Cancel,
496                        ..
497                    } => cancel.cancel(),
498                    Message::Tell {
499                        request: Request::Notify { content } | Request::Interrupt { content },
500                        from,
501                    } => {
502                        let from = from.unwrap_or_else(SessionId::user);
503                        let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
504                        let _ = events.send(AgentEvent::MessageReceived {
505                            from,
506                            content,
507                        });
508                    }
509                    Message::Tell {
510                        request: Request::Wake { content },
511                        from,
512                    } => {
513                        let from = from.unwrap_or_else(SessionId::user);
514                        let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
515                        let _ = events.send(AgentEvent::MessageReceived {
516                            from,
517                            content,
518                        });
519                    }
520                    other => deferred.push_back(other),
521                }
522            },
523            result = &mut run => break result,
524            Some(event) = sink_rx.recv() => {
525                let _ = events.send(event);
526            }
527        }
528    };
529
530    // The run dropped its sink on completion; forward whatever is still buffered
531    // so the last events (e.g. `AgentEnd`) are never stranded.
532    while let Ok(event) = sink_rx.try_recv() {
533        let _ = events.send(event);
534    }
535
536    // A session-write error inside the run is a `LoopError`; the run's own
537    // `AgentEvent::Error` covers stream failures, so surface this one the same
538    // way rather than dropping it silently.
539    if let Err(e) = &result {
540        let _ = events.send(AgentEvent::Error {
541            message: e.to_string(),
542        });
543    }
544    // Final transition: a normal stop is `Done`; a run-failure — the loop's
545    // `StopReason::Error` or a `LoopError` — is `Failed` (sticky until the next
546    // run sets `Running`).
547    match &result {
548        Ok(StopReason::Error) | Err(_) => {
549            state.send_replace(MemberState::Failed);
550        }
551        Ok(_) => {
552            state.send_replace(MemberState::Done);
553        }
554    }
555    result
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::agent::{Agent, AgentConfig};
562    use crate::compaction::CompactionPolicy;
563    use crate::event::LlmStreamEvent;
564    use crate::hooks::HooksSet;
565    use crate::message::StopReason;
566    use crate::streamfn::{LlmOpts, LlmStream, StreamFn};
567    use crate::tool::{ToolContext, ToolOutput, TypedTool, erased};
568    use futures::StreamExt as _;
569    use serde::Deserialize;
570    use serde_json::json;
571    use std::sync::{Arc, Mutex};
572    use std::time::Duration;
573
574    #[derive(Clone, Default)]
575    struct Recorder {
576        script: Arc<Mutex<VecDeque<Vec<LlmStreamEvent>>>>,
577        calls: Arc<Mutex<Vec<Vec<AgentMessage>>>>,
578        models: Arc<Mutex<Vec<String>>>,
579    }
580
581    impl Recorder {
582        fn push(&self, events: Vec<LlmStreamEvent>) {
583            self.script.lock().unwrap().push_back(events);
584        }
585        fn calls(&self) -> Vec<Vec<AgentMessage>> {
586            self.calls.lock().unwrap().clone()
587        }
588        fn models(&self) -> Vec<String> {
589            self.models.lock().unwrap().clone()
590        }
591    }
592
593    fn fake_stream_fn(rec: &Recorder) -> StreamFn {
594        let rec = rec.clone();
595        Arc::new(
596            move |ctx: &[AgentMessage], _system, _tools, opts: &LlmOpts| {
597                rec.calls.lock().unwrap().push(ctx.to_vec());
598                rec.models.lock().unwrap().push(opts.model.clone());
599                let events = rec.script.lock().unwrap().pop_front().unwrap_or_default();
600                Box::pin(futures::stream::iter(events)) as LlmStream
601            },
602        )
603    }
604
605    fn agent_config(stream_fn: StreamFn, tools: Vec<crate::tool::Tool>) -> AgentConfig {
606        AgentConfig {
607            system: "sys".into(),
608            tools,
609            llm: LlmOpts {
610                model: "m1".into(),
611                ..LlmOpts::default()
612            },
613            stream_fn,
614            hooks: HooksSet::default(),
615            session: None,
616            context: Vec::new(),
617            working_dir: std::path::PathBuf::new(),
618            max_turns: crate::loop_::DEFAULT_MAX_TURNS,
619            parallel_tools: true,
620            compaction: CompactionPolicy::default(),
621            plan_mode: crate::hooks::PlanModeHandle::new(),
622        }
623    }
624
625    /// Block until the run ends, panicking if it stalls.
626    async fn wait_for_end(rx: &mut broadcast::Receiver<AgentEvent>) {
627        loop {
628            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
629                .await
630                .expect("an event within the timeout")
631                .expect("the outbox stays open");
632            if matches!(event, AgentEvent::AgentEnd) {
633                return;
634            }
635        }
636    }
637
638    #[tokio::test]
639    async fn submit_streams_events_to_a_subscriber() {
640        let rec = Recorder::default();
641        rec.push(vec![
642            LlmStreamEvent::TextDelta("hello".into()),
643            LlmStreamEvent::Done {
644                stop_reason: StopReason::Stop,
645                usage: None,
646            },
647        ]);
648        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
649        let mut rx = handle.subscribe();
650        handle.send(Request::Submit { text: "hi".into() }).unwrap();
651
652        let mut saw_text = false;
653        loop {
654            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
655                .await
656                .expect("an event")
657                .expect("open");
658            match event {
659                AgentEvent::MessageUpdate { message } if message.as_text() == "hello" => {
660                    saw_text = true;
661                }
662                AgentEvent::AgentEnd => break,
663                _ => {}
664            }
665        }
666        assert!(saw_text, "the streamed text reached the subscriber");
667    }
668
669    #[tokio::test]
670    async fn idle_cancel_does_not_poison_the_next_run() {
671        let rec = Recorder::default();
672        for text in ["one", "two"] {
673            rec.push(vec![
674                LlmStreamEvent::TextDelta(text.into()),
675                LlmStreamEvent::Done {
676                    stop_reason: StopReason::Stop,
677                    usage: None,
678                },
679            ]);
680        }
681        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
682        let mut rx = handle.subscribe();
683
684        handle.send(Request::Submit { text: "a".into() }).unwrap();
685        wait_for_end(&mut rx).await;
686
687        // No run in flight: this must be swallowed, not arm a kill for run two.
688        handle.send(Request::Cancel).unwrap();
689        handle.send(Request::Submit { text: "b".into() }).unwrap();
690
691        let mut saw_two = false;
692        loop {
693            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
694                .await
695                .expect("an event")
696                .expect("open");
697            match event {
698                AgentEvent::MessageUpdate { message } if message.as_text() == "two" => {
699                    saw_two = true;
700                }
701                AgentEvent::AgentEnd => break,
702                _ => {}
703            }
704        }
705        assert!(saw_two, "the second run completed despite the idle cancel");
706    }
707
708    #[tokio::test]
709    async fn cancel_aborts_an_in_flight_run() {
710        // One delta, then the stream hangs: only a working cancel ends the run.
711        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
712            let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
713            Box::pin(head.chain(futures::stream::pending())) as LlmStream
714        });
715        let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
716        let mut rx = handle.subscribe();
717        handle.send(Request::Submit { text: "hi".into() }).unwrap();
718
719        loop {
720            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
721                .await
722                .expect("an event")
723                .expect("open");
724            if matches!(event, AgentEvent::MessageUpdate { .. }) {
725                handle.send(Request::Cancel).unwrap();
726                break;
727            }
728        }
729        // Without a live cancel this would time out on the pending stream.
730        wait_for_end(&mut rx).await;
731    }
732
733    #[tokio::test]
734    async fn state_is_running_during_a_turn_then_done_after() {
735        // A stream that emits one delta then hangs: the run is observably in
736        // flight until a cancel ends it.
737        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
738            let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
739            Box::pin(head.chain(futures::stream::pending())) as LlmStream
740        });
741        let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
742        assert_eq!(handle.state(), MemberState::Idle, "a virgin session is idle");
743
744        // A watcher wakes on the transition rather than polling.
745        let mut state_rx = handle.state_rx();
746        handle.send(Request::Submit { text: "hi".into() }).unwrap();
747        tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
748            .await
749            .expect("a state change")
750            .expect("open");
751        assert_eq!(*state_rx.borrow_and_update(), MemberState::Running);
752        assert_eq!(handle.state(), MemberState::Running, "the run is in flight");
753
754        handle.send(Request::Cancel).unwrap();
755        tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
756            .await
757            .expect("a state change")
758            .expect("open");
759        assert_eq!(
760            *state_rx.borrow_and_update(),
761            MemberState::Done,
762            "an abort ends Done, not Failed"
763        );
764        assert_eq!(handle.state(), MemberState::Done);
765    }
766
767    #[tokio::test]
768    async fn a_fatal_stream_error_ends_failed() {
769        let rec = Recorder::default();
770        rec.push(vec![LlmStreamEvent::Error {
771            message: "boom".into(),
772            fatal: true,
773        }]);
774        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
775        let mut rx = handle.subscribe();
776        handle.send(Request::Submit { text: "hi".into() }).unwrap();
777        wait_for_end(&mut rx).await;
778        assert_eq!(handle.state(), MemberState::Failed);
779    }
780
781    #[derive(Deserialize, schemars::JsonSchema)]
782    struct GateArgs {
783        text: String,
784    }
785
786    /// Blocks until released, so the actor can be steered while a run is inside
787    /// a tool call.
788    struct GateTool {
789        entered: mpsc::UnboundedSender<()>,
790        release: Arc<tokio::sync::Notify>,
791    }
792
793    #[async_trait::async_trait]
794    impl TypedTool for GateTool {
795        type Args = GateArgs;
796        fn name(&self) -> &str {
797            "gate"
798        }
799        fn description(&self) -> &str {
800            "blocks until released"
801        }
802        async fn execute(&self, args: GateArgs, _ctx: &ToolContext) -> ToolOutput {
803            self.entered.send(()).ok();
804            self.release.notified().await;
805            ToolOutput {
806                output: format!("released:{}", args.text),
807                is_error: false,
808                diff: None,
809                path: None,
810            }
811        }
812    }
813
814    #[tokio::test]
815    async fn config_request_during_a_run_is_deferred_to_after_it() {
816        // Run one: turn 1 blocks in the gate, turn 2 finishes — both on the
817        // run's own model. Run two is a fresh request and must see the swap.
818        let rec = Recorder::default();
819        rec.push(vec![
820            LlmStreamEvent::ToolCall {
821                id: "c1".into(),
822                name: "gate".into(),
823                arguments: json!({ "text": "hi" }),
824            },
825            LlmStreamEvent::Done {
826                stop_reason: StopReason::ToolUse,
827                usage: None,
828            },
829        ]);
830        for text in ["turn two", "run two"] {
831            rec.push(vec![
832                LlmStreamEvent::TextDelta(text.into()),
833                LlmStreamEvent::Done {
834                    stop_reason: StopReason::Stop,
835                    usage: None,
836                },
837            ]);
838        }
839
840        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
841        let release = Arc::new(tokio::sync::Notify::new());
842        let gate = erased(GateTool {
843            entered: entered_tx,
844            release: release.clone(),
845        });
846
847        let handle =
848            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
849        let mut rx = handle.subscribe();
850        handle.send(Request::Submit { text: "a".into() }).unwrap();
851
852        entered_rx.recv().await.unwrap(); // run one is inside the tool
853        handle
854            .send(Request::SetModel { model: "m2".into() })
855            .unwrap(); // must not take effect until run one is done
856        release.notify_one();
857        wait_for_end(&mut rx).await; // run one ends on m1
858
859        handle.send(Request::Submit { text: "b".into() }).unwrap();
860        wait_for_end(&mut rx).await; // run two
861
862        assert_eq!(
863            rec.models(),
864            ["m1", "m1", "m2"],
865            "the swap waited for the run, then applied to the next one"
866        );
867    }
868
869    #[tokio::test]
870    async fn ask_history_returns_the_conversation() {
871        let rec = Recorder::default();
872        rec.push(vec![
873            LlmStreamEvent::TextDelta("hi back".into()),
874            LlmStreamEvent::Done {
875                stop_reason: StopReason::Stop,
876                usage: None,
877            },
878        ]);
879        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
880        let mut rx = handle.subscribe();
881        handle
882            .send(Request::Submit {
883                text: "hello".into(),
884            })
885            .unwrap();
886        wait_for_end(&mut rx).await;
887
888        let reply = handle.ask(Request::GetHistory).await.unwrap();
889        let AgentEvent::History { messages } = reply else {
890            panic!("expected a History reply, got {reply:?}");
891        };
892        assert!(
893            messages.iter().any(|m| m.as_text() == "hello"),
894            "the read-back carries the user turn: {messages:?}"
895        );
896    }
897
898    #[tokio::test]
899    async fn ask_set_model_replies_ack_and_takes_effect() {
900        let rec = Recorder::default();
901        rec.push(vec![
902            LlmStreamEvent::TextDelta("x".into()),
903            LlmStreamEvent::Done {
904                stop_reason: StopReason::Stop,
905                usage: None,
906            },
907        ]);
908        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
909        let mut rx = handle.subscribe();
910
911        let reply = handle
912            .ask(Request::SetModel { model: "m2".into() })
913            .await
914            .unwrap();
915        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
916
917        handle.send(Request::Submit { text: "go".into() }).unwrap();
918        wait_for_end(&mut rx).await;
919        assert_eq!(rec.models(), ["m2"], "the swap applied to the next run");
920    }
921
922    #[tokio::test]
923    async fn define_on_a_local_session_is_an_error() {
924        let rec = Recorder::default();
925        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
926        let reply = handle
927            .ask(Request::Define {
928                name: Some("w1".into()),
929                model: None,
930                role: None,
931                tools: None,
932                base_url: None,
933                api_key: None,
934                read_only: false,
935                effort: None,
936            })
937            .await
938            .unwrap();
939        assert!(
940            matches!(&reply, AgentEvent::Error { message } if message.contains("served")),
941            "{reply:?}"
942        );
943    }
944
945    #[tokio::test]
946    async fn ask_compact_with_nothing_to_do_replies_ack() {
947        let handle = SessionActor::spawn(Agent::new(agent_config(
948            fake_stream_fn(&Recorder::default()),
949            vec![],
950        )));
951        let reply = handle
952            .ask(Request::Compact { instructions: None })
953            .await
954            .unwrap();
955        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
956    }
957
958    #[tokio::test]
959    async fn ask_submit_replies_stopped_with_the_reason() {
960        let rec = Recorder::default();
961        rec.push(vec![
962            LlmStreamEvent::TextDelta("done".into()),
963            LlmStreamEvent::Done {
964                stop_reason: StopReason::Stop,
965                usage: None,
966            },
967        ]);
968        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
969        let reply = handle
970            .ask(Request::Submit { text: "hi".into() })
971            .await
972            .unwrap();
973        assert!(
974            matches!(
975                reply,
976                AgentEvent::Stopped {
977                    stop_reason: StopReason::Stop
978                }
979            ),
980            "{reply:?}"
981        );
982    }
983
984    #[tokio::test]
985    async fn notify_appends_without_starting_a_turn() {
986        let rec = Recorder::default();
987        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
988        let mut rx = handle.subscribe();
989
990        let reply = handle
991            .ask(Request::Notify {
992                content: "ping".into(),
993            })
994            .await
995            .unwrap();
996        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
997
998        // The message was surfaced to subscribers...
999        let surfaced = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1000            .await
1001            .expect("an event")
1002            .expect("open");
1003        assert!(
1004            matches!(&surfaced, AgentEvent::MessageReceived { content, .. } if content == "ping"),
1005            "{surfaced:?}"
1006        );
1007
1008        // ...the conversation carries it...
1009        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1010            panic!("expected History");
1011        };
1012        assert!(
1013            messages
1014                .iter()
1015                .any(|m| m.as_text() == "[message from user]\nping"),
1016            "{messages:?}"
1017        );
1018
1019        // ...and no turn ran (the loop was never entered).
1020        assert!(rec.calls().is_empty(), "notify must not run the loop");
1021    }
1022
1023    #[tokio::test]
1024    async fn wake_starts_a_run_when_idle() {
1025        let rec = Recorder::default();
1026        rec.push(vec![
1027            LlmStreamEvent::TextDelta("acted".into()),
1028            LlmStreamEvent::Done {
1029                stop_reason: StopReason::Stop,
1030                usage: None,
1031            },
1032        ]);
1033        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1034        let mut rx = handle.subscribe();
1035
1036        let reply = handle
1037            .ask(Request::Wake {
1038                content: "do the task".into(),
1039            })
1040            .await
1041            .unwrap();
1042        assert!(
1043            matches!(
1044                reply,
1045                AgentEvent::Stopped {
1046                    stop_reason: StopReason::Stop
1047                }
1048            ),
1049            "{reply:?}"
1050        );
1051        wait_for_end(&mut rx).await;
1052
1053        let calls = rec.calls();
1054        assert_eq!(calls.len(), 1, "Wake ran the loop once");
1055        assert!(
1056            calls[0]
1057                .iter()
1058                .any(|m| m.as_text() == "[message from user]\ndo the task"),
1059            "the tagged task is the prompt: {:?}",
1060            calls[0]
1061        );
1062    }
1063
1064    struct InboundPolicy;
1065
1066    #[async_trait::async_trait]
1067    impl crate::hooks::Hooks for InboundPolicy {
1068        async fn before_inbound(
1069            &self,
1070            _from: Option<&SessionId>,
1071            request: &mut Request,
1072        ) -> Option<String> {
1073            let Request::Notify { content } = request else {
1074                return None;
1075            };
1076            if content.starts_with("drop:") {
1077                return Some("policy".into());
1078            }
1079            *content = format!("{content}!");
1080            None
1081        }
1082    }
1083
1084    #[tokio::test]
1085    async fn before_inbound_drops_and_rewrites_a_notify() {
1086        let rec = Recorder::default();
1087        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1088        cfg.hooks = HooksSet::one(Arc::new(InboundPolicy));
1089        let handle = SessionActor::spawn(Agent::new(cfg));
1090
1091        // Dropped: an `ask` is answered with an Error, and nothing lands.
1092        let reply = handle
1093            .ask(Request::Notify {
1094                content: "drop: me".into(),
1095            })
1096            .await
1097            .unwrap();
1098        assert!(
1099            matches!(&reply, AgentEvent::Error { message } if message.contains("policy")),
1100            "{reply:?}"
1101        );
1102
1103        // Rewritten: the hook's edit is what is recorded.
1104        let reply = handle
1105            .ask(Request::Notify {
1106                content: "keep me".into(),
1107            })
1108            .await
1109            .unwrap();
1110        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1111
1112        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1113            panic!("expected History");
1114        };
1115        let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1116        assert!(
1117            !texts.iter().any(|t| t == "drop: me"),
1118            "the dropped message never landed: {texts:?}"
1119        );
1120        assert!(
1121            texts.iter().any(|t| t == "[message from user]\nkeep me!"),
1122            "the rewrite landed: {texts:?}"
1123        );
1124    }
1125
1126    #[tokio::test]
1127    async fn interrupt_reaches_the_next_turn_while_a_tool_is_blocked() {
1128        let rec = Recorder::default();
1129        rec.push(vec![
1130            LlmStreamEvent::ToolCall {
1131                id: "c1".into(),
1132                name: "gate".into(),
1133                arguments: json!({ "text": "hi" }),
1134            },
1135            LlmStreamEvent::Done {
1136                stop_reason: StopReason::ToolUse,
1137                usage: None,
1138            },
1139        ]);
1140        rec.push(vec![
1141            LlmStreamEvent::TextDelta("after interrupt".into()),
1142            LlmStreamEvent::Done {
1143                stop_reason: StopReason::Stop,
1144                usage: None,
1145            },
1146        ]);
1147
1148        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1149        let release = Arc::new(tokio::sync::Notify::new());
1150        let gate = erased(GateTool {
1151            entered: entered_tx,
1152            release: release.clone(),
1153        });
1154
1155        let handle =
1156            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1157        let mut rx = handle.subscribe();
1158        handle.send(Request::Submit { text: "hi".into() }).unwrap();
1159
1160        entered_rx.recv().await.unwrap(); // the tool is now blocked
1161        handle
1162            .send(Request::Interrupt {
1163                content: "mid-run interrupt".into(),
1164            })
1165            .unwrap();
1166        release.notify_one();
1167
1168        wait_for_end(&mut rx).await;
1169
1170        let calls = rec.calls();
1171        assert_eq!(calls.len(), 2, "two stream calls: the tool turn and the next");
1172        assert!(
1173            calls[1]
1174                .iter()
1175                .any(|m| matches!(m, AgentMessage::User { .. })
1176                    && m.as_text() == "[message from user]\nmid-run interrupt"),
1177            "the interrupt drained into the next turn: {:?}",
1178            calls[1]
1179        );
1180    }
1181
1182    #[tokio::test]
1183    async fn send_from_attributes_the_sender() {
1184        let rec = Recorder::default();
1185        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1186        let mut rx = handle.subscribe();
1187
1188        handle
1189            .send_from(
1190                SessionId::agent("b"),
1191                Request::Notify {
1192                    content: "ping".into(),
1193                },
1194            )
1195            .unwrap();
1196
1197        let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1198            .await
1199            .expect("an event")
1200            .expect("open");
1201        assert!(
1202            matches!(&event, AgentEvent::MessageReceived { from, content }
1203                if from.as_str() == "agent:b" && content == "ping"),
1204            "{event:?}"
1205        );
1206    }
1207
1208    struct SenderPolicy;
1209
1210    #[async_trait::async_trait]
1211    impl crate::hooks::Hooks for SenderPolicy {
1212        async fn before_inbound(
1213            &self,
1214            from: Option<&SessionId>,
1215            _request: &mut Request,
1216        ) -> Option<String> {
1217            (from.map(SessionId::as_str) == Some("agent:evil")).then(|| "blocked sender".into())
1218        }
1219    }
1220
1221    #[tokio::test]
1222    async fn before_inbound_sees_the_sender() {
1223        let rec = Recorder::default();
1224        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1225        cfg.hooks = HooksSet::one(Arc::new(SenderPolicy));
1226        let handle = SessionActor::spawn(Agent::new(cfg));
1227
1228        // A hostile peer is dropped...
1229        let reply = handle
1230            .ask_from(
1231                SessionId::agent("evil"),
1232                Request::Notify {
1233                    content: "let me in".into(),
1234                },
1235            )
1236            .await
1237            .unwrap();
1238        assert!(
1239            matches!(&reply, AgentEvent::Error { message } if message.contains("blocked sender")),
1240            "{reply:?}"
1241        );
1242
1243        // ...a friendly one is not.
1244        let reply = handle
1245            .ask_from(
1246                SessionId::agent("friend"),
1247                Request::Notify {
1248                    content: "hello".into(),
1249                },
1250            )
1251            .await
1252            .unwrap();
1253        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1254
1255        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1256            panic!("expected History");
1257        };
1258        let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1259        assert!(
1260            texts
1261                .iter()
1262                .any(|t| t == "[message from agent:friend]\nhello"),
1263            "{texts:?}"
1264        );
1265        assert!(!texts.iter().any(|t| t.contains("let me in")), "{texts:?}");
1266    }
1267}