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};
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::{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}
139
140impl SessionHandle {
141    /// Submit a request to the session's inbox.
142    ///
143    /// Fails only if the session has shut down (every handle was dropped and the
144    /// actor task has ended). A `Submit` runs to completion before the next
145    /// non-interrupt request is serviced, but `Interrupt`/`Wake`/`Cancel` sent
146    /// during a run are handled immediately (see the actor's delivery rules).
147    pub fn send(&self, request: Request) -> Result<(), SessionClosed> {
148        self.tell(None, request)
149    }
150
151    /// Like [`SessionHandle::send`], but attributed to a sender address so the
152    /// recipient can attribute and policy-check the message (a peer session
153    /// passes its own `"agent:<id>"` address).
154    pub fn send_from(&self, from: SessionId, request: Request) -> Result<(), SessionClosed> {
155        self.tell(Some(from), request)
156    }
157
158    fn tell(&self, from: Option<SessionId>, request: Request) -> Result<(), SessionClosed> {
159        self.inbox
160            .send(Message::Tell { from, request })
161            .map_err(|_| SessionClosed)
162    }
163
164    /// Send a request and await its reply.
165    ///
166    /// `ask` is for requests that expect a reply: `GetHistory` (→
167    /// [`AgentEvent::History`]), `SetModel`/`SetEffort` (→ [`AgentEvent::Ack`]
168    /// or [`AgentEvent::Error`]), `Compact` (→ [`AgentEvent::Compaction`],
169    /// [`AgentEvent::Ack`], or [`AgentEvent::Error`]). It also works for
170    /// `Submit`, replying when the run completes. Interrupts
171    /// (`Interrupt`/`Wake`/`Cancel`) want no reply — use `send`; an `ask` on one
172    /// is deferred like any command while a run is in flight.
173    ///
174    /// Fails only if the session has shut down.
175    pub async fn ask(&self, request: Request) -> Result<AgentEvent, SessionClosed> {
176        self.question(None, request).await
177    }
178
179    /// Like [`SessionHandle::ask`], attributed to a sender address.
180    pub async fn ask_from(
181        &self,
182        from: SessionId,
183        request: Request,
184    ) -> Result<AgentEvent, SessionClosed> {
185        self.question(Some(from), request).await
186    }
187
188    async fn question(
189        &self,
190        from: Option<SessionId>,
191        request: Request,
192    ) -> Result<AgentEvent, SessionClosed> {
193        let (reply_tx, reply_rx) = oneshot::channel();
194        self.inbox
195            .send(Message::Ask {
196                from,
197                request,
198                reply: reply_tx,
199            })
200            .map_err(|_| SessionClosed)?;
201        reply_rx.await.map_err(|_| SessionClosed)
202    }
203
204    /// Subscribe to the session's event stream. Each subscriber gets its own
205    /// receiver; a lagging subscriber drops events rather than stalling the run.
206    pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
207        self.events.subscribe()
208    }
209}
210
211/// Returned when a [`SessionHandle::send`] targets a session that has ended.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct SessionClosed;
214
215impl fmt::Display for SessionClosed {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.write_str("session actor has shut down")
218    }
219}
220
221impl std::error::Error for SessionClosed {}
222
223/// A mailbox item: fire-and-forget, or a request that expects a reply.
224enum Message {
225    /// No reply wanted. `from` is the sender's address — `None` for a local
226    /// caller (the human), `Some(peer)` when another session sends.
227    Tell {
228        from: Option<SessionId>,
229        request: Request,
230    },
231    /// The reply to the request is sent here when it is serviced.
232    Ask {
233        from: Option<SessionId>,
234        request: Request,
235        reply: oneshot::Sender<AgentEvent>,
236    },
237}
238
239/// Spawns the actor task that owns an [`Agent`].
240///
241/// The `Agent` moves into a background task; all interaction returns through the
242/// [`SessionHandle`]. The task exits when the last handle drops.
243pub struct SessionActor;
244
245impl SessionActor {
246    pub fn spawn(agent: Agent) -> SessionHandle {
247        let (inbox_tx, inbox_rx) = mpsc::unbounded_channel();
248        let (events_tx, _) = broadcast::channel(EVENT_BUFFER);
249        let events = events_tx.clone();
250        tokio::spawn(serve(agent, inbox_rx, events_tx));
251        SessionHandle {
252            inbox: inbox_tx,
253            events,
254        }
255    }
256}
257
258/// The actor loop: pull one request at a time, service it, repeat.
259async fn serve(
260    mut agent: Agent,
261    mut inbox: mpsc::UnboundedReceiver<Message>,
262    events: broadcast::Sender<AgentEvent>,
263) {
264    // Requests that arrive *during* a run but cannot be applied to it (a
265    // second `Submit`, a model swap) wait here and are serviced once the run
266    // ends, preserving their order.
267    let mut deferred: VecDeque<Message> = VecDeque::new();
268
269    loop {
270        let message = match deferred.pop_front() {
271            Some(message) => message,
272            None => match inbox.recv().await {
273                Some(message) => message,
274                None => break, // every handle dropped: shut down
275            },
276        };
277
278        dispatch(&mut agent, message, &mut inbox, &events, &mut deferred).await;
279    }
280}
281
282/// Prefix an inbound peer message with its sender, so the model sees who sent
283/// it and knows the address to reply to (§13.13).
284fn tag(sender: &SessionId, content: &str) -> String {
285    format!("[message from {sender}]\n{content}")
286}
287
288/// Run the inbound policy on an A2A message (the mirror of
289/// `before_tool_call`). Returns `Some(reason)` if the message was dropped; the
290/// request may have been rewritten in place by the hook either way.
291async fn gate_inbound(hooks: &HooksSet, message: &mut Message) -> Option<String> {
292    let (from, request) = match message {
293        Message::Tell { from, request } | Message::Ask { from, request, .. } => (from, request),
294    };
295    if !crate::protocol::is_inbound(request) {
296        return None;
297    }
298    hooks.before_inbound(from.as_ref(), request).await
299}
300
301/// Service one mailbox item: run the request, then answer an `Ask` with the
302/// reply it produced (a `Tell` drops the reply on the floor).
303async fn dispatch(
304    agent: &mut Agent,
305    mut message: Message,
306    inbox: &mut mpsc::UnboundedReceiver<Message>,
307    events: &broadcast::Sender<AgentEvent>,
308    deferred: &mut VecDeque<Message>,
309) {
310    // Inbound peer-message policy (S4-1): a drop skips delivery entirely; a
311    // rewrite (in place) is what gets serviced.
312    if let Some(reason) = gate_inbound(agent.hooks(), &mut message).await {
313        if let Message::Ask { reply, .. } = message {
314            let _ = reply.send(AgentEvent::Error {
315                message: format!("inbound blocked: {reason}"),
316            });
317        }
318        return;
319    }
320
321    let (from, request, reply) = match message {
322        Message::Tell { from, request } => (from, request, None),
323        Message::Ask { from, request, reply } => (from, request, Some(reply)),
324    };
325    let sender = from.unwrap_or_else(SessionId::user);
326
327    let event = match request {
328        Request::Submit { text } => match run(agent, &text, inbox, events, deferred).await {
329            Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
330            Err(e) => AgentEvent::Error {
331                message: e.to_string(),
332            },
333        },
334        // A2A delivery verbs (S4-1). `Notify` appends with no turn;
335        // `Interrupt`/`Wake` are the canonical interrupt/continue forms.
336        Request::Notify { content } => match agent.notify(tag(&sender, &content)) {
337            Ok(_) => {
338                let _ = events.send(AgentEvent::MessageReceived {
339                    from: sender.clone(),
340                    content,
341                });
342                AgentEvent::Ack
343            }
344            Err(e) => AgentEvent::Error {
345                message: e.to_string(),
346            },
347        },
348        Request::Interrupt { content } => {
349            agent.steer(AgentMessage::user_text(tag(&sender, &content)));
350            let _ = events.send(AgentEvent::MessageReceived {
351                from: sender.clone(),
352                content,
353            });
354            AgentEvent::Ack
355        }
356        // A `Wake` runs even when idle: start a turn with the (tagged) content,
357        // like `Submit` — otherwise a worker handed a task would only queue a
358        // follow-up and never act on it.
359        Request::Wake { content } => {
360            let _ = events.send(AgentEvent::MessageReceived {
361                from: sender.clone(),
362                content: content.clone(),
363            });
364            match run(agent, &tag(&sender, &content), inbox, events, deferred).await {
365                Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
366                Err(e) => AgentEvent::Error {
367                    message: e.to_string(),
368                },
369            }
370        }
371        // An idle cancel is a no-op (see the delivery rules): the token is
372        // minted fresh at the end of each run, so cancelling an idle session
373        // would otherwise abort the next run on its first poll.
374        Request::Cancel => AgentEvent::Ack,
375        Request::SetModel { model } => match agent.set_model(model) {
376            Ok(()) => AgentEvent::Ack,
377            Err(e) => AgentEvent::Error {
378                message: e.to_string(),
379            },
380        },
381        Request::SetEffort { effort } => match agent.set_effort(effort) {
382            Ok(()) => AgentEvent::Ack,
383            Err(e) => AgentEvent::Error {
384                message: e.to_string(),
385            },
386        },
387        Request::Compact { instructions } => match agent.compact(instructions.as_deref()).await {
388            Ok(CompactOutcome::NothingToDo) => AgentEvent::Ack,
389            Ok(CompactOutcome::Done {
390                summarized, kept, ..
391            }) => AgentEvent::Compaction { summarized, kept },
392            Err(e) => AgentEvent::Error { message: e },
393        },
394        // `/btw`: a tool-free side call. `side_ask` takes `&self`, so no borrow
395        // conflict with the run. A `SideAsk` arriving mid-run is deferred to a
396        // turn boundary like any other inbox request (see the run loop's
397        // `deferred.push_back`) — queued, not refused.
398        Request::SideAsk { text } => match agent.side_ask(&text).await {
399            Ok(answer) => AgentEvent::SideAnswer {
400                text: answer.text,
401                usage: answer.usage,
402            },
403            Err(e) => AgentEvent::Error {
404                message: e.to_string(),
405            },
406        },
407        // Toggle plan mode: flips the shared handle (the `PlanModeHooks` reads
408        // the same one) and recomposes the system prompt. Infallible.
409        Request::SetPlanMode { on } => {
410            agent.set_plan_mode(on);
411            AgentEvent::Ack
412        }
413        Request::GetHistory => AgentEvent::History {
414            messages: agent.messages().to_vec(),
415        },
416        // A served peer is defined by a socket server's injected handler; an
417        // in-process session has no factory, so it can only refuse.
418        Request::Define { .. } => AgentEvent::Error {
419            message: "agent definition is served, not in-process".into(),
420        },
421        Request::ListSessions | Request::Unknown => AgentEvent::Ack,
422    };
423
424    if let Some(reply) = reply {
425        let _ = reply.send(event);
426    }
427}
428
429/// Drive one `Agent::run`, forwarding its events to the outbox while still
430/// servicing the inbox so `Interrupt`/`Wake`/`Cancel` reach the in-flight run.
431async fn run(
432    agent: &mut Agent,
433    text: &str,
434    inbox: &mut mpsc::UnboundedReceiver<Message>,
435    events: &broadcast::Sender<AgentEvent>,
436    deferred: &mut VecDeque<Message>,
437) -> Result<StopReason, LoopError> {
438    let (sink_tx, mut sink_rx) = mpsc::unbounded_channel::<AgentEvent>();
439    // Sender/token clones taken *before* `run` borrows the agent: steering and
440    // cancelling the in-flight run must not need the `&mut`.
441    let steer = agent.steer_sender();
442    let follow_up = agent.follow_up_sender();
443    let cancel = agent.cancel_token();
444    let hooks = agent.hooks().clone();
445
446    let mut run = Box::pin(agent.run(text, sink_tx));
447    let result = loop {
448        // Biased: service the inbox *before* advancing the run. An `Interrupt` must
449        // be in the agent's steering channel before the run reaches its next
450        // turn-boundary drain, or it is silently a turn late. Polling the run
451        // first would let it cross that boundary while the request still sits
452        // here.
453        tokio::select! {
454            biased;
455            Some(mut message) = inbox.recv() => {
456                if let Some(reason) = gate_inbound(&hooks, &mut message).await {
457                    if let Message::Ask { reply, .. } = message {
458                        let _ = reply.send(AgentEvent::Error {
459                            message: format!("inbound blocked: {reason}"),
460                        });
461                    }
462                    continue;
463                }
464                match message {
465                    Message::Tell {
466                        request: Request::Cancel,
467                        ..
468                    } => cancel.cancel(),
469                    Message::Tell {
470                        request: Request::Notify { content } | Request::Interrupt { content },
471                        from,
472                    } => {
473                        let from = from.unwrap_or_else(SessionId::user);
474                        let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
475                        let _ = events.send(AgentEvent::MessageReceived {
476                            from,
477                            content,
478                        });
479                    }
480                    Message::Tell {
481                        request: Request::Wake { content },
482                        from,
483                    } => {
484                        let from = from.unwrap_or_else(SessionId::user);
485                        let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
486                        let _ = events.send(AgentEvent::MessageReceived {
487                            from,
488                            content,
489                        });
490                    }
491                    other => deferred.push_back(other),
492                }
493            },
494            result = &mut run => break result,
495            Some(event) = sink_rx.recv() => {
496                let _ = events.send(event);
497            }
498        }
499    };
500
501    // The run dropped its sink on completion; forward whatever is still buffered
502    // so the last events (e.g. `AgentEnd`) are never stranded.
503    while let Ok(event) = sink_rx.try_recv() {
504        let _ = events.send(event);
505    }
506
507    // A session-write error inside the run is a `LoopError`; the run's own
508    // `AgentEvent::Error` covers stream failures, so surface this one the same
509    // way rather than dropping it silently.
510    if let Err(e) = &result {
511        let _ = events.send(AgentEvent::Error {
512            message: e.to_string(),
513        });
514    }
515    result
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::agent::{Agent, AgentConfig};
522    use crate::compaction::CompactionPolicy;
523    use crate::event::LlmStreamEvent;
524    use crate::hooks::HooksSet;
525    use crate::message::StopReason;
526    use crate::streamfn::{LlmOpts, LlmStream, StreamFn};
527    use crate::tool::{ToolContext, ToolOutput, TypedTool, erased};
528    use futures::StreamExt as _;
529    use serde::Deserialize;
530    use serde_json::json;
531    use std::sync::{Arc, Mutex};
532    use std::time::Duration;
533
534    #[derive(Clone, Default)]
535    struct Recorder {
536        script: Arc<Mutex<VecDeque<Vec<LlmStreamEvent>>>>,
537        calls: Arc<Mutex<Vec<Vec<AgentMessage>>>>,
538        models: Arc<Mutex<Vec<String>>>,
539    }
540
541    impl Recorder {
542        fn push(&self, events: Vec<LlmStreamEvent>) {
543            self.script.lock().unwrap().push_back(events);
544        }
545        fn calls(&self) -> Vec<Vec<AgentMessage>> {
546            self.calls.lock().unwrap().clone()
547        }
548        fn models(&self) -> Vec<String> {
549            self.models.lock().unwrap().clone()
550        }
551    }
552
553    fn fake_stream_fn(rec: &Recorder) -> StreamFn {
554        let rec = rec.clone();
555        Arc::new(
556            move |ctx: &[AgentMessage], _system, _tools, opts: &LlmOpts| {
557                rec.calls.lock().unwrap().push(ctx.to_vec());
558                rec.models.lock().unwrap().push(opts.model.clone());
559                let events = rec.script.lock().unwrap().pop_front().unwrap_or_default();
560                Box::pin(futures::stream::iter(events)) as LlmStream
561            },
562        )
563    }
564
565    fn agent_config(stream_fn: StreamFn, tools: Vec<crate::tool::Tool>) -> AgentConfig {
566        AgentConfig {
567            system: "sys".into(),
568            tools,
569            llm: LlmOpts {
570                model: "m1".into(),
571                ..LlmOpts::default()
572            },
573            stream_fn,
574            hooks: HooksSet::default(),
575            session: None,
576            context: Vec::new(),
577            working_dir: std::path::PathBuf::new(),
578            max_turns: crate::loop_::DEFAULT_MAX_TURNS,
579            parallel_tools: true,
580            compaction: CompactionPolicy::default(),
581            plan_mode: crate::hooks::PlanModeHandle::new(),
582        }
583    }
584
585    /// Block until the run ends, panicking if it stalls.
586    async fn wait_for_end(rx: &mut broadcast::Receiver<AgentEvent>) {
587        loop {
588            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
589                .await
590                .expect("an event within the timeout")
591                .expect("the outbox stays open");
592            if matches!(event, AgentEvent::AgentEnd) {
593                return;
594            }
595        }
596    }
597
598    #[tokio::test]
599    async fn submit_streams_events_to_a_subscriber() {
600        let rec = Recorder::default();
601        rec.push(vec![
602            LlmStreamEvent::TextDelta("hello".into()),
603            LlmStreamEvent::Done {
604                stop_reason: StopReason::Stop,
605                usage: None,
606            },
607        ]);
608        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
609        let mut rx = handle.subscribe();
610        handle.send(Request::Submit { text: "hi".into() }).unwrap();
611
612        let mut saw_text = false;
613        loop {
614            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
615                .await
616                .expect("an event")
617                .expect("open");
618            match event {
619                AgentEvent::MessageUpdate { message } if message.as_text() == "hello" => {
620                    saw_text = true;
621                }
622                AgentEvent::AgentEnd => break,
623                _ => {}
624            }
625        }
626        assert!(saw_text, "the streamed text reached the subscriber");
627    }
628
629    #[tokio::test]
630    async fn idle_cancel_does_not_poison_the_next_run() {
631        let rec = Recorder::default();
632        for text in ["one", "two"] {
633            rec.push(vec![
634                LlmStreamEvent::TextDelta(text.into()),
635                LlmStreamEvent::Done {
636                    stop_reason: StopReason::Stop,
637                    usage: None,
638                },
639            ]);
640        }
641        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
642        let mut rx = handle.subscribe();
643
644        handle.send(Request::Submit { text: "a".into() }).unwrap();
645        wait_for_end(&mut rx).await;
646
647        // No run in flight: this must be swallowed, not arm a kill for run two.
648        handle.send(Request::Cancel).unwrap();
649        handle.send(Request::Submit { text: "b".into() }).unwrap();
650
651        let mut saw_two = false;
652        loop {
653            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
654                .await
655                .expect("an event")
656                .expect("open");
657            match event {
658                AgentEvent::MessageUpdate { message } if message.as_text() == "two" => {
659                    saw_two = true;
660                }
661                AgentEvent::AgentEnd => break,
662                _ => {}
663            }
664        }
665        assert!(saw_two, "the second run completed despite the idle cancel");
666    }
667
668    #[tokio::test]
669    async fn cancel_aborts_an_in_flight_run() {
670        // One delta, then the stream hangs: only a working cancel ends the run.
671        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
672            let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
673            Box::pin(head.chain(futures::stream::pending())) as LlmStream
674        });
675        let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
676        let mut rx = handle.subscribe();
677        handle.send(Request::Submit { text: "hi".into() }).unwrap();
678
679        loop {
680            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
681                .await
682                .expect("an event")
683                .expect("open");
684            if matches!(event, AgentEvent::MessageUpdate { .. }) {
685                handle.send(Request::Cancel).unwrap();
686                break;
687            }
688        }
689        // Without a live cancel this would time out on the pending stream.
690        wait_for_end(&mut rx).await;
691    }
692
693    #[derive(Deserialize, schemars::JsonSchema)]
694    struct GateArgs {
695        text: String,
696    }
697
698    /// Blocks until released, so the actor can be steered while a run is inside
699    /// a tool call.
700    struct GateTool {
701        entered: mpsc::UnboundedSender<()>,
702        release: Arc<tokio::sync::Notify>,
703    }
704
705    #[async_trait::async_trait]
706    impl TypedTool for GateTool {
707        type Args = GateArgs;
708        fn name(&self) -> &str {
709            "gate"
710        }
711        fn description(&self) -> &str {
712            "blocks until released"
713        }
714        async fn execute(&self, args: GateArgs, _ctx: &ToolContext) -> ToolOutput {
715            self.entered.send(()).ok();
716            self.release.notified().await;
717            ToolOutput {
718                output: format!("released:{}", args.text),
719                is_error: false,
720                diff: None,
721                path: None,
722            }
723        }
724    }
725
726    #[tokio::test]
727    async fn config_request_during_a_run_is_deferred_to_after_it() {
728        // Run one: turn 1 blocks in the gate, turn 2 finishes — both on the
729        // run's own model. Run two is a fresh request and must see the swap.
730        let rec = Recorder::default();
731        rec.push(vec![
732            LlmStreamEvent::ToolCall {
733                id: "c1".into(),
734                name: "gate".into(),
735                arguments: json!({ "text": "hi" }),
736            },
737            LlmStreamEvent::Done {
738                stop_reason: StopReason::ToolUse,
739                usage: None,
740            },
741        ]);
742        for text in ["turn two", "run two"] {
743            rec.push(vec![
744                LlmStreamEvent::TextDelta(text.into()),
745                LlmStreamEvent::Done {
746                    stop_reason: StopReason::Stop,
747                    usage: None,
748                },
749            ]);
750        }
751
752        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
753        let release = Arc::new(tokio::sync::Notify::new());
754        let gate = erased(GateTool {
755            entered: entered_tx,
756            release: release.clone(),
757        });
758
759        let handle =
760            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
761        let mut rx = handle.subscribe();
762        handle.send(Request::Submit { text: "a".into() }).unwrap();
763
764        entered_rx.recv().await.unwrap(); // run one is inside the tool
765        handle
766            .send(Request::SetModel { model: "m2".into() })
767            .unwrap(); // must not take effect until run one is done
768        release.notify_one();
769        wait_for_end(&mut rx).await; // run one ends on m1
770
771        handle.send(Request::Submit { text: "b".into() }).unwrap();
772        wait_for_end(&mut rx).await; // run two
773
774        assert_eq!(
775            rec.models(),
776            ["m1", "m1", "m2"],
777            "the swap waited for the run, then applied to the next one"
778        );
779    }
780
781    #[tokio::test]
782    async fn ask_history_returns_the_conversation() {
783        let rec = Recorder::default();
784        rec.push(vec![
785            LlmStreamEvent::TextDelta("hi back".into()),
786            LlmStreamEvent::Done {
787                stop_reason: StopReason::Stop,
788                usage: None,
789            },
790        ]);
791        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
792        let mut rx = handle.subscribe();
793        handle
794            .send(Request::Submit {
795                text: "hello".into(),
796            })
797            .unwrap();
798        wait_for_end(&mut rx).await;
799
800        let reply = handle.ask(Request::GetHistory).await.unwrap();
801        let AgentEvent::History { messages } = reply else {
802            panic!("expected a History reply, got {reply:?}");
803        };
804        assert!(
805            messages.iter().any(|m| m.as_text() == "hello"),
806            "the read-back carries the user turn: {messages:?}"
807        );
808    }
809
810    #[tokio::test]
811    async fn ask_set_model_replies_ack_and_takes_effect() {
812        let rec = Recorder::default();
813        rec.push(vec![
814            LlmStreamEvent::TextDelta("x".into()),
815            LlmStreamEvent::Done {
816                stop_reason: StopReason::Stop,
817                usage: None,
818            },
819        ]);
820        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
821        let mut rx = handle.subscribe();
822
823        let reply = handle
824            .ask(Request::SetModel { model: "m2".into() })
825            .await
826            .unwrap();
827        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
828
829        handle.send(Request::Submit { text: "go".into() }).unwrap();
830        wait_for_end(&mut rx).await;
831        assert_eq!(rec.models(), ["m2"], "the swap applied to the next run");
832    }
833
834    #[tokio::test]
835    async fn define_on_a_local_session_is_an_error() {
836        let rec = Recorder::default();
837        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
838        let reply = handle
839            .ask(Request::Define {
840                name: Some("w1".into()),
841                model: None,
842                role: None,
843                tools: None,
844                base_url: None,
845                api_key: None,
846                read_only: false,
847                effort: None,
848            })
849            .await
850            .unwrap();
851        assert!(
852            matches!(&reply, AgentEvent::Error { message } if message.contains("served")),
853            "{reply:?}"
854        );
855    }
856
857    #[tokio::test]
858    async fn ask_compact_with_nothing_to_do_replies_ack() {
859        let handle = SessionActor::spawn(Agent::new(agent_config(
860            fake_stream_fn(&Recorder::default()),
861            vec![],
862        )));
863        let reply = handle
864            .ask(Request::Compact { instructions: None })
865            .await
866            .unwrap();
867        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
868    }
869
870    #[tokio::test]
871    async fn ask_submit_replies_stopped_with_the_reason() {
872        let rec = Recorder::default();
873        rec.push(vec![
874            LlmStreamEvent::TextDelta("done".into()),
875            LlmStreamEvent::Done {
876                stop_reason: StopReason::Stop,
877                usage: None,
878            },
879        ]);
880        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
881        let reply = handle
882            .ask(Request::Submit { text: "hi".into() })
883            .await
884            .unwrap();
885        assert!(
886            matches!(
887                reply,
888                AgentEvent::Stopped {
889                    stop_reason: StopReason::Stop
890                }
891            ),
892            "{reply:?}"
893        );
894    }
895
896    #[tokio::test]
897    async fn notify_appends_without_starting_a_turn() {
898        let rec = Recorder::default();
899        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
900        let mut rx = handle.subscribe();
901
902        let reply = handle
903            .ask(Request::Notify {
904                content: "ping".into(),
905            })
906            .await
907            .unwrap();
908        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
909
910        // The message was surfaced to subscribers...
911        let surfaced = tokio::time::timeout(Duration::from_secs(1), rx.recv())
912            .await
913            .expect("an event")
914            .expect("open");
915        assert!(
916            matches!(&surfaced, AgentEvent::MessageReceived { content, .. } if content == "ping"),
917            "{surfaced:?}"
918        );
919
920        // ...the conversation carries it...
921        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
922            panic!("expected History");
923        };
924        assert!(
925            messages
926                .iter()
927                .any(|m| m.as_text() == "[message from user]\nping"),
928            "{messages:?}"
929        );
930
931        // ...and no turn ran (the loop was never entered).
932        assert!(rec.calls().is_empty(), "notify must not run the loop");
933    }
934
935    #[tokio::test]
936    async fn wake_starts_a_run_when_idle() {
937        let rec = Recorder::default();
938        rec.push(vec![
939            LlmStreamEvent::TextDelta("acted".into()),
940            LlmStreamEvent::Done {
941                stop_reason: StopReason::Stop,
942                usage: None,
943            },
944        ]);
945        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
946        let mut rx = handle.subscribe();
947
948        let reply = handle
949            .ask(Request::Wake {
950                content: "do the task".into(),
951            })
952            .await
953            .unwrap();
954        assert!(
955            matches!(
956                reply,
957                AgentEvent::Stopped {
958                    stop_reason: StopReason::Stop
959                }
960            ),
961            "{reply:?}"
962        );
963        wait_for_end(&mut rx).await;
964
965        let calls = rec.calls();
966        assert_eq!(calls.len(), 1, "Wake ran the loop once");
967        assert!(
968            calls[0]
969                .iter()
970                .any(|m| m.as_text() == "[message from user]\ndo the task"),
971            "the tagged task is the prompt: {:?}",
972            calls[0]
973        );
974    }
975
976    struct InboundPolicy;
977
978    #[async_trait::async_trait]
979    impl crate::hooks::Hooks for InboundPolicy {
980        async fn before_inbound(
981            &self,
982            _from: Option<&SessionId>,
983            request: &mut Request,
984        ) -> Option<String> {
985            let Request::Notify { content } = request else {
986                return None;
987            };
988            if content.starts_with("drop:") {
989                return Some("policy".into());
990            }
991            *content = format!("{content}!");
992            None
993        }
994    }
995
996    #[tokio::test]
997    async fn before_inbound_drops_and_rewrites_a_notify() {
998        let rec = Recorder::default();
999        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1000        cfg.hooks = HooksSet::one(Arc::new(InboundPolicy));
1001        let handle = SessionActor::spawn(Agent::new(cfg));
1002
1003        // Dropped: an `ask` is answered with an Error, and nothing lands.
1004        let reply = handle
1005            .ask(Request::Notify {
1006                content: "drop: me".into(),
1007            })
1008            .await
1009            .unwrap();
1010        assert!(
1011            matches!(&reply, AgentEvent::Error { message } if message.contains("policy")),
1012            "{reply:?}"
1013        );
1014
1015        // Rewritten: the hook's edit is what is recorded.
1016        let reply = handle
1017            .ask(Request::Notify {
1018                content: "keep me".into(),
1019            })
1020            .await
1021            .unwrap();
1022        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1023
1024        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1025            panic!("expected History");
1026        };
1027        let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1028        assert!(
1029            !texts.iter().any(|t| t == "drop: me"),
1030            "the dropped message never landed: {texts:?}"
1031        );
1032        assert!(
1033            texts.iter().any(|t| t == "[message from user]\nkeep me!"),
1034            "the rewrite landed: {texts:?}"
1035        );
1036    }
1037
1038    #[tokio::test]
1039    async fn interrupt_reaches_the_next_turn_while_a_tool_is_blocked() {
1040        let rec = Recorder::default();
1041        rec.push(vec![
1042            LlmStreamEvent::ToolCall {
1043                id: "c1".into(),
1044                name: "gate".into(),
1045                arguments: json!({ "text": "hi" }),
1046            },
1047            LlmStreamEvent::Done {
1048                stop_reason: StopReason::ToolUse,
1049                usage: None,
1050            },
1051        ]);
1052        rec.push(vec![
1053            LlmStreamEvent::TextDelta("after interrupt".into()),
1054            LlmStreamEvent::Done {
1055                stop_reason: StopReason::Stop,
1056                usage: None,
1057            },
1058        ]);
1059
1060        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1061        let release = Arc::new(tokio::sync::Notify::new());
1062        let gate = erased(GateTool {
1063            entered: entered_tx,
1064            release: release.clone(),
1065        });
1066
1067        let handle =
1068            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1069        let mut rx = handle.subscribe();
1070        handle.send(Request::Submit { text: "hi".into() }).unwrap();
1071
1072        entered_rx.recv().await.unwrap(); // the tool is now blocked
1073        handle
1074            .send(Request::Interrupt {
1075                content: "mid-run interrupt".into(),
1076            })
1077            .unwrap();
1078        release.notify_one();
1079
1080        wait_for_end(&mut rx).await;
1081
1082        let calls = rec.calls();
1083        assert_eq!(calls.len(), 2, "two stream calls: the tool turn and the next");
1084        assert!(
1085            calls[1]
1086                .iter()
1087                .any(|m| matches!(m, AgentMessage::User { .. })
1088                    && m.as_text() == "[message from user]\nmid-run interrupt"),
1089            "the interrupt drained into the next turn: {:?}",
1090            calls[1]
1091        );
1092    }
1093
1094    #[tokio::test]
1095    async fn send_from_attributes_the_sender() {
1096        let rec = Recorder::default();
1097        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1098        let mut rx = handle.subscribe();
1099
1100        handle
1101            .send_from(
1102                SessionId::agent("b"),
1103                Request::Notify {
1104                    content: "ping".into(),
1105                },
1106            )
1107            .unwrap();
1108
1109        let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1110            .await
1111            .expect("an event")
1112            .expect("open");
1113        assert!(
1114            matches!(&event, AgentEvent::MessageReceived { from, content }
1115                if from.as_str() == "agent:b" && content == "ping"),
1116            "{event:?}"
1117        );
1118    }
1119
1120    struct SenderPolicy;
1121
1122    #[async_trait::async_trait]
1123    impl crate::hooks::Hooks for SenderPolicy {
1124        async fn before_inbound(
1125            &self,
1126            from: Option<&SessionId>,
1127            _request: &mut Request,
1128        ) -> Option<String> {
1129            (from.map(SessionId::as_str) == Some("agent:evil")).then(|| "blocked sender".into())
1130        }
1131    }
1132
1133    #[tokio::test]
1134    async fn before_inbound_sees_the_sender() {
1135        let rec = Recorder::default();
1136        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1137        cfg.hooks = HooksSet::one(Arc::new(SenderPolicy));
1138        let handle = SessionActor::spawn(Agent::new(cfg));
1139
1140        // A hostile peer is dropped...
1141        let reply = handle
1142            .ask_from(
1143                SessionId::agent("evil"),
1144                Request::Notify {
1145                    content: "let me in".into(),
1146                },
1147            )
1148            .await
1149            .unwrap();
1150        assert!(
1151            matches!(&reply, AgentEvent::Error { message } if message.contains("blocked sender")),
1152            "{reply:?}"
1153        );
1154
1155        // ...a friendly one is not.
1156        let reply = handle
1157            .ask_from(
1158                SessionId::agent("friend"),
1159                Request::Notify {
1160                    content: "hello".into(),
1161                },
1162            )
1163            .await
1164            .unwrap();
1165        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1166
1167        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1168            panic!("expected History");
1169        };
1170        let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1171        assert!(
1172            texts
1173                .iter()
1174                .any(|t| t == "[message from agent:friend]\nhello"),
1175            "{texts:?}"
1176        );
1177        assert!(!texts.iter().any(|t| t.contains("let me in")), "{texts:?}");
1178    }
1179}