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//!        SetTitle  {..} ─► agent.set_title(..)   → appends a `Title` entry
43//!        Compact   {..} ─► agent.compact(..).await
44//!        GetHistory     ─► reply with the context
45//!        Usage          ─► reply with folded stats (+ $ when priced)
46//!        Unknown        ─► (ignored)
47//! ```
48//!
49//! `run()` — the Agent is borrowed for the whole run, so it is reached only
50//! through clones taken *before* the borrow.
51//!
52//! ```text
53//!   steer     = agent.steer_sender()
54//!   follow_up = agent.follow_up_sender()
55//!   cancel    = agent.cancel_token()
56//!
57//!   loop { tokio::select! { biased; ... } }   ← biased: inbox polled FIRST
58//!        ├─ Some(req) = inbox.recv() ─► Cancel   ─► cancel.cancel()         ┐ to the
59//!        │                              Steer    ─► steer.send(msg)         │ live run
60//!        │                              FollowUp ─► follow_up.send(msg)     │
61//!        │                              other    ─► deferred.push_back(req) ┘ waits
62//!        ├─ result  = &mut run  ──────► run finished → break
63//!        └─ Some(ev) = sink_rx.recv() ► events.send(ev)  (fan-out to subscribers)
64//!   }
65//!   then: drain leftover sink_rx → events.send ; on Err(run) → Error event
66//! ```
67//!
68//! [`Frame`]: crate::protocol::Frame
69//!
70//! ## Delivery rules
71//!
72//! A run holds the agent's `&mut` for its whole life, so the actor splits the
73//! inbox by what each request needs:
74//!
75//! | request | while a run is in flight |
76//! |---------|--------------------------|
77//! | `Cancel` | delivered at once — the token is cancelled; the loop aborts |
78//! | `Interrupt` / `Wake` | forwarded to the run's channels at once |
79//! | `SetModel` / `SetEffort` / `SetTitle` / `Compact` | deferred, applied when the run ends |
80//! | another `Submit` | deferred, becomes the next run |
81//!
82//! The interactive path therefore never waits on a run. The deferred set is
83//! exactly the requests that need `&mut Agent` — you cannot swap the model or
84//! compact the context out from under a run — and deferring them is also the
85//! documented meaning: `set_model`/`set_effort` "take effect on the next run".
86//!
87//! One caveat lives in the kernel, not here — the actor hands a `Cancel` over at
88//! once, but the *loop* is what acts on it, and its latency depends on where the
89//! run is:
90//!
91//! ```text
92//! where the run is when Cancel arrives          when it stops
93//! ---------------------------------------------  -----------------------
94//! model streaming     (loop races the token)     immediately
95//! between turns       (checked at turn start)    immediately
96//! inside a tool call  (no race on the future)    when the tool returns *
97//! ```
98//!
99//! `*` unless the tool watches `ToolContext::cancel` itself (e.g. `bash` kills
100//! its child) — then it can stop early.
101//!
102//! ## Requests and replies
103//!
104//! `send` is fire-and-forget; `ask` awaits a reply. The reply is an
105//! [`AgentEvent`] reply variant — [`AgentEvent::History`] for `GetHistory`,
106//! [`AgentEvent::Ack`]/[`AgentEvent::Error`] for `SetModel`/`SetEffort`/
107//! [`AgentEvent::Compaction`] for `Compact`, [`AgentEvent::Usage`] for `Usage`
108//! — correlated in-process by a
109//! one-shot channel. Replies are never fanned out on the event stream; the wire
110//! (S2) will carry the same variants correlated by the envelope's `reply_to`.
111//!
112//! `SetTitle` is the one request that answers with BOTH: the reply is an
113//! [`AgentEvent::SessionTitle`], and the same event is *streamed* so a UI
114//! subscriber (and, mid-run, the deferred `Tell` — which gets no reply at all)
115//! still learns the name.
116//!
117//! Scope so far: every request variant that maps onto an existing [`Agent`]
118//! method is serviced. Still to come (S1b): pointing the CLI at a handle.
119
120use std::collections::VecDeque;
121use std::fmt;
122
123use tokio::sync::{broadcast, mpsc, oneshot, watch};
124
125use crate::agent::Agent;
126use crate::compaction::CompactOutcome;
127use crate::event::AgentEvent;
128use crate::hooks::HooksSet;
129use crate::loop_::LoopError;
130use crate::message::{AgentMessage, StopReason};
131use crate::protocol::{MemberState, Request, SessionId};
132use crate::streamfn::LlmOpts;
133
134/// Default outbox buffer: how far a subscriber may lag before it starts losing
135/// events (`broadcast` semantics — the slowest reader drops, it never blocks the
136/// run).
137pub const EVENT_BUFFER: usize = 1024;
138
139/// Handle to a running [`SessionActor`].
140///
141/// Cloning gives another caller the same mailbox and stream. When the last
142/// handle drops, the session shuts down and its [`Agent`] is dropped.
143#[derive(Clone)]
144pub struct SessionHandle {
145    inbox: mpsc::UnboundedSender<Message>,
146    events: broadcast::Sender<AgentEvent>,
147    /// The run-state cell — the ONE server-side liveness fact (bug 2). A `watch`
148    /// (not a bare `AtomicU8`) so a registry/server task can WAKE on a transition
149    /// and re-push the roster; `send_replace` keeps the latest value even with no
150    /// receiver yet. `Clone` rides freely (the sender is shared).
151    state: watch::Sender<MemberState>,
152}
153
154impl SessionHandle {
155    /// Submit a request to the session's inbox.
156    ///
157    /// Fails only if the session has shut down (every handle was dropped and the
158    /// actor task has ended). A `Submit` runs to completion before the next
159    /// non-interrupt request is serviced, but `Interrupt`/`Wake`/`Cancel` sent
160    /// during a run are handled immediately (see the actor's delivery rules).
161    pub fn send(&self, request: Request) -> Result<(), SessionClosed> {
162        self.tell(None, request)
163    }
164
165    /// Like [`SessionHandle::send`], but attributed to a sender address so the
166    /// recipient can attribute and policy-check the message (a peer session
167    /// passes its own `"agent:<id>"` address).
168    pub fn send_from(&self, from: SessionId, request: Request) -> Result<(), SessionClosed> {
169        self.tell(Some(from), request)
170    }
171
172    fn tell(&self, from: Option<SessionId>, request: Request) -> Result<(), SessionClosed> {
173        self.inbox
174            .send(Message::Tell { from, request })
175            .map_err(|_| SessionClosed)
176    }
177
178    /// Send a request and await its reply.
179    ///
180    /// `ask` is for requests that expect a reply: `GetHistory` (→
181    /// [`AgentEvent::History`]), `SetModel`/`SetEffort` (→ [`AgentEvent::Ack`]
182    /// or [`AgentEvent::Error`]), `Compact` (→ [`AgentEvent::Compaction`],
183    /// [`AgentEvent::Ack`], or [`AgentEvent::Error`]). It also works for
184    /// `Submit`, replying when the run completes. Interrupts
185    /// (`Interrupt`/`Wake`/`Cancel`) want no reply — use `send`; an `ask` on one
186    /// still lands mid-run (the actor answers it `Ack`), so a server-side `cancel`
187    /// is never deferred.
188    ///
189    /// Fails only if the session has shut down.
190    pub async fn ask(&self, request: Request) -> Result<AgentEvent, SessionClosed> {
191        self.question(None, request).await
192    }
193
194    /// Like [`SessionHandle::ask`], attributed to a sender address.
195    pub async fn ask_from(
196        &self,
197        from: SessionId,
198        request: Request,
199    ) -> Result<AgentEvent, SessionClosed> {
200        self.question(Some(from), request).await
201    }
202
203    async fn question(
204        &self,
205        from: Option<SessionId>,
206        request: Request,
207    ) -> Result<AgentEvent, SessionClosed> {
208        let (reply_tx, reply_rx) = oneshot::channel();
209        self.inbox
210            .send(Message::Ask {
211                from,
212                request,
213                reply: reply_tx,
214            })
215            .map_err(|_| SessionClosed)?;
216        reply_rx.await.map_err(|_| SessionClosed)
217    }
218
219    /// The session's current run-state. Read-only, lock-free (a `watch` borrow),
220    /// so a `peers` listing / roster read over N members is cheap.
221    pub fn state(&self) -> MemberState {
222        *self.state.borrow()
223    }
224
225    /// A live view of the run-state, so a registry/server task can wake on a
226    /// transition instead of polling. Seeded with the current value (like
227    /// `Registry::subscribe`).
228    pub fn state_rx(&self) -> watch::Receiver<MemberState> {
229        self.state.subscribe()
230    }
231    /// Subscribe to the session's event stream. Each subscriber gets its own
232    /// receiver; a lagging subscriber drops events rather than stalling the run.
233    pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
234        self.events.subscribe()
235    }
236}
237
238/// Returned when a [`SessionHandle::send`] targets a session that has ended.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct SessionClosed;
241
242impl fmt::Display for SessionClosed {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str("session actor has shut down")
245    }
246}
247
248impl std::error::Error for SessionClosed {}
249
250/// A mailbox item: fire-and-forget, or a request that expects a reply.
251enum Message {
252    /// No reply wanted. `from` is the sender's address — `None` for a local
253    /// caller (the human), `Some(peer)` when another session sends.
254    Tell {
255        from: Option<SessionId>,
256        request: Request,
257    },
258    /// The reply to the request is sent here when it is serviced.
259    Ask {
260        from: Option<SessionId>,
261        request: Request,
262        reply: oneshot::Sender<AgentEvent>,
263    },
264}
265
266/// Spawns the actor task that owns an [`Agent`].
267///
268/// The `Agent` moves into a background task; all interaction returns through the
269/// [`SessionHandle`]. The task exits when the last handle drops.
270pub struct SessionActor;
271
272impl SessionActor {
273    pub fn spawn(agent: Agent) -> SessionHandle {
274        let (inbox_tx, inbox_rx) = mpsc::unbounded_channel();
275        let (events_tx, _) = broadcast::channel(EVENT_BUFFER);
276        let events = events_tx.clone();
277        let (state_tx, _state_rx) = watch::channel(MemberState::Idle);
278        tokio::spawn(serve(agent, inbox_rx, events_tx, state_tx.clone()));
279        SessionHandle {
280            inbox: inbox_tx,
281            events,
282            state: state_tx,
283        }
284    }
285}
286
287/// The actor loop: pull one request at a time, service it, repeat.
288async fn serve(
289    mut agent: Agent,
290    mut inbox: mpsc::UnboundedReceiver<Message>,
291    events: broadcast::Sender<AgentEvent>,
292    state: watch::Sender<MemberState>,
293) {
294    // Requests that arrive *during* a run but cannot be applied to it (a
295    // second `Submit`, a model swap) wait here and are serviced once the run
296    // ends, preserving their order.
297    let mut deferred: VecDeque<Message> = VecDeque::new();
298
299    loop {
300        let message = match deferred.pop_front() {
301            Some(message) => message,
302            None => match inbox.recv().await {
303                Some(message) => message,
304                None => break, // every handle dropped: shut down
305            },
306        };
307
308        dispatch(&mut agent, message, &mut inbox, &events, &mut deferred, &state).await;
309    }
310}
311
312/// Prefix an inbound peer message with its sender, so the model sees who sent
313/// it and knows the address to reply to (§13.13).
314fn tag(sender: &SessionId, content: &str) -> String {
315    format!("[message from {sender}]\n{content}")
316}
317
318/// Run the inbound policy on an A2A message (the mirror of
319/// `before_tool_call`). Returns `Some(reason)` if the message was dropped; the
320/// request may have been rewritten in place by the hook either way.
321async fn gate_inbound(hooks: &HooksSet, message: &mut Message) -> Option<String> {
322    let (from, request) = match message {
323        Message::Tell { from, request } | Message::Ask { from, request, .. } => (from, request),
324    };
325    if !crate::protocol::is_inbound(request) {
326        return None;
327    }
328    hooks.before_inbound(from.as_ref(), request).await
329}
330
331/// Build the `Request::Usage` reply: fold the transcript, then price it with
332/// the provider THIS session bills against — the one body both arms share, so
333/// an idle and a mid-run report can never disagree about what a report is.
334///
335/// Contract:
336/// - the fold is the pure [`crate::stats::session_stats`] over `messages` (the
337///   idle arm passes the live `Agent::messages()`, the mid-run arm the
338///   turn-boundary snapshot);
339/// - `cost` comes from [`crate::pricing::cost_of`] on the SAME slice with
340///   `llm.base_url.as_deref()` / `&llm.model`, so a model switched mid-session
341///   is priced per message and the turns that cannot be priced are COUNTED
342///   (`Cost::unpriced_turns`), not silently dropped;
343/// - an unknown provider or model yields `cost: None` inside an otherwise
344///   valid report — a missing price is never an [`AgentEvent::Error`];
345/// - empty `messages` ⇒ a `SessionStats::default()` in the reply, i.e.
346///   "(no usage reported)" downstream: a fresh session answers, it does not fail.
347///
348/// Does NOT promise: a `$` for a non-zen provider, tier rates, or a per-model
349/// `base_url` (see `pricing`'s module doc). Panics on nothing.
350fn usage_reply(messages: &[AgentMessage], llm: &LlmOpts) -> AgentEvent {
351    let mut stats = crate::stats::session_stats(messages);
352    stats.cost = crate::pricing::cost_of(messages, llm.base_url.as_deref(), &llm.model);
353    AgentEvent::Usage { stats }
354}
355
356/// Service one mailbox item: run the request, then answer an `Ask` with the
357/// reply it produced (a `Tell` drops the reply on the floor).
358async fn dispatch(
359    agent: &mut Agent,
360    mut message: Message,
361    inbox: &mut mpsc::UnboundedReceiver<Message>,
362    events: &broadcast::Sender<AgentEvent>,
363    deferred: &mut VecDeque<Message>,
364    state: &watch::Sender<MemberState>,
365) {
366    // Inbound peer-message policy (S4-1): a drop skips delivery entirely; a
367    // rewrite (in place) is what gets serviced.
368    if let Some(reason) = gate_inbound(agent.hooks(), &mut message).await {
369        if let Message::Ask { reply, .. } = message {
370            let _ = reply.send(AgentEvent::Error {
371                message: format!("inbound blocked: {reason}"),
372            });
373        }
374        return;
375    }
376
377    let (from, request, reply) = match message {
378        Message::Tell { from, request } => (from, request, None),
379        Message::Ask { from, request, reply } => (from, request, Some(reply)),
380    };
381    let sender = from.unwrap_or_else(SessionId::user);
382
383    let event = match request {
384        Request::Submit { text } => match run(agent, &text, inbox, events, deferred, state).await {
385            Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
386            Err(e) => AgentEvent::Error {
387                message: e.to_string(),
388            },
389        },
390        // A2A delivery verbs (S4-1). `Notify` appends with no turn;
391        // `Interrupt`/`Wake` are the canonical interrupt/continue forms.
392        Request::Notify { content } => match agent.notify(tag(&sender, &content)) {
393            Ok(_) => {
394                let _ = events.send(AgentEvent::MessageReceived {
395                    from: sender.clone(),
396                    content,
397                });
398                AgentEvent::Ack
399            }
400            Err(e) => AgentEvent::Error {
401                message: e.to_string(),
402            },
403        },
404        Request::Interrupt { content } => {
405            agent.steer(AgentMessage::user_text(tag(&sender, &content)));
406            let _ = events.send(AgentEvent::MessageReceived {
407                from: sender.clone(),
408                content,
409            });
410            AgentEvent::Ack
411        }
412        // A `Wake` runs even when idle: start a turn with the (tagged) content,
413        // like `Submit` — otherwise a worker handed a task would only queue a
414        // follow-up and never act on it.
415        Request::Wake { content } => {
416            let _ = events.send(AgentEvent::MessageReceived {
417                from: sender.clone(),
418                content: content.clone(),
419            });
420            match run(agent, &tag(&sender, &content), inbox, events, deferred, state).await {
421                Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
422                Err(e) => AgentEvent::Error {
423                    message: e.to_string(),
424                },
425            }
426        }
427        // An idle cancel is a no-op (see the delivery rules): the token is
428        // minted fresh at the end of each run, so cancelling an idle session
429        // would otherwise abort the next run on its first poll.
430        Request::Cancel => AgentEvent::Ack,
431        Request::SetModel { model } => match agent.set_model(model) {
432            Ok(()) => AgentEvent::Ack,
433            Err(e) => AgentEvent::Error {
434                message: e.to_string(),
435            },
436        },
437        Request::SetEffort { effort } => match agent.set_effort(effort) {
438            Ok(()) => AgentEvent::Ack,
439            Err(e) => AgentEvent::Error {
440                message: e.to_string(),
441            },
442        },
443        Request::Compact { instructions } => match agent.compact(instructions.as_deref()).await {
444            Ok(CompactOutcome::NothingToDo) => AgentEvent::Ack,
445            Ok(CompactOutcome::Done {
446                summarized, kept, ..
447            }) => AgentEvent::Compaction { summarized, kept },
448            Err(e) => AgentEvent::Error { message: e },
449        },
450        // `/btw`: a tool-free side call. `side_ask` takes `&self`, so no borrow
451        // conflict with the run. A `SideAsk` arriving mid-run is deferred to a
452        // turn boundary like any other inbox request (see the run loop's
453        // `deferred.push_back`) — queued, not refused.
454        Request::SideAsk { text } => match agent.side_ask(&text).await {
455            Ok(answer) => AgentEvent::SideAnswer {
456                text: answer.text,
457                usage: answer.usage,
458            },
459            Err(e) => AgentEvent::Error {
460                message: e.to_string(),
461            },
462        },
463        // Toggle plan mode: flips the shared handle (the `PlanModeHooks` reads
464        // the same one) and recomposes the system prompt. Infallible.
465        Request::SetPlanMode { on } => {
466            agent.set_plan_mode(on);
467            AgentEvent::Ack
468        }
469        // Rename this session (W031): append the `Title` entry and report it.
470        // Streamed AND returned. Why both: `dispatch` answers an `Ask` through its
471        // `reply` oneshot and a `Tell` not at all, so a mid-run `SetTitle` —
472        // re-queued as a `Tell` by the matcher below — would otherwise learn
473        // nothing. The broadcast copy is the report; the returned copy is the
474        // `Ask`'s reply. A double delivery to an `Ask` caller is harmless: the
475        // reducer sets the same title twice (and `Todo` already works this way).
476        Request::SetTitle { title } => match agent.set_title(&title) {
477            Ok(()) => {
478                let _ = events.send(AgentEvent::SessionTitle { title: title.clone() });
479                AgentEvent::SessionTitle { title }
480            }
481            Err(e) => AgentEvent::Error {
482                message: e.to_string(),
483            },
484        },
485        Request::GetHistory => AgentEvent::History {
486            messages: agent.messages().to_vec(),
487        },
488        Request::Status => AgentEvent::Status {
489            last_assistant_text: crate::message::last_assistant_text(agent.messages()),
490        },
491        Request::Settings => AgentEvent::Settings {
492            model: agent.llm().model.clone(),
493            effort: agent.effort().map(str::to_string),
494            plan: agent.plan_mode().get(),
495            context_limit: crate::limits::model_limit(
496                agent.llm().base_url.as_deref(),
497                &agent.llm().model,
498            )
499            .map(|limit| limit.context),
500        },
501        Request::Usage => usage_reply(agent.messages(), agent.llm()),
502        // A served peer is defined by a socket server's injected handler; an
503        // in-process session has no factory, so it can only refuse.
504        Request::Define { .. } => AgentEvent::Error {
505            message: "agent definition is served, not in-process".into(),
506        },
507        Request::ListSessions | Request::ListModels | Request::Unknown => AgentEvent::Ack,
508    };
509
510    if let Some(reply) = reply {
511        let _ = reply.send(event);
512    }
513}
514
515/// Drive one `Agent::run`, forwarding its events to the outbox while still
516/// servicing the inbox so `Interrupt`/`Wake`/`Cancel` reach the in-flight run.
517async fn run(
518    agent: &mut Agent,
519    text: &str,
520    inbox: &mut mpsc::UnboundedReceiver<Message>,
521    events: &broadcast::Sender<AgentEvent>,
522    deferred: &mut VecDeque<Message>,
523    state: &watch::Sender<MemberState>,
524) -> Result<StopReason, LoopError> {
525    // State transitions live at the run boundary — this `run()` is the in-flight
526    // window. The event stream's `AgentStart`/`AgentEnd` are the OTHER place this
527    // could be derived; the actor-owned cell is authoritative because it is set
528    // synchronously before the first await, so a reader never misses `Running`.
529    // A `Cancel` ends on `Ok(StopReason::Aborted)` -> `Done`: an abort is not a
530    // failure, matching the TUI.
531    state.send_replace(MemberState::Running);
532    let (sink_tx, mut sink_rx) = mpsc::unbounded_channel::<AgentEvent>();
533    // Sender/token clones taken *before* `run` borrows the agent: steering and
534    // cancelling the in-flight run must not need the `&mut`.
535    let steer = agent.steer_sender();
536    let follow_up = agent.follow_up_sender();
537    let cancel = agent.cancel_token();
538    let hooks = agent.hooks().clone();
539    // Read-only handle for mid-run `GetHistory`/`SideAsk` (see the arms below).
540    let side = agent.side_handle();
541    // Plan mode is NOT on the `SideHandle`, so the mid-run `Settings` arm needs
542    // the shared handle cloned beside `hooks` — same reason, same place (both are
543    // `Clone`d before `agent.run` borrows the agent).
544    let plan = agent.plan_mode();
545
546    let mut run = Box::pin(agent.run(text, sink_tx));
547    let result = loop {
548        // Biased: service the inbox *before* advancing the run. An `Interrupt` must
549        // be in the agent's steering channel before the run reaches its next
550        // turn-boundary drain, or it is silently a turn late. Polling the run
551        // first would let it cross that boundary while the request still sits
552        // here.
553        tokio::select! {
554            biased;
555            Some(mut message) = inbox.recv() => {
556                if let Some(reason) = gate_inbound(&hooks, &mut message).await {
557                    if let Message::Ask { reply, .. } = message {
558                        let _ = reply.send(AgentEvent::Error {
559                            message: format!("inbound blocked: {reason}"),
560                        });
561                    }
562                    continue;
563                }
564                match message {
565                    // The interrupt verbs (Cancel / Notify-Interrupt / Wake) want NO reply —
566                    // but the server delivers EVERY request via `ask` (`server.rs`), so handle
567                    // them for BOTH `Tell` and `Ask`, or an `Ask{Cancel}` falls through to
568                    // `deferred` and is queued until the run ENDS: the interrupt never lands.
569                    // An `Ask` gets its `Ack`; a `Tell` needs none.
570                    Message::Tell {
571                        request: Request::Cancel,
572                        ..
573                    } => cancel.cancel(),
574                    Message::Ask {
575                        request: Request::Cancel,
576                        reply,
577                        ..
578                    } => {
579                        cancel.cancel();
580                        let _ = reply.send(AgentEvent::Ack);
581                    }
582                    Message::Tell {
583                        request: Request::Notify { content } | Request::Interrupt { content },
584                        from,
585                    } => {
586                        let from = from.unwrap_or_else(SessionId::user);
587                        let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
588                        let _ = events.send(AgentEvent::MessageReceived {
589                            from,
590                            content,
591                        });
592                    }
593                    Message::Ask {
594                        request: Request::Notify { content } | Request::Interrupt { content },
595                        from,
596                        reply,
597                    } => {
598                        let from = from.unwrap_or_else(SessionId::user);
599                        let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
600                        let _ = events.send(AgentEvent::MessageReceived {
601                            from,
602                            content,
603                        });
604                        let _ = reply.send(AgentEvent::Ack);
605                    }
606                    Message::Tell {
607                        request: Request::Wake { content },
608                        from,
609                    } => {
610                        let from = from.unwrap_or_else(SessionId::user);
611                        let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
612                        let _ = events.send(AgentEvent::MessageReceived {
613                            from,
614                            content,
615                        });
616                    }
617                    Message::Ask {
618                        request: Request::Wake { content },
619                        from,
620                        reply,
621                    } => {
622                        let from = from.unwrap_or_else(SessionId::user);
623                        let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
624                        let _ = events.send(AgentEvent::MessageReceived {
625                            from,
626                            content,
627                        });
628                        let _ = reply.send(AgentEvent::Ack);
629                    }
630                    // Read-only queries are answered mid-run from the context
631                    // snapshot `run_loop` republishes each turn — a `/btw` or
632                    // `/usage` never waits for the whole run. `side` was cloned
633                    // before the run borrowed the agent.
634                    Message::Ask {
635                        request: Request::GetHistory,
636                        reply,
637                        ..
638                    } => {
639                        let _ = reply.send(AgentEvent::History { messages: side.history() });
640                    }
641                    Message::Ask {
642                        request: Request::Status,
643                        reply,
644                        ..
645                    } => {
646                        let _ = reply.send(AgentEvent::Status {
647                            last_assistant_text: side.last_assistant_text(),
648                        });
649                    }
650                    // The mid-run twin of the idle `Settings` arm: answered AT
651                    // ONCE from the read-only handles, never deferred (the `Usage`
652                    // rationale below). It MUST sit above the mutation arm below —
653                    // that matcher is a catch-all for mutations, so a `Settings`
654                    // placed after it would be queued as "apply at the boundary"
655                    // and the panel would learn nothing until the run ended, which
656                    // is exactly the staleness this request exists to fix.
657                    Message::Ask {
658                        request: Request::Settings,
659                        reply,
660                        ..
661                    } => {
662                        let llm = side.llm();
663                        let model = llm.model.clone();
664                        let _ = reply.send(AgentEvent::Settings {
665                            context_limit: crate::limits::model_limit(
666                                llm.base_url.as_deref(),
667                                &model,
668                            )
669                            .map(|limit| limit.context),
670                            model,
671                            effort: llm.effort.clone(),
672                            plan: plan.get(),
673                        });
674                    }
675                    // The mid-run twin of the idle arm: answered AT ONCE from the
676                    // turn-boundary snapshot, so a `/usage` never waits for the run
677                    // to finish. `side.history()` clones the snapshot; the fold needs
678                    // the slice and the reply owns the result. This arm must NOT fall
679                    // through to `deferred` — a read that queues behind a run is the
680                    // bug `GetHistory` already fixed.
681                    Message::Ask {
682                        request: Request::Usage,
683                        reply,
684                        ..
685                    } => {
686                        let _ = reply.send(usage_reply(&side.history(), side.llm()));
687                    }
688                    Message::Ask {
689                        request: Request::SideAsk { text },
690                        reply,
691                        ..
692                    } => {
693                        // A side ask is itself a model call: spawn it so it runs
694                        // alongside the run instead of stalling the inbox.
695                        let side = side.clone();
696                        tokio::spawn(async move {
697                            let event = match side.side_ask(&text).await {
698                                Ok(answer) => AgentEvent::SideAnswer {
699                                    text: answer.text,
700                                    usage: answer.usage,
701                                },
702                                Err(e) => AgentEvent::Error {
703                                    message: e.to_string(),
704                                },
705                            };
706                            let _ = reply.send(event);
707                        });
708                    }
709                    // A mutation cannot apply mid-run (it needs `&mut Agent`), but
710                    // its contract is "next run": acknowledge now and re-queue it
711                    // as a `Tell` so it is applied silently at the run boundary —
712                    // instead of leaving the ask to time out (item-41 ASK_TIMEOUT).
713                    //
714                    // `SetTitle` rides this same arm (W031): the Ack means
715                    // "accepted, applied at the run boundary", and the title itself
716                    // arrives as a STREAMED `AgentEvent::SessionTitle` from the idle
717                    // arm once the run is over. No second reply channel is needed —
718                    // that would break the one-reply-per-Ask invariant the
719                    // transport relies on.
720                    Message::Ask {
721                        from,
722                        request:
723                            req @ (Request::SetModel { .. }
724                            | Request::SetTitle { .. }
725                            | Request::SetEffort { .. }
726                            | Request::SetPlanMode { .. }),
727                        reply,
728                    } => {
729                        let _ = reply.send(AgentEvent::Ack);
730                        deferred.push_back(Message::Tell { from, request: req });
731                    }
732                    other => deferred.push_back(other),
733                }
734            },
735            result = &mut run => break result,
736            Some(event) = sink_rx.recv() => {
737                let _ = events.send(event);
738            }
739        }
740    };
741
742    // The run dropped its sink on completion; forward whatever is still buffered
743    // so the last events (e.g. `AgentEnd`) are never stranded.
744    while let Ok(event) = sink_rx.try_recv() {
745        let _ = events.send(event);
746    }
747
748    // A session-write error inside the run is a `LoopError`; the run's own
749    // `AgentEvent::Error` covers stream failures, so surface this one the same
750    // way rather than dropping it silently.
751    if let Err(e) = &result {
752        let _ = events.send(AgentEvent::Error {
753            message: e.to_string(),
754        });
755    }
756    // Final transition: a normal stop is `Done`; a run-failure — the loop's
757    // `StopReason::Error` or a `LoopError` — is `Failed` (sticky until the next
758    // run sets `Running`).
759    match &result {
760        Ok(StopReason::Error) | Err(_) => {
761            state.send_replace(MemberState::Failed);
762        }
763        Ok(_) => {
764            state.send_replace(MemberState::Done);
765        }
766    }
767    result
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::agent::{Agent, AgentConfig};
774    use crate::compaction::CompactionPolicy;
775    use crate::event::LlmStreamEvent;
776    use crate::hooks::HooksSet;
777    use crate::message::StopReason;
778    use crate::session::Session;
779    use crate::streamfn::{LlmOpts, LlmStream, StreamFn};
780    use crate::tool::{ToolContext, ToolOutput, TypedTool, erased};
781    use futures::StreamExt as _;
782    use serde::Deserialize;
783    use serde_json::json;
784    use std::sync::{Arc, Mutex};
785    use std::time::Duration;
786
787    #[derive(Clone, Default)]
788    struct Recorder {
789        script: Arc<Mutex<VecDeque<Vec<LlmStreamEvent>>>>,
790        calls: Arc<Mutex<Vec<Vec<AgentMessage>>>>,
791        models: Arc<Mutex<Vec<String>>>,
792    }
793
794    impl Recorder {
795        fn push(&self, events: Vec<LlmStreamEvent>) {
796            self.script.lock().unwrap().push_back(events);
797        }
798        fn calls(&self) -> Vec<Vec<AgentMessage>> {
799            self.calls.lock().unwrap().clone()
800        }
801        fn models(&self) -> Vec<String> {
802            self.models.lock().unwrap().clone()
803        }
804    }
805
806    fn fake_stream_fn(rec: &Recorder) -> StreamFn {
807        let rec = rec.clone();
808        Arc::new(
809            move |ctx: &[AgentMessage], _system, _tools, opts: &LlmOpts| {
810                rec.calls.lock().unwrap().push(ctx.to_vec());
811                rec.models.lock().unwrap().push(opts.model.clone());
812                let events = rec.script.lock().unwrap().pop_front().unwrap_or_default();
813                Box::pin(futures::stream::iter(events)) as LlmStream
814            },
815        )
816    }
817
818    fn agent_config(stream_fn: StreamFn, tools: Vec<crate::tool::Tool>) -> AgentConfig {
819        AgentConfig {
820            system: "sys".into(),
821            tools,
822            llm: LlmOpts {
823                model: "m1".into(),
824                ..LlmOpts::default()
825            },
826            stream_fn,
827            hooks: HooksSet::default(),
828            session: None,
829            context: Vec::new(),
830            working_dir: std::path::PathBuf::new(),
831            max_turns: crate::loop_::DEFAULT_MAX_TURNS,
832            parallel_tools: true,
833            compaction: CompactionPolicy::default(),
834            plan_mode: crate::hooks::PlanModeHandle::new(),
835            // W031 test double: the kernel default, like every non-CLI site.
836            title_after_turns: crate::title::DEFAULT_TITLE_AFTER_TURNS,
837        }
838    }
839
840    /// Block until the run ends, panicking if it stalls.
841    async fn wait_for_end(rx: &mut broadcast::Receiver<AgentEvent>) {
842        loop {
843            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
844                .await
845                .expect("an event within the timeout")
846                .expect("the outbox stays open");
847            if matches!(event, AgentEvent::AgentEnd) {
848                return;
849            }
850        }
851    }
852
853    #[tokio::test]
854    async fn submit_streams_events_to_a_subscriber() {
855        let rec = Recorder::default();
856        rec.push(vec![
857            LlmStreamEvent::TextDelta("hello".into()),
858            LlmStreamEvent::Done {
859                stop_reason: StopReason::Stop,
860                usage: None,
861            },
862        ]);
863        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
864        let mut rx = handle.subscribe();
865        handle.send(Request::Submit { text: "hi".into() }).unwrap();
866
867        let mut saw_text = false;
868        loop {
869            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
870                .await
871                .expect("an event")
872                .expect("open");
873            match event {
874                AgentEvent::MessageUpdate { message } if message.as_text() == "hello" => {
875                    saw_text = true;
876                }
877                AgentEvent::AgentEnd => break,
878                _ => {}
879            }
880        }
881        assert!(saw_text, "the streamed text reached the subscriber");
882    }
883
884    #[tokio::test]
885    async fn idle_cancel_does_not_poison_the_next_run() {
886        let rec = Recorder::default();
887        for text in ["one", "two"] {
888            rec.push(vec![
889                LlmStreamEvent::TextDelta(text.into()),
890                LlmStreamEvent::Done {
891                    stop_reason: StopReason::Stop,
892                    usage: None,
893                },
894            ]);
895        }
896        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
897        let mut rx = handle.subscribe();
898
899        handle.send(Request::Submit { text: "a".into() }).unwrap();
900        wait_for_end(&mut rx).await;
901
902        // No run in flight: this must be swallowed, not arm a kill for run two.
903        handle.send(Request::Cancel).unwrap();
904        handle.send(Request::Submit { text: "b".into() }).unwrap();
905
906        let mut saw_two = false;
907        loop {
908            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
909                .await
910                .expect("an event")
911                .expect("open");
912            match event {
913                AgentEvent::MessageUpdate { message } if message.as_text() == "two" => {
914                    saw_two = true;
915                }
916                AgentEvent::AgentEnd => break,
917                _ => {}
918            }
919        }
920        assert!(saw_two, "the second run completed despite the idle cancel");
921    }
922
923    #[tokio::test]
924    async fn cancel_aborts_an_in_flight_run() {
925        // One delta, then the stream hangs: only a working cancel ends the run.
926        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
927            let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
928            Box::pin(head.chain(futures::stream::pending())) as LlmStream
929        });
930        let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
931        let mut rx = handle.subscribe();
932        handle.send(Request::Submit { text: "hi".into() }).unwrap();
933
934        loop {
935            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
936                .await
937                .expect("an event")
938                .expect("open");
939            if matches!(event, AgentEvent::MessageUpdate { .. }) {
940                handle.send(Request::Cancel).unwrap();
941                break;
942            }
943        }
944        // Without a live cancel this would time out on the pending stream.
945        wait_for_end(&mut rx).await;
946    }
947
948    #[tokio::test]
949    async fn ask_cancel_aborts_an_in_flight_run() {
950        // The server delivers EVERY request via `ask`; an `Ask{Cancel}` must abort the run
951        // mid-flight, NOT be deferred to its end (the bug this fixes). One delta, then a
952        // hang: only a live cancel ends the run.
953        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
954            let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
955            Box::pin(head.chain(futures::stream::pending())) as LlmStream
956        });
957        let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
958        let mut rx = handle.subscribe();
959        handle.send(Request::Submit { text: "hi".into() }).unwrap();
960
961        loop {
962            let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
963                .await
964                .expect("an event")
965                .expect("open");
966            if matches!(event, AgentEvent::MessageUpdate { .. }) {
967                // ASK for the cancel: deferred, this hangs and the 2 s timeout fires (the
968                // pre-fix failure); handled, it replies `Ack` at once.
969                let reply = tokio::time::timeout(Duration::from_secs(2), handle.ask(Request::Cancel))
970                    .await
971                    .expect("the cancel ask is answered mid-run, not deferred")
972                    .unwrap();
973                assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
974                break;
975            }
976        }
977        // Without a live cancel this would time out on the pending stream.
978        wait_for_end(&mut rx).await;
979    }
980
981    #[tokio::test]
982    async fn state_is_running_during_a_turn_then_done_after() {
983        // A stream that emits one delta then hangs: the run is observably in
984        // flight until a cancel ends it.
985        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
986            let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
987            Box::pin(head.chain(futures::stream::pending())) as LlmStream
988        });
989        let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
990        assert_eq!(handle.state(), MemberState::Idle, "a virgin session is idle");
991
992        // A watcher wakes on the transition rather than polling.
993        let mut state_rx = handle.state_rx();
994        handle.send(Request::Submit { text: "hi".into() }).unwrap();
995        tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
996            .await
997            .expect("a state change")
998            .expect("open");
999        assert_eq!(*state_rx.borrow_and_update(), MemberState::Running);
1000        assert_eq!(handle.state(), MemberState::Running, "the run is in flight");
1001
1002        handle.send(Request::Cancel).unwrap();
1003        tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
1004            .await
1005            .expect("a state change")
1006            .expect("open");
1007        assert_eq!(
1008            *state_rx.borrow_and_update(),
1009            MemberState::Done,
1010            "an abort ends Done, not Failed"
1011        );
1012        assert_eq!(handle.state(), MemberState::Done);
1013    }
1014
1015    #[tokio::test]
1016    async fn a_fatal_stream_error_ends_failed() {
1017        let rec = Recorder::default();
1018        rec.push(vec![LlmStreamEvent::Error {
1019            message: "boom".into(),
1020            fatal: true,
1021        }]);
1022        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1023        let mut rx = handle.subscribe();
1024        handle.send(Request::Submit { text: "hi".into() }).unwrap();
1025        wait_for_end(&mut rx).await;
1026        assert_eq!(handle.state(), MemberState::Failed);
1027    }
1028
1029    #[derive(Deserialize, schemars::JsonSchema)]
1030    struct GateArgs {
1031        text: String,
1032    }
1033
1034    /// Blocks until released, so the actor can be steered while a run is inside
1035    /// a tool call.
1036    struct GateTool {
1037        entered: mpsc::UnboundedSender<()>,
1038        release: Arc<tokio::sync::Notify>,
1039    }
1040
1041    #[async_trait::async_trait]
1042    impl TypedTool for GateTool {
1043        type Args = GateArgs;
1044        fn name(&self) -> &str {
1045            "gate"
1046        }
1047        fn description(&self) -> &str {
1048            "blocks until released"
1049        }
1050        async fn execute(&self, args: GateArgs, _ctx: &ToolContext) -> ToolOutput {
1051            self.entered.send(()).ok();
1052            self.release.notified().await;
1053            ToolOutput {
1054                output: format!("released:{}", args.text),
1055                is_error: false,
1056                diff: None,
1057                path: None,
1058            }
1059        }
1060    }
1061
1062    #[tokio::test]
1063    async fn set_title_mid_run_is_deferred_and_still_reported() {
1064        // A run that blocks inside a tool: exactly the window in which a rename
1065        // must NOT apply (it needs `&mut Agent`, which the run holds).
1066        let rec = Recorder::default();
1067        rec.push(vec![
1068            LlmStreamEvent::ToolCall {
1069                id: "c1".into(),
1070                name: "gate".into(),
1071                arguments: json!({ "text": "hi" }),
1072            },
1073            LlmStreamEvent::Done {
1074                stop_reason: StopReason::ToolUse,
1075                usage: None,
1076            },
1077        ]);
1078        rec.push(vec![
1079            LlmStreamEvent::TextDelta("done".into()),
1080            LlmStreamEvent::Done {
1081                stop_reason: StopReason::Stop,
1082                usage: None,
1083            },
1084        ]);
1085
1086        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1087        let release = Arc::new(tokio::sync::Notify::new());
1088        let gate = erased(GateTool {
1089            entered: entered_tx,
1090            release: release.clone(),
1091        });
1092
1093        let dir = tempfile::tempdir().unwrap();
1094        let mut cfg = agent_config(fake_stream_fn(&rec), vec![gate]);
1095        cfg.session = Some(Session::create(dir.path()).unwrap());
1096        let path = cfg.session.as_ref().unwrap().path().unwrap().to_path_buf();
1097        let handle = SessionActor::spawn(Agent::new(cfg));
1098        let mut rx = handle.subscribe();
1099        handle.send(Request::Submit { text: "a".into() }).unwrap();
1100
1101        entered_rx.recv().await.unwrap(); // the run is inside the tool
1102
1103        // 1. The Ask is answered AT ONCE with the deferral Ack — a bounded wait,
1104        //    so a missing deferral arm fails here instead of hanging.
1105        let ack = tokio::time::timeout(
1106            Duration::from_secs(5),
1107            handle.ask(Request::SetTitle {
1108                title: "retry backoff".into(),
1109            }),
1110        )
1111        .await
1112        .expect("the deferral Ack must arrive mid-run, not after it")
1113        .unwrap();
1114        assert_eq!(ack, AgentEvent::Ack, "the Ack, not the title");
1115
1116        // 2. Nothing was applied yet: the file still has no `Title` entry.
1117        assert_eq!(
1118            Session::open(&path).unwrap().title(),
1119            None,
1120            "the rename waits for the run boundary"
1121        );
1122
1123        release.notify_one();
1124        wait_for_end(&mut rx).await; // the run finishes first…
1125
1126        // 3. …and THEN the title is reported — by the re-queued `Tell`, through
1127        //    the idle arm's STREAM. This is the half a silent drop would lose.
1128        let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1129            .await
1130            .expect("the SessionTitle must arrive after the run")
1131            .expect("the outbox stays open");
1132        assert_eq!(
1133            event,
1134            AgentEvent::SessionTitle {
1135                title: "retry backoff".into()
1136            }
1137        );
1138        assert_eq!(
1139            Session::open(&path).unwrap().title().as_deref(),
1140            Some("retry backoff"),
1141            "applied exactly once, at the boundary"
1142        );
1143    }
1144
1145    #[tokio::test]
1146    async fn ask_set_title_replies_and_streams_the_title() {
1147        let rec = Recorder::default();
1148        let dir = tempfile::tempdir().unwrap();
1149        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1150        cfg.session = Some(Session::create(dir.path()).unwrap());
1151        let path = cfg.session.as_ref().unwrap().path().unwrap().to_path_buf();
1152        let handle = SessionActor::spawn(Agent::new(cfg));
1153        let mut rx = handle.subscribe();
1154
1155        let reply = handle
1156            .ask(Request::SetTitle {
1157                title: "idle rename".into(),
1158            })
1159            .await
1160            .unwrap();
1161        assert_eq!(
1162            reply,
1163            AgentEvent::SessionTitle {
1164                title: "idle rename".into()
1165            }
1166        );
1167
1168        // Streamed too, so a subscriber (the TUI status line) learns it.
1169        let streamed = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1170            .await
1171            .expect("a streamed SessionTitle")
1172            .expect("the outbox stays open");
1173        assert_eq!(
1174            streamed,
1175            AgentEvent::SessionTitle {
1176                title: "idle rename".into()
1177            }
1178        );
1179        assert_eq!(
1180            Session::open(&path).unwrap().title().as_deref(),
1181            Some("idle rename")
1182        );
1183    }
1184
1185    #[tokio::test]
1186    async fn config_request_during_a_run_is_deferred_to_after_it() {
1187        // Run one: turn 1 blocks in the gate, turn 2 finishes — both on the
1188        // run's own model. Run two is a fresh request and must see the swap.
1189        let rec = Recorder::default();
1190        rec.push(vec![
1191            LlmStreamEvent::ToolCall {
1192                id: "c1".into(),
1193                name: "gate".into(),
1194                arguments: json!({ "text": "hi" }),
1195            },
1196            LlmStreamEvent::Done {
1197                stop_reason: StopReason::ToolUse,
1198                usage: None,
1199            },
1200        ]);
1201        for text in ["turn two", "run two"] {
1202            rec.push(vec![
1203                LlmStreamEvent::TextDelta(text.into()),
1204                LlmStreamEvent::Done {
1205                    stop_reason: StopReason::Stop,
1206                    usage: None,
1207                },
1208            ]);
1209        }
1210
1211        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1212        let release = Arc::new(tokio::sync::Notify::new());
1213        let gate = erased(GateTool {
1214            entered: entered_tx,
1215            release: release.clone(),
1216        });
1217
1218        let handle =
1219            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1220        let mut rx = handle.subscribe();
1221        handle.send(Request::Submit { text: "a".into() }).unwrap();
1222
1223        entered_rx.recv().await.unwrap(); // run one is inside the tool
1224        handle
1225            .send(Request::SetModel { model: "m2".into() })
1226            .unwrap(); // must not take effect until run one is done
1227        release.notify_one();
1228        wait_for_end(&mut rx).await; // run one ends on m1
1229
1230        handle.send(Request::Submit { text: "b".into() }).unwrap();
1231        wait_for_end(&mut rx).await; // run two
1232
1233        assert_eq!(
1234            rec.models(),
1235            ["m1", "m1", "m2"],
1236            "the swap waited for the run, then applied to the next one"
1237        );
1238    }
1239
1240    #[tokio::test]
1241    async fn ask_history_returns_the_conversation() {
1242        let rec = Recorder::default();
1243        rec.push(vec![
1244            LlmStreamEvent::TextDelta("hi back".into()),
1245            LlmStreamEvent::Done {
1246                stop_reason: StopReason::Stop,
1247                usage: None,
1248            },
1249        ]);
1250        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1251        let mut rx = handle.subscribe();
1252        handle
1253            .send(Request::Submit {
1254                text: "hello".into(),
1255            })
1256            .unwrap();
1257        wait_for_end(&mut rx).await;
1258
1259        let reply = handle.ask(Request::GetHistory).await.unwrap();
1260        let AgentEvent::History { messages } = reply else {
1261            panic!("expected a History reply, got {reply:?}");
1262        };
1263        assert!(
1264            messages.iter().any(|m| m.as_text() == "hello"),
1265            "the read-back carries the user turn: {messages:?}"
1266        );
1267    }
1268
1269    #[tokio::test]
1270    async fn ask_set_model_replies_ack_and_takes_effect() {
1271        let rec = Recorder::default();
1272        rec.push(vec![
1273            LlmStreamEvent::TextDelta("x".into()),
1274            LlmStreamEvent::Done {
1275                stop_reason: StopReason::Stop,
1276                usage: None,
1277            },
1278        ]);
1279        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1280        let mut rx = handle.subscribe();
1281
1282        let reply = handle
1283            .ask(Request::SetModel { model: "m2".into() })
1284            .await
1285            .unwrap();
1286        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1287
1288        handle.send(Request::Submit { text: "go".into() }).unwrap();
1289        wait_for_end(&mut rx).await;
1290        assert_eq!(rec.models(), ["m2"], "the swap applied to the next run");
1291    }
1292
1293    #[tokio::test]
1294    async fn define_on_a_local_session_is_an_error() {
1295        let rec = Recorder::default();
1296        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1297        let reply = handle
1298            .ask(Request::Define {
1299                name: Some("w1".into()),
1300                model: None,
1301                role: None,
1302                tools: None,
1303                base_url: None,
1304                api_key: None,
1305                read_only: false,
1306                effort: None,
1307            })
1308            .await
1309            .unwrap();
1310        assert!(
1311            matches!(&reply, AgentEvent::Error { message } if message.contains("served")),
1312            "{reply:?}"
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn ask_compact_with_nothing_to_do_replies_ack() {
1318        let handle = SessionActor::spawn(Agent::new(agent_config(
1319            fake_stream_fn(&Recorder::default()),
1320            vec![],
1321        )));
1322        let reply = handle
1323            .ask(Request::Compact { instructions: None })
1324            .await
1325            .unwrap();
1326        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1327    }
1328
1329    #[tokio::test]
1330    async fn ask_submit_replies_stopped_with_the_reason() {
1331        let rec = Recorder::default();
1332        rec.push(vec![
1333            LlmStreamEvent::TextDelta("done".into()),
1334            LlmStreamEvent::Done {
1335                stop_reason: StopReason::Stop,
1336                usage: None,
1337            },
1338        ]);
1339        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1340        let reply = handle
1341            .ask(Request::Submit { text: "hi".into() })
1342            .await
1343            .unwrap();
1344        assert!(
1345            matches!(
1346                reply,
1347                AgentEvent::Stopped {
1348                    stop_reason: StopReason::Stop
1349                }
1350            ),
1351            "{reply:?}"
1352        );
1353    }
1354
1355    #[tokio::test]
1356    async fn notify_appends_without_starting_a_turn() {
1357        let rec = Recorder::default();
1358        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1359        let mut rx = handle.subscribe();
1360
1361        let reply = handle
1362            .ask(Request::Notify {
1363                content: "ping".into(),
1364            })
1365            .await
1366            .unwrap();
1367        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1368
1369        // The message was surfaced to subscribers...
1370        let surfaced = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1371            .await
1372            .expect("an event")
1373            .expect("open");
1374        assert!(
1375            matches!(&surfaced, AgentEvent::MessageReceived { content, .. } if content == "ping"),
1376            "{surfaced:?}"
1377        );
1378
1379        // ...the conversation carries it...
1380        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1381            panic!("expected History");
1382        };
1383        assert!(
1384            messages
1385                .iter()
1386                .any(|m| m.as_text() == "[message from user]\nping"),
1387            "{messages:?}"
1388        );
1389
1390        // ...and no turn ran (the loop was never entered).
1391        assert!(rec.calls().is_empty(), "notify must not run the loop");
1392    }
1393
1394    #[tokio::test]
1395    async fn wake_starts_a_run_when_idle() {
1396        let rec = Recorder::default();
1397        rec.push(vec![
1398            LlmStreamEvent::TextDelta("acted".into()),
1399            LlmStreamEvent::Done {
1400                stop_reason: StopReason::Stop,
1401                usage: None,
1402            },
1403        ]);
1404        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1405        let mut rx = handle.subscribe();
1406
1407        let reply = handle
1408            .ask(Request::Wake {
1409                content: "do the task".into(),
1410            })
1411            .await
1412            .unwrap();
1413        assert!(
1414            matches!(
1415                reply,
1416                AgentEvent::Stopped {
1417                    stop_reason: StopReason::Stop
1418                }
1419            ),
1420            "{reply:?}"
1421        );
1422        wait_for_end(&mut rx).await;
1423
1424        let calls = rec.calls();
1425        assert_eq!(calls.len(), 1, "Wake ran the loop once");
1426        assert!(
1427            calls[0]
1428                .iter()
1429                .any(|m| m.as_text() == "[message from user]\ndo the task"),
1430            "the tagged task is the prompt: {:?}",
1431            calls[0]
1432        );
1433    }
1434
1435    struct InboundPolicy;
1436
1437    #[async_trait::async_trait]
1438    impl crate::hooks::Hooks for InboundPolicy {
1439        async fn before_inbound(
1440            &self,
1441            _from: Option<&SessionId>,
1442            request: &mut Request,
1443        ) -> Option<String> {
1444            let Request::Notify { content } = request else {
1445                return None;
1446            };
1447            if content.starts_with("drop:") {
1448                return Some("policy".into());
1449            }
1450            *content = format!("{content}!");
1451            None
1452        }
1453    }
1454
1455    #[tokio::test]
1456    async fn before_inbound_drops_and_rewrites_a_notify() {
1457        let rec = Recorder::default();
1458        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1459        cfg.hooks = HooksSet::one(Arc::new(InboundPolicy));
1460        let handle = SessionActor::spawn(Agent::new(cfg));
1461
1462        // Dropped: an `ask` is answered with an Error, and nothing lands.
1463        let reply = handle
1464            .ask(Request::Notify {
1465                content: "drop: me".into(),
1466            })
1467            .await
1468            .unwrap();
1469        assert!(
1470            matches!(&reply, AgentEvent::Error { message } if message.contains("policy")),
1471            "{reply:?}"
1472        );
1473
1474        // Rewritten: the hook's edit is what is recorded.
1475        let reply = handle
1476            .ask(Request::Notify {
1477                content: "keep me".into(),
1478            })
1479            .await
1480            .unwrap();
1481        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1482
1483        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1484            panic!("expected History");
1485        };
1486        let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1487        assert!(
1488            !texts.iter().any(|t| t == "drop: me"),
1489            "the dropped message never landed: {texts:?}"
1490        );
1491        assert!(
1492            texts.iter().any(|t| t == "[message from user]\nkeep me!"),
1493            "the rewrite landed: {texts:?}"
1494        );
1495    }
1496
1497    #[tokio::test]
1498    async fn interrupt_reaches_the_next_turn_while_a_tool_is_blocked() {
1499        let rec = Recorder::default();
1500        rec.push(vec![
1501            LlmStreamEvent::ToolCall {
1502                id: "c1".into(),
1503                name: "gate".into(),
1504                arguments: json!({ "text": "hi" }),
1505            },
1506            LlmStreamEvent::Done {
1507                stop_reason: StopReason::ToolUse,
1508                usage: None,
1509            },
1510        ]);
1511        rec.push(vec![
1512            LlmStreamEvent::TextDelta("after interrupt".into()),
1513            LlmStreamEvent::Done {
1514                stop_reason: StopReason::Stop,
1515                usage: None,
1516            },
1517        ]);
1518
1519        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1520        let release = Arc::new(tokio::sync::Notify::new());
1521        let gate = erased(GateTool {
1522            entered: entered_tx,
1523            release: release.clone(),
1524        });
1525
1526        let handle =
1527            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1528        let mut rx = handle.subscribe();
1529        handle.send(Request::Submit { text: "hi".into() }).unwrap();
1530
1531        entered_rx.recv().await.unwrap(); // the tool is now blocked
1532        handle
1533            .send(Request::Interrupt {
1534                content: "mid-run interrupt".into(),
1535            })
1536            .unwrap();
1537        release.notify_one();
1538
1539        wait_for_end(&mut rx).await;
1540
1541        let calls = rec.calls();
1542        assert_eq!(calls.len(), 2, "two stream calls: the tool turn and the next");
1543        assert!(
1544            calls[1]
1545                .iter()
1546                .any(|m| matches!(m, AgentMessage::User { .. })
1547                    && m.as_text() == "[message from user]\nmid-run interrupt"),
1548            "the interrupt drained into the next turn: {:?}",
1549            calls[1]
1550        );
1551    }
1552
1553    #[tokio::test]
1554    async fn send_from_attributes_the_sender() {
1555        let rec = Recorder::default();
1556        let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1557        let mut rx = handle.subscribe();
1558
1559        handle
1560            .send_from(
1561                SessionId::agent("b"),
1562                Request::Notify {
1563                    content: "ping".into(),
1564                },
1565            )
1566            .unwrap();
1567
1568        let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1569            .await
1570            .expect("an event")
1571            .expect("open");
1572        assert!(
1573            matches!(&event, AgentEvent::MessageReceived { from, content }
1574                if from.as_str() == "agent:b" && content == "ping"),
1575            "{event:?}"
1576        );
1577    }
1578
1579    struct SenderPolicy;
1580
1581    #[async_trait::async_trait]
1582    impl crate::hooks::Hooks for SenderPolicy {
1583        async fn before_inbound(
1584            &self,
1585            from: Option<&SessionId>,
1586            _request: &mut Request,
1587        ) -> Option<String> {
1588            (from.map(SessionId::as_str) == Some("agent:evil")).then(|| "blocked sender".into())
1589        }
1590    }
1591
1592    #[tokio::test]
1593    async fn before_inbound_sees_the_sender() {
1594        let rec = Recorder::default();
1595        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1596        cfg.hooks = HooksSet::one(Arc::new(SenderPolicy));
1597        let handle = SessionActor::spawn(Agent::new(cfg));
1598
1599        // A hostile peer is dropped...
1600        let reply = handle
1601            .ask_from(
1602                SessionId::agent("evil"),
1603                Request::Notify {
1604                    content: "let me in".into(),
1605                },
1606            )
1607            .await
1608            .unwrap();
1609        assert!(
1610            matches!(&reply, AgentEvent::Error { message } if message.contains("blocked sender")),
1611            "{reply:?}"
1612        );
1613
1614        // ...a friendly one is not.
1615        let reply = handle
1616            .ask_from(
1617                SessionId::agent("friend"),
1618                Request::Notify {
1619                    content: "hello".into(),
1620                },
1621            )
1622            .await
1623            .unwrap();
1624        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1625
1626        let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1627            panic!("expected History");
1628        };
1629        let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1630        assert!(
1631            texts
1632                .iter()
1633                .any(|t| t == "[message from agent:friend]\nhello"),
1634            "{texts:?}"
1635        );
1636        assert!(!texts.iter().any(|t| t.contains("let me in")), "{texts:?}");
1637    }
1638
1639    #[tokio::test]
1640    async fn side_ask_is_answered_mid_run() {
1641        // Run one blocks in the gate (turn 1), so the actor is mid-run when the
1642        // side ask arrives; it must be answered without waiting for the run.
1643        let rec = Recorder::default();
1644        rec.push(vec![
1645            LlmStreamEvent::ToolCall {
1646                id: "c1".into(),
1647                name: "gate".into(),
1648                arguments: json!({ "text": "hi" }),
1649            },
1650            LlmStreamEvent::Done {
1651                stop_reason: StopReason::ToolUse,
1652                usage: None,
1653            },
1654        ]);
1655        // The side ask's own model call (empty tool list) pops this.
1656        rec.push(vec![
1657            LlmStreamEvent::TextDelta("aside".into()),
1658            LlmStreamEvent::Done {
1659                stop_reason: StopReason::Stop,
1660                usage: None,
1661            },
1662        ]);
1663        // Run one, turn 2, after the gate releases.
1664        rec.push(vec![
1665            LlmStreamEvent::TextDelta("done".into()),
1666            LlmStreamEvent::Done {
1667                stop_reason: StopReason::Stop,
1668                usage: None,
1669            },
1670        ]);
1671
1672        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1673        let release = Arc::new(tokio::sync::Notify::new());
1674        let gate = erased(GateTool {
1675            entered: entered_tx,
1676            release: release.clone(),
1677        });
1678        let handle =
1679            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1680        let mut rx = handle.subscribe();
1681        handle.send(Request::Submit { text: "a".into() }).unwrap();
1682
1683        entered_rx.recv().await.unwrap(); // run one is inside the tool, still in flight
1684
1685        // Answered now, not after the run: `ask` returns before we release.
1686        let reply = tokio::time::timeout(
1687            Duration::from_secs(5),
1688            handle.ask(Request::SideAsk { text: "why?".into() }),
1689        )
1690        .await
1691        .expect("the side ask must not wait for the run")
1692        .unwrap();
1693        assert!(
1694            matches!(&reply, AgentEvent::SideAnswer { text, .. } if text == "aside"),
1695            "{reply:?}"
1696        );
1697
1698        release.notify_one();
1699        wait_for_end(&mut rx).await;
1700    }
1701
1702    #[tokio::test]
1703    async fn get_history_is_answered_mid_run_from_the_snapshot() {
1704        let rec = Recorder::default();
1705        rec.push(vec![
1706            LlmStreamEvent::ToolCall {
1707                id: "c1".into(),
1708                name: "gate".into(),
1709                arguments: json!({ "text": "hi" }),
1710            },
1711            LlmStreamEvent::Done {
1712                stop_reason: StopReason::ToolUse,
1713                usage: None,
1714            },
1715        ]);
1716        rec.push(vec![
1717            LlmStreamEvent::TextDelta("done".into()),
1718            LlmStreamEvent::Done {
1719                stop_reason: StopReason::Stop,
1720                usage: None,
1721            },
1722        ]);
1723
1724        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1725        let release = Arc::new(tokio::sync::Notify::new());
1726        let gate = erased(GateTool {
1727            entered: entered_tx,
1728            release: release.clone(),
1729        });
1730        let handle =
1731            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1732        let mut rx = handle.subscribe();
1733        handle.send(Request::Submit { text: "a".into() }).unwrap();
1734
1735        entered_rx.recv().await.unwrap();
1736
1737        let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::GetHistory))
1738            .await
1739            .expect("history must not wait for the run")
1740            .unwrap();
1741        let AgentEvent::History { messages } = reply else {
1742            panic!("expected History, got {reply:?}");
1743        };
1744        assert!(
1745            messages.iter().any(|m| m.as_text() == "a"),
1746            "the mid-run snapshot carries the submit: {messages:?}"
1747        );
1748
1749        release.notify_one();
1750        wait_for_end(&mut rx).await;
1751    }
1752
1753    #[tokio::test]
1754    async fn a_deferred_command_is_acked_before_the_run_ends() {
1755        let rec = Recorder::default();
1756        rec.push(vec![
1757            LlmStreamEvent::ToolCall {
1758                id: "c1".into(),
1759                name: "gate".into(),
1760                arguments: json!({ "text": "hi" }),
1761            },
1762            LlmStreamEvent::Done {
1763                stop_reason: StopReason::ToolUse,
1764                usage: None,
1765            },
1766        ]);
1767        for text in ["turn two", "run two"] {
1768            rec.push(vec![
1769                LlmStreamEvent::TextDelta(text.into()),
1770                LlmStreamEvent::Done {
1771                    stop_reason: StopReason::Stop,
1772                    usage: None,
1773                },
1774            ]);
1775        }
1776
1777        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1778        let release = Arc::new(tokio::sync::Notify::new());
1779        let gate = erased(GateTool {
1780            entered: entered_tx,
1781            release: release.clone(),
1782        });
1783        let handle =
1784            SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1785        let mut rx = handle.subscribe();
1786        handle.send(Request::Submit { text: "a".into() }).unwrap();
1787
1788        entered_rx.recv().await.unwrap();
1789
1790        // Acked now (mid-run), not left to time out; applied at the run boundary.
1791        let reply = tokio::time::timeout(
1792            Duration::from_secs(5),
1793            handle.ask(Request::SetModel { model: "m2".into() }),
1794        )
1795        .await
1796        .expect("a deferred command must be acked, not hang")
1797        .unwrap();
1798        assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1799
1800        release.notify_one();
1801        wait_for_end(&mut rx).await; // run one ends on m1
1802
1803        handle.send(Request::Submit { text: "b".into() }).unwrap();
1804        wait_for_end(&mut rx).await; // run two
1805        assert_eq!(
1806            rec.models(),
1807            ["m1", "m1", "m2"],
1808            "the swap applied after the run, not before"
1809        );
1810    }
1811
1812    #[tokio::test]
1813    async fn usage_reply_is_answered_mid_run_without_waiting() {
1814        // Cloned from `ask_status_is_answered_mid_run_from_the_snapshot`: same
1815        // GateTool + release choreography, one assertion changed — the mid-run
1816        // ask answers at once with folded, PRICED stats.
1817        let rec = Recorder::default();
1818        rec.push(vec![
1819            LlmStreamEvent::ToolCall {
1820                id: "c1".into(),
1821                name: "gate".into(),
1822                arguments: json!({ "text": "hi" }),
1823            },
1824            LlmStreamEvent::Done {
1825                stop_reason: StopReason::ToolUse,
1826                usage: None,
1827            },
1828        ]);
1829        rec.push(vec![
1830            LlmStreamEvent::TextDelta("done".into()),
1831            LlmStreamEvent::Done {
1832                stop_reason: StopReason::Stop,
1833                usage: None,
1834            },
1835        ]);
1836
1837        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1838        let release = Arc::new(tokio::sync::Notify::new());
1839        let gate = erased(GateTool {
1840            entered: entered_tx,
1841            release: release.clone(),
1842        });
1843        // Seed a priced assistant (zen base + deepseek-v4.1-flash) so the mid-run
1844        // reply folds ONE turn and reports a cost.
1845        let mut cfg = agent_config(fake_stream_fn(&rec), vec![gate]);
1846        cfg.llm.base_url = Some("https://opencode.ai/zen/go/v1".into());
1847        cfg.llm.model = "deepseek-v4.1-flash".into();
1848        cfg.context = vec![AgentMessage::Assistant {
1849            content: vec![crate::message::ContentBlock::Text {
1850                text: "seeded work".into(),
1851            }],
1852            stop_reason: StopReason::Stop,
1853            usage: Some(crate::message::Usage {
1854                input_tokens: 1_000,
1855                output_tokens: 500,
1856                cache_read_tokens: Some(400),
1857                cache_write_tokens: None,
1858            }),
1859            model: Some("deepseek-v4.1-flash".into()),
1860        }];
1861        let handle = SessionActor::spawn(Agent::new(cfg));
1862        let mut rx = handle.subscribe();
1863        handle.send(Request::Submit { text: "a".into() }).unwrap();
1864
1865        entered_rx.recv().await.unwrap(); // run is inside the tool
1866
1867        let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Usage))
1868            .await
1869            .expect("usage must not wait for the run")
1870            .unwrap();
1871        let AgentEvent::Usage { stats } = reply else {
1872            panic!("expected Usage, got {reply:?}");
1873        };
1874        assert_eq!(stats.turns, 1);
1875        assert!(stats.cost.is_some(), "a priced model reports a cost mid-run");
1876
1877        release.notify_one();
1878        wait_for_end(&mut rx).await;
1879    }
1880
1881    #[tokio::test]
1882    async fn an_idle_usage_reply_folds_the_live_transcript() {
1883        // The idle arm reads the FRESH `Agent::messages()`, not the snapshot.
1884        let rec = Recorder::default();
1885        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1886        cfg.llm.base_url = None; // unpriced: a valid report with cost: None
1887        cfg.context = vec![AgentMessage::Assistant {
1888            content: vec![crate::message::ContentBlock::Text {
1889                text: "done".into(),
1890            }],
1891            stop_reason: StopReason::Stop,
1892            usage: Some(crate::message::Usage {
1893                input_tokens: 10,
1894                output_tokens: 20,
1895                cache_read_tokens: None,
1896                cache_write_tokens: None,
1897            }),
1898            model: None,
1899        }];
1900        let handle = SessionActor::spawn(Agent::new(cfg));
1901
1902        let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Usage))
1903            .await
1904            .expect("an idle usage read answers")
1905            .unwrap();
1906        let AgentEvent::Usage { stats } = reply else {
1907            panic!("expected Usage, got {reply:?}");
1908        };
1909        assert_eq!(stats.turns, 1);
1910        assert_eq!(stats.input_tokens, 10);
1911        assert_eq!(stats.cost, None, "no base_url ⇒ no price, never $0");
1912    }
1913
1914    #[tokio::test]
1915    async fn settings_reply_reports_the_agents_live_state() {
1916        // A getter, not a constant: the four fields are the agent's own state, so
1917        // each setter moves what the next read reports (W032 §4.5).
1918        let rec = Recorder::default();
1919        let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1920        cfg.llm.effort = Some("high".into());
1921        let handle = SessionActor::spawn(Agent::new(cfg));
1922
1923        let AgentEvent::Settings {
1924            model,
1925            effort,
1926            plan,
1927            context_limit,
1928        } = handle.ask(Request::Settings).await.unwrap()
1929        else {
1930            panic!("expected Settings");
1931        };
1932        assert_eq!(model, "m1");
1933        assert_eq!(effort.as_deref(), Some("high"));
1934        assert!(!plan, "plan mode is off at launch");
1935        assert_eq!(
1936            context_limit, None,
1937            "an unknown window is `None`, never 0 and never a guess \
1938             (`limits::model_limit` knows one endpoint family)"
1939        );
1940
1941        handle
1942            .ask(Request::SetModel {
1943                model: "m2".into(),
1944            })
1945            .await
1946            .unwrap();
1947        handle
1948            .ask(Request::SetEffort { effort: None })
1949            .await
1950            .unwrap();
1951        handle
1952            .ask(Request::SetPlanMode { on: true })
1953            .await
1954            .unwrap();
1955
1956        let AgentEvent::Settings {
1957            model,
1958            effort,
1959            plan,
1960            ..
1961        } = handle.ask(Request::Settings).await.unwrap()
1962        else {
1963            panic!("expected Settings");
1964        };
1965        assert_eq!(model, "m2", "the model switch is visible");
1966        assert_eq!(effort, None, "clearing effort is visible");
1967        assert!(plan, "plan mode is visible");
1968    }
1969
1970    #[tokio::test]
1971    async fn settings_is_answered_mid_run_from_the_side_handle() {
1972        // The panel must not learn nothing until the run ends — that staleness is
1973        // exactly what `Request::Settings` exists to fix, so the mid-run arm is
1974        // answered AT ONCE, from the handles cloned before `agent.run`.
1975        let rec = Recorder::default();
1976        rec.push(vec![
1977            LlmStreamEvent::ToolCall {
1978                id: "c1".into(),
1979                name: "gate".into(),
1980                arguments: json!({ "text": "hi" }),
1981            },
1982            LlmStreamEvent::Done {
1983                stop_reason: StopReason::ToolUse,
1984                usage: None,
1985            },
1986        ]);
1987        rec.push(vec![
1988            LlmStreamEvent::TextDelta("done".into()),
1989            LlmStreamEvent::Done {
1990                stop_reason: StopReason::Stop,
1991                usage: None,
1992            },
1993        ]);
1994        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1995        let release = Arc::new(tokio::sync::Notify::new());
1996        let gate = erased(GateTool {
1997            entered: entered_tx,
1998            release: release.clone(),
1999        });
2000        let mut cfg = agent_config(fake_stream_fn(&rec), vec![gate]);
2001        cfg.llm.effort = Some("low".into());
2002        let handle = SessionActor::spawn(Agent::new(cfg));
2003        let mut rx = handle.subscribe();
2004        handle.send(Request::Submit { text: "a".into() }).unwrap();
2005
2006        entered_rx.recv().await.unwrap(); // the run is inside the tool
2007
2008        let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Settings))
2009            .await
2010            .expect("settings must not wait for the run")
2011            .unwrap();
2012        let AgentEvent::Settings {
2013            model,
2014            effort,
2015            plan,
2016            ..
2017        } = reply
2018        else {
2019            panic!("expected Settings, got {reply:?}");
2020        };
2021        assert_eq!(model, "m1");
2022        assert_eq!(effort.as_deref(), Some("low"));
2023        assert!(
2024            !plan,
2025            "plan mode is read from the handle cloned beside `hooks`"
2026        );
2027
2028        release.notify_one();
2029        wait_for_end(&mut rx).await;
2030    }
2031
2032    #[tokio::test]
2033    async fn ask_status_is_answered_mid_run_from_the_snapshot() {
2034        // A lean `Status` (no transcript): the member's last assistant text,
2035        // answered while a run is in flight from the turn-boundary snapshot.
2036        let rec = Recorder::default();
2037        rec.push(vec![
2038            LlmStreamEvent::ToolCall {
2039                id: "c1".into(),
2040                name: "gate".into(),
2041                arguments: json!({ "text": "hi" }),
2042            },
2043            LlmStreamEvent::Done {
2044                stop_reason: StopReason::ToolUse,
2045                usage: None,
2046            },
2047        ]);
2048        rec.push(vec![
2049            LlmStreamEvent::TextDelta("done".into()),
2050            LlmStreamEvent::Done {
2051                stop_reason: StopReason::Stop,
2052                usage: None,
2053            },
2054        ]);
2055
2056        let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
2057        let release = Arc::new(tokio::sync::Notify::new());
2058        let gate = erased(GateTool {
2059            entered: entered_tx,
2060            release: release.clone(),
2061        });
2062        // Seed an assistant so the mid-run snapshot has a "last work".
2063        let mut cfg = agent_config(fake_stream_fn(&rec), vec![gate]);
2064        cfg.context = vec![AgentMessage::Assistant {
2065            content: vec![crate::message::ContentBlock::Text {
2066                text: "seeded work".into(),
2067            }],
2068            stop_reason: StopReason::Stop,
2069            usage: None,
2070            model: None,
2071        }];
2072        let handle = SessionActor::spawn(Agent::new(cfg));
2073        let mut rx = handle.subscribe();
2074        handle.send(Request::Submit { text: "a".into() }).unwrap();
2075
2076        entered_rx.recv().await.unwrap(); // run is inside the tool
2077
2078        let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Status))
2079            .await
2080            .expect("status must not wait for the run")
2081            .unwrap();
2082        let AgentEvent::Status { last_assistant_text } = reply else {
2083            panic!("expected Status, got {reply:?}");
2084        };
2085        assert_eq!(last_assistant_text.as_deref(), Some("seeded work"));
2086
2087        release.notify_one();
2088        wait_for_end(&mut rx).await;
2089    }
2090}