1use 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
134pub const EVENT_BUFFER: usize = 1024;
138
139#[derive(Clone)]
144pub struct SessionHandle {
145 inbox: mpsc::UnboundedSender<Message>,
146 events: broadcast::Sender<AgentEvent>,
147 state: watch::Sender<MemberState>,
152}
153
154impl SessionHandle {
155 pub fn send(&self, request: Request) -> Result<(), SessionClosed> {
162 self.tell(None, request)
163 }
164
165 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 pub async fn ask(&self, request: Request) -> Result<AgentEvent, SessionClosed> {
191 self.question(None, request).await
192 }
193
194 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 pub fn state(&self) -> MemberState {
222 *self.state.borrow()
223 }
224
225 pub fn state_rx(&self) -> watch::Receiver<MemberState> {
229 self.state.subscribe()
230 }
231 pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
234 self.events.subscribe()
235 }
236}
237
238#[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
250enum Message {
252 Tell {
255 from: Option<SessionId>,
256 request: Request,
257 },
258 Ask {
260 from: Option<SessionId>,
261 request: Request,
262 reply: oneshot::Sender<AgentEvent>,
263 },
264}
265
266pub 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
287async fn serve(
289 mut agent: Agent,
290 mut inbox: mpsc::UnboundedReceiver<Message>,
291 events: broadcast::Sender<AgentEvent>,
292 state: watch::Sender<MemberState>,
293) {
294 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, },
306 };
307
308 dispatch(&mut agent, message, &mut inbox, &events, &mut deferred, &state).await;
309 }
310}
311
312fn tag(sender: &SessionId, content: &str) -> String {
315 format!("[message from {sender}]\n{content}")
316}
317
318async 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
331fn 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
356async 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 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 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 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 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 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 Request::SetPlanMode { on } => {
466 agent.set_plan_mode(on);
467 AgentEvent::Ack
468 }
469 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 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
515async 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.send_replace(MemberState::Running);
532 let (sink_tx, mut sink_rx) = mpsc::unbounded_channel::<AgentEvent>();
533 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 let side = agent.side_handle();
541 let plan = agent.plan_mode();
545
546 let mut run = Box::pin(agent.run(text, sink_tx));
547 let result = loop {
548 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 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 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 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 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 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 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 while let Ok(event) = sink_rx.try_recv() {
745 let _ = events.send(event);
746 }
747
748 if let Err(e) = &result {
752 let _ = events.send(AgentEvent::Error {
753 message: e.to_string(),
754 });
755 }
756 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 title_after_turns: crate::title::DEFAULT_TITLE_AFTER_TURNS,
837 }
838 }
839
840 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 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 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 wait_for_end(&mut rx).await;
946 }
947
948 #[tokio::test]
949 async fn ask_cancel_aborts_an_in_flight_run() {
950 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 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 wait_for_end(&mut rx).await;
979 }
980
981 #[tokio::test]
982 async fn state_is_running_during_a_turn_then_done_after() {
983 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 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 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 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(); 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 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; 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 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 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(); handle
1225 .send(Request::SetModel { model: "m2".into() })
1226 .unwrap(); release.notify_one();
1228 wait_for_end(&mut rx).await; handle.send(Request::Submit { text: "b".into() }).unwrap();
1231 wait_for_end(&mut rx).await; 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 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 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 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 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 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(); 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 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 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 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 rec.push(vec![
1657 LlmStreamEvent::TextDelta("aside".into()),
1658 LlmStreamEvent::Done {
1659 stop_reason: StopReason::Stop,
1660 usage: None,
1661 },
1662 ]);
1663 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(); 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 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; handle.send(Request::Submit { text: "b".into() }).unwrap();
1804 wait_for_end(&mut rx).await; 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 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 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(); 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 let rec = Recorder::default();
1885 let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1886 cfg.llm.base_url = None; 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 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 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(); 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 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 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(); 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}