1use std::collections::VecDeque;
113use std::fmt;
114
115use tokio::sync::{broadcast, mpsc, oneshot, watch};
116
117use crate::agent::Agent;
118use crate::compaction::CompactOutcome;
119use crate::event::AgentEvent;
120use crate::hooks::HooksSet;
121use crate::loop_::LoopError;
122use crate::message::{AgentMessage, StopReason};
123use crate::protocol::{MemberState, Request, SessionId};
124
125pub const EVENT_BUFFER: usize = 1024;
129
130#[derive(Clone)]
135pub struct SessionHandle {
136 inbox: mpsc::UnboundedSender<Message>,
137 events: broadcast::Sender<AgentEvent>,
138 state: watch::Sender<MemberState>,
143}
144
145impl SessionHandle {
146 pub fn send(&self, request: Request) -> Result<(), SessionClosed> {
153 self.tell(None, request)
154 }
155
156 pub fn send_from(&self, from: SessionId, request: Request) -> Result<(), SessionClosed> {
160 self.tell(Some(from), request)
161 }
162
163 fn tell(&self, from: Option<SessionId>, request: Request) -> Result<(), SessionClosed> {
164 self.inbox
165 .send(Message::Tell { from, request })
166 .map_err(|_| SessionClosed)
167 }
168
169 pub async fn ask(&self, request: Request) -> Result<AgentEvent, SessionClosed> {
181 self.question(None, request).await
182 }
183
184 pub async fn ask_from(
186 &self,
187 from: SessionId,
188 request: Request,
189 ) -> Result<AgentEvent, SessionClosed> {
190 self.question(Some(from), request).await
191 }
192
193 async fn question(
194 &self,
195 from: Option<SessionId>,
196 request: Request,
197 ) -> Result<AgentEvent, SessionClosed> {
198 let (reply_tx, reply_rx) = oneshot::channel();
199 self.inbox
200 .send(Message::Ask {
201 from,
202 request,
203 reply: reply_tx,
204 })
205 .map_err(|_| SessionClosed)?;
206 reply_rx.await.map_err(|_| SessionClosed)
207 }
208
209 pub fn state(&self) -> MemberState {
212 *self.state.borrow()
213 }
214
215 pub fn state_rx(&self) -> watch::Receiver<MemberState> {
219 self.state.subscribe()
220 }
221 pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
224 self.events.subscribe()
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct SessionClosed;
231
232impl fmt::Display for SessionClosed {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 f.write_str("session actor has shut down")
235 }
236}
237
238impl std::error::Error for SessionClosed {}
239
240enum Message {
242 Tell {
245 from: Option<SessionId>,
246 request: Request,
247 },
248 Ask {
250 from: Option<SessionId>,
251 request: Request,
252 reply: oneshot::Sender<AgentEvent>,
253 },
254}
255
256pub struct SessionActor;
261
262impl SessionActor {
263 pub fn spawn(agent: Agent) -> SessionHandle {
264 let (inbox_tx, inbox_rx) = mpsc::unbounded_channel();
265 let (events_tx, _) = broadcast::channel(EVENT_BUFFER);
266 let events = events_tx.clone();
267 let (state_tx, _state_rx) = watch::channel(MemberState::Idle);
268 tokio::spawn(serve(agent, inbox_rx, events_tx, state_tx.clone()));
269 SessionHandle {
270 inbox: inbox_tx,
271 events,
272 state: state_tx,
273 }
274 }
275}
276
277async fn serve(
279 mut agent: Agent,
280 mut inbox: mpsc::UnboundedReceiver<Message>,
281 events: broadcast::Sender<AgentEvent>,
282 state: watch::Sender<MemberState>,
283) {
284 let mut deferred: VecDeque<Message> = VecDeque::new();
288
289 loop {
290 let message = match deferred.pop_front() {
291 Some(message) => message,
292 None => match inbox.recv().await {
293 Some(message) => message,
294 None => break, },
296 };
297
298 dispatch(&mut agent, message, &mut inbox, &events, &mut deferred, &state).await;
299 }
300}
301
302fn tag(sender: &SessionId, content: &str) -> String {
305 format!("[message from {sender}]\n{content}")
306}
307
308async fn gate_inbound(hooks: &HooksSet, message: &mut Message) -> Option<String> {
312 let (from, request) = match message {
313 Message::Tell { from, request } | Message::Ask { from, request, .. } => (from, request),
314 };
315 if !crate::protocol::is_inbound(request) {
316 return None;
317 }
318 hooks.before_inbound(from.as_ref(), request).await
319}
320
321async fn dispatch(
324 agent: &mut Agent,
325 mut message: Message,
326 inbox: &mut mpsc::UnboundedReceiver<Message>,
327 events: &broadcast::Sender<AgentEvent>,
328 deferred: &mut VecDeque<Message>,
329 state: &watch::Sender<MemberState>,
330) {
331 if let Some(reason) = gate_inbound(agent.hooks(), &mut message).await {
334 if let Message::Ask { reply, .. } = message {
335 let _ = reply.send(AgentEvent::Error {
336 message: format!("inbound blocked: {reason}"),
337 });
338 }
339 return;
340 }
341
342 let (from, request, reply) = match message {
343 Message::Tell { from, request } => (from, request, None),
344 Message::Ask { from, request, reply } => (from, request, Some(reply)),
345 };
346 let sender = from.unwrap_or_else(SessionId::user);
347
348 let event = match request {
349 Request::Submit { text } => match run(agent, &text, inbox, events, deferred, state).await {
350 Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
351 Err(e) => AgentEvent::Error {
352 message: e.to_string(),
353 },
354 },
355 Request::Notify { content } => match agent.notify(tag(&sender, &content)) {
358 Ok(_) => {
359 let _ = events.send(AgentEvent::MessageReceived {
360 from: sender.clone(),
361 content,
362 });
363 AgentEvent::Ack
364 }
365 Err(e) => AgentEvent::Error {
366 message: e.to_string(),
367 },
368 },
369 Request::Interrupt { content } => {
370 agent.steer(AgentMessage::user_text(tag(&sender, &content)));
371 let _ = events.send(AgentEvent::MessageReceived {
372 from: sender.clone(),
373 content,
374 });
375 AgentEvent::Ack
376 }
377 Request::Wake { content } => {
381 let _ = events.send(AgentEvent::MessageReceived {
382 from: sender.clone(),
383 content: content.clone(),
384 });
385 match run(agent, &tag(&sender, &content), inbox, events, deferred, state).await {
386 Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
387 Err(e) => AgentEvent::Error {
388 message: e.to_string(),
389 },
390 }
391 }
392 Request::Cancel => AgentEvent::Ack,
396 Request::SetModel { model } => match agent.set_model(model) {
397 Ok(()) => AgentEvent::Ack,
398 Err(e) => AgentEvent::Error {
399 message: e.to_string(),
400 },
401 },
402 Request::SetEffort { effort } => match agent.set_effort(effort) {
403 Ok(()) => AgentEvent::Ack,
404 Err(e) => AgentEvent::Error {
405 message: e.to_string(),
406 },
407 },
408 Request::Compact { instructions } => match agent.compact(instructions.as_deref()).await {
409 Ok(CompactOutcome::NothingToDo) => AgentEvent::Ack,
410 Ok(CompactOutcome::Done {
411 summarized, kept, ..
412 }) => AgentEvent::Compaction { summarized, kept },
413 Err(e) => AgentEvent::Error { message: e },
414 },
415 Request::SideAsk { text } => match agent.side_ask(&text).await {
420 Ok(answer) => AgentEvent::SideAnswer {
421 text: answer.text,
422 usage: answer.usage,
423 },
424 Err(e) => AgentEvent::Error {
425 message: e.to_string(),
426 },
427 },
428 Request::SetPlanMode { on } => {
431 agent.set_plan_mode(on);
432 AgentEvent::Ack
433 }
434 Request::GetHistory => AgentEvent::History {
435 messages: agent.messages().to_vec(),
436 },
437 Request::Define { .. } => AgentEvent::Error {
440 message: "agent definition is served, not in-process".into(),
441 },
442 Request::ListSessions | Request::Unknown => AgentEvent::Ack,
443 };
444
445 if let Some(reply) = reply {
446 let _ = reply.send(event);
447 }
448}
449
450async fn run(
453 agent: &mut Agent,
454 text: &str,
455 inbox: &mut mpsc::UnboundedReceiver<Message>,
456 events: &broadcast::Sender<AgentEvent>,
457 deferred: &mut VecDeque<Message>,
458 state: &watch::Sender<MemberState>,
459) -> Result<StopReason, LoopError> {
460 state.send_replace(MemberState::Running);
467 let (sink_tx, mut sink_rx) = mpsc::unbounded_channel::<AgentEvent>();
468 let steer = agent.steer_sender();
471 let follow_up = agent.follow_up_sender();
472 let cancel = agent.cancel_token();
473 let hooks = agent.hooks().clone();
474
475 let mut run = Box::pin(agent.run(text, sink_tx));
476 let result = loop {
477 tokio::select! {
483 biased;
484 Some(mut message) = inbox.recv() => {
485 if let Some(reason) = gate_inbound(&hooks, &mut message).await {
486 if let Message::Ask { reply, .. } = message {
487 let _ = reply.send(AgentEvent::Error {
488 message: format!("inbound blocked: {reason}"),
489 });
490 }
491 continue;
492 }
493 match message {
494 Message::Tell {
495 request: Request::Cancel,
496 ..
497 } => cancel.cancel(),
498 Message::Tell {
499 request: Request::Notify { content } | Request::Interrupt { content },
500 from,
501 } => {
502 let from = from.unwrap_or_else(SessionId::user);
503 let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
504 let _ = events.send(AgentEvent::MessageReceived {
505 from,
506 content,
507 });
508 }
509 Message::Tell {
510 request: Request::Wake { content },
511 from,
512 } => {
513 let from = from.unwrap_or_else(SessionId::user);
514 let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
515 let _ = events.send(AgentEvent::MessageReceived {
516 from,
517 content,
518 });
519 }
520 other => deferred.push_back(other),
521 }
522 },
523 result = &mut run => break result,
524 Some(event) = sink_rx.recv() => {
525 let _ = events.send(event);
526 }
527 }
528 };
529
530 while let Ok(event) = sink_rx.try_recv() {
533 let _ = events.send(event);
534 }
535
536 if let Err(e) = &result {
540 let _ = events.send(AgentEvent::Error {
541 message: e.to_string(),
542 });
543 }
544 match &result {
548 Ok(StopReason::Error) | Err(_) => {
549 state.send_replace(MemberState::Failed);
550 }
551 Ok(_) => {
552 state.send_replace(MemberState::Done);
553 }
554 }
555 result
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::agent::{Agent, AgentConfig};
562 use crate::compaction::CompactionPolicy;
563 use crate::event::LlmStreamEvent;
564 use crate::hooks::HooksSet;
565 use crate::message::StopReason;
566 use crate::streamfn::{LlmOpts, LlmStream, StreamFn};
567 use crate::tool::{ToolContext, ToolOutput, TypedTool, erased};
568 use futures::StreamExt as _;
569 use serde::Deserialize;
570 use serde_json::json;
571 use std::sync::{Arc, Mutex};
572 use std::time::Duration;
573
574 #[derive(Clone, Default)]
575 struct Recorder {
576 script: Arc<Mutex<VecDeque<Vec<LlmStreamEvent>>>>,
577 calls: Arc<Mutex<Vec<Vec<AgentMessage>>>>,
578 models: Arc<Mutex<Vec<String>>>,
579 }
580
581 impl Recorder {
582 fn push(&self, events: Vec<LlmStreamEvent>) {
583 self.script.lock().unwrap().push_back(events);
584 }
585 fn calls(&self) -> Vec<Vec<AgentMessage>> {
586 self.calls.lock().unwrap().clone()
587 }
588 fn models(&self) -> Vec<String> {
589 self.models.lock().unwrap().clone()
590 }
591 }
592
593 fn fake_stream_fn(rec: &Recorder) -> StreamFn {
594 let rec = rec.clone();
595 Arc::new(
596 move |ctx: &[AgentMessage], _system, _tools, opts: &LlmOpts| {
597 rec.calls.lock().unwrap().push(ctx.to_vec());
598 rec.models.lock().unwrap().push(opts.model.clone());
599 let events = rec.script.lock().unwrap().pop_front().unwrap_or_default();
600 Box::pin(futures::stream::iter(events)) as LlmStream
601 },
602 )
603 }
604
605 fn agent_config(stream_fn: StreamFn, tools: Vec<crate::tool::Tool>) -> AgentConfig {
606 AgentConfig {
607 system: "sys".into(),
608 tools,
609 llm: LlmOpts {
610 model: "m1".into(),
611 ..LlmOpts::default()
612 },
613 stream_fn,
614 hooks: HooksSet::default(),
615 session: None,
616 context: Vec::new(),
617 working_dir: std::path::PathBuf::new(),
618 max_turns: crate::loop_::DEFAULT_MAX_TURNS,
619 parallel_tools: true,
620 compaction: CompactionPolicy::default(),
621 plan_mode: crate::hooks::PlanModeHandle::new(),
622 }
623 }
624
625 async fn wait_for_end(rx: &mut broadcast::Receiver<AgentEvent>) {
627 loop {
628 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
629 .await
630 .expect("an event within the timeout")
631 .expect("the outbox stays open");
632 if matches!(event, AgentEvent::AgentEnd) {
633 return;
634 }
635 }
636 }
637
638 #[tokio::test]
639 async fn submit_streams_events_to_a_subscriber() {
640 let rec = Recorder::default();
641 rec.push(vec![
642 LlmStreamEvent::TextDelta("hello".into()),
643 LlmStreamEvent::Done {
644 stop_reason: StopReason::Stop,
645 usage: None,
646 },
647 ]);
648 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
649 let mut rx = handle.subscribe();
650 handle.send(Request::Submit { text: "hi".into() }).unwrap();
651
652 let mut saw_text = false;
653 loop {
654 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
655 .await
656 .expect("an event")
657 .expect("open");
658 match event {
659 AgentEvent::MessageUpdate { message } if message.as_text() == "hello" => {
660 saw_text = true;
661 }
662 AgentEvent::AgentEnd => break,
663 _ => {}
664 }
665 }
666 assert!(saw_text, "the streamed text reached the subscriber");
667 }
668
669 #[tokio::test]
670 async fn idle_cancel_does_not_poison_the_next_run() {
671 let rec = Recorder::default();
672 for text in ["one", "two"] {
673 rec.push(vec![
674 LlmStreamEvent::TextDelta(text.into()),
675 LlmStreamEvent::Done {
676 stop_reason: StopReason::Stop,
677 usage: None,
678 },
679 ]);
680 }
681 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
682 let mut rx = handle.subscribe();
683
684 handle.send(Request::Submit { text: "a".into() }).unwrap();
685 wait_for_end(&mut rx).await;
686
687 handle.send(Request::Cancel).unwrap();
689 handle.send(Request::Submit { text: "b".into() }).unwrap();
690
691 let mut saw_two = false;
692 loop {
693 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
694 .await
695 .expect("an event")
696 .expect("open");
697 match event {
698 AgentEvent::MessageUpdate { message } if message.as_text() == "two" => {
699 saw_two = true;
700 }
701 AgentEvent::AgentEnd => break,
702 _ => {}
703 }
704 }
705 assert!(saw_two, "the second run completed despite the idle cancel");
706 }
707
708 #[tokio::test]
709 async fn cancel_aborts_an_in_flight_run() {
710 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
712 let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
713 Box::pin(head.chain(futures::stream::pending())) as LlmStream
714 });
715 let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
716 let mut rx = handle.subscribe();
717 handle.send(Request::Submit { text: "hi".into() }).unwrap();
718
719 loop {
720 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
721 .await
722 .expect("an event")
723 .expect("open");
724 if matches!(event, AgentEvent::MessageUpdate { .. }) {
725 handle.send(Request::Cancel).unwrap();
726 break;
727 }
728 }
729 wait_for_end(&mut rx).await;
731 }
732
733 #[tokio::test]
734 async fn state_is_running_during_a_turn_then_done_after() {
735 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
738 let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
739 Box::pin(head.chain(futures::stream::pending())) as LlmStream
740 });
741 let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
742 assert_eq!(handle.state(), MemberState::Idle, "a virgin session is idle");
743
744 let mut state_rx = handle.state_rx();
746 handle.send(Request::Submit { text: "hi".into() }).unwrap();
747 tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
748 .await
749 .expect("a state change")
750 .expect("open");
751 assert_eq!(*state_rx.borrow_and_update(), MemberState::Running);
752 assert_eq!(handle.state(), MemberState::Running, "the run is in flight");
753
754 handle.send(Request::Cancel).unwrap();
755 tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
756 .await
757 .expect("a state change")
758 .expect("open");
759 assert_eq!(
760 *state_rx.borrow_and_update(),
761 MemberState::Done,
762 "an abort ends Done, not Failed"
763 );
764 assert_eq!(handle.state(), MemberState::Done);
765 }
766
767 #[tokio::test]
768 async fn a_fatal_stream_error_ends_failed() {
769 let rec = Recorder::default();
770 rec.push(vec![LlmStreamEvent::Error {
771 message: "boom".into(),
772 fatal: true,
773 }]);
774 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
775 let mut rx = handle.subscribe();
776 handle.send(Request::Submit { text: "hi".into() }).unwrap();
777 wait_for_end(&mut rx).await;
778 assert_eq!(handle.state(), MemberState::Failed);
779 }
780
781 #[derive(Deserialize, schemars::JsonSchema)]
782 struct GateArgs {
783 text: String,
784 }
785
786 struct GateTool {
789 entered: mpsc::UnboundedSender<()>,
790 release: Arc<tokio::sync::Notify>,
791 }
792
793 #[async_trait::async_trait]
794 impl TypedTool for GateTool {
795 type Args = GateArgs;
796 fn name(&self) -> &str {
797 "gate"
798 }
799 fn description(&self) -> &str {
800 "blocks until released"
801 }
802 async fn execute(&self, args: GateArgs, _ctx: &ToolContext) -> ToolOutput {
803 self.entered.send(()).ok();
804 self.release.notified().await;
805 ToolOutput {
806 output: format!("released:{}", args.text),
807 is_error: false,
808 diff: None,
809 path: None,
810 }
811 }
812 }
813
814 #[tokio::test]
815 async fn config_request_during_a_run_is_deferred_to_after_it() {
816 let rec = Recorder::default();
819 rec.push(vec![
820 LlmStreamEvent::ToolCall {
821 id: "c1".into(),
822 name: "gate".into(),
823 arguments: json!({ "text": "hi" }),
824 },
825 LlmStreamEvent::Done {
826 stop_reason: StopReason::ToolUse,
827 usage: None,
828 },
829 ]);
830 for text in ["turn two", "run two"] {
831 rec.push(vec![
832 LlmStreamEvent::TextDelta(text.into()),
833 LlmStreamEvent::Done {
834 stop_reason: StopReason::Stop,
835 usage: None,
836 },
837 ]);
838 }
839
840 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
841 let release = Arc::new(tokio::sync::Notify::new());
842 let gate = erased(GateTool {
843 entered: entered_tx,
844 release: release.clone(),
845 });
846
847 let handle =
848 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
849 let mut rx = handle.subscribe();
850 handle.send(Request::Submit { text: "a".into() }).unwrap();
851
852 entered_rx.recv().await.unwrap(); handle
854 .send(Request::SetModel { model: "m2".into() })
855 .unwrap(); release.notify_one();
857 wait_for_end(&mut rx).await; handle.send(Request::Submit { text: "b".into() }).unwrap();
860 wait_for_end(&mut rx).await; assert_eq!(
863 rec.models(),
864 ["m1", "m1", "m2"],
865 "the swap waited for the run, then applied to the next one"
866 );
867 }
868
869 #[tokio::test]
870 async fn ask_history_returns_the_conversation() {
871 let rec = Recorder::default();
872 rec.push(vec![
873 LlmStreamEvent::TextDelta("hi back".into()),
874 LlmStreamEvent::Done {
875 stop_reason: StopReason::Stop,
876 usage: None,
877 },
878 ]);
879 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
880 let mut rx = handle.subscribe();
881 handle
882 .send(Request::Submit {
883 text: "hello".into(),
884 })
885 .unwrap();
886 wait_for_end(&mut rx).await;
887
888 let reply = handle.ask(Request::GetHistory).await.unwrap();
889 let AgentEvent::History { messages } = reply else {
890 panic!("expected a History reply, got {reply:?}");
891 };
892 assert!(
893 messages.iter().any(|m| m.as_text() == "hello"),
894 "the read-back carries the user turn: {messages:?}"
895 );
896 }
897
898 #[tokio::test]
899 async fn ask_set_model_replies_ack_and_takes_effect() {
900 let rec = Recorder::default();
901 rec.push(vec![
902 LlmStreamEvent::TextDelta("x".into()),
903 LlmStreamEvent::Done {
904 stop_reason: StopReason::Stop,
905 usage: None,
906 },
907 ]);
908 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
909 let mut rx = handle.subscribe();
910
911 let reply = handle
912 .ask(Request::SetModel { model: "m2".into() })
913 .await
914 .unwrap();
915 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
916
917 handle.send(Request::Submit { text: "go".into() }).unwrap();
918 wait_for_end(&mut rx).await;
919 assert_eq!(rec.models(), ["m2"], "the swap applied to the next run");
920 }
921
922 #[tokio::test]
923 async fn define_on_a_local_session_is_an_error() {
924 let rec = Recorder::default();
925 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
926 let reply = handle
927 .ask(Request::Define {
928 name: Some("w1".into()),
929 model: None,
930 role: None,
931 tools: None,
932 base_url: None,
933 api_key: None,
934 read_only: false,
935 effort: None,
936 })
937 .await
938 .unwrap();
939 assert!(
940 matches!(&reply, AgentEvent::Error { message } if message.contains("served")),
941 "{reply:?}"
942 );
943 }
944
945 #[tokio::test]
946 async fn ask_compact_with_nothing_to_do_replies_ack() {
947 let handle = SessionActor::spawn(Agent::new(agent_config(
948 fake_stream_fn(&Recorder::default()),
949 vec![],
950 )));
951 let reply = handle
952 .ask(Request::Compact { instructions: None })
953 .await
954 .unwrap();
955 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
956 }
957
958 #[tokio::test]
959 async fn ask_submit_replies_stopped_with_the_reason() {
960 let rec = Recorder::default();
961 rec.push(vec![
962 LlmStreamEvent::TextDelta("done".into()),
963 LlmStreamEvent::Done {
964 stop_reason: StopReason::Stop,
965 usage: None,
966 },
967 ]);
968 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
969 let reply = handle
970 .ask(Request::Submit { text: "hi".into() })
971 .await
972 .unwrap();
973 assert!(
974 matches!(
975 reply,
976 AgentEvent::Stopped {
977 stop_reason: StopReason::Stop
978 }
979 ),
980 "{reply:?}"
981 );
982 }
983
984 #[tokio::test]
985 async fn notify_appends_without_starting_a_turn() {
986 let rec = Recorder::default();
987 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
988 let mut rx = handle.subscribe();
989
990 let reply = handle
991 .ask(Request::Notify {
992 content: "ping".into(),
993 })
994 .await
995 .unwrap();
996 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
997
998 let surfaced = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1000 .await
1001 .expect("an event")
1002 .expect("open");
1003 assert!(
1004 matches!(&surfaced, AgentEvent::MessageReceived { content, .. } if content == "ping"),
1005 "{surfaced:?}"
1006 );
1007
1008 let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1010 panic!("expected History");
1011 };
1012 assert!(
1013 messages
1014 .iter()
1015 .any(|m| m.as_text() == "[message from user]\nping"),
1016 "{messages:?}"
1017 );
1018
1019 assert!(rec.calls().is_empty(), "notify must not run the loop");
1021 }
1022
1023 #[tokio::test]
1024 async fn wake_starts_a_run_when_idle() {
1025 let rec = Recorder::default();
1026 rec.push(vec![
1027 LlmStreamEvent::TextDelta("acted".into()),
1028 LlmStreamEvent::Done {
1029 stop_reason: StopReason::Stop,
1030 usage: None,
1031 },
1032 ]);
1033 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1034 let mut rx = handle.subscribe();
1035
1036 let reply = handle
1037 .ask(Request::Wake {
1038 content: "do the task".into(),
1039 })
1040 .await
1041 .unwrap();
1042 assert!(
1043 matches!(
1044 reply,
1045 AgentEvent::Stopped {
1046 stop_reason: StopReason::Stop
1047 }
1048 ),
1049 "{reply:?}"
1050 );
1051 wait_for_end(&mut rx).await;
1052
1053 let calls = rec.calls();
1054 assert_eq!(calls.len(), 1, "Wake ran the loop once");
1055 assert!(
1056 calls[0]
1057 .iter()
1058 .any(|m| m.as_text() == "[message from user]\ndo the task"),
1059 "the tagged task is the prompt: {:?}",
1060 calls[0]
1061 );
1062 }
1063
1064 struct InboundPolicy;
1065
1066 #[async_trait::async_trait]
1067 impl crate::hooks::Hooks for InboundPolicy {
1068 async fn before_inbound(
1069 &self,
1070 _from: Option<&SessionId>,
1071 request: &mut Request,
1072 ) -> Option<String> {
1073 let Request::Notify { content } = request else {
1074 return None;
1075 };
1076 if content.starts_with("drop:") {
1077 return Some("policy".into());
1078 }
1079 *content = format!("{content}!");
1080 None
1081 }
1082 }
1083
1084 #[tokio::test]
1085 async fn before_inbound_drops_and_rewrites_a_notify() {
1086 let rec = Recorder::default();
1087 let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1088 cfg.hooks = HooksSet::one(Arc::new(InboundPolicy));
1089 let handle = SessionActor::spawn(Agent::new(cfg));
1090
1091 let reply = handle
1093 .ask(Request::Notify {
1094 content: "drop: me".into(),
1095 })
1096 .await
1097 .unwrap();
1098 assert!(
1099 matches!(&reply, AgentEvent::Error { message } if message.contains("policy")),
1100 "{reply:?}"
1101 );
1102
1103 let reply = handle
1105 .ask(Request::Notify {
1106 content: "keep me".into(),
1107 })
1108 .await
1109 .unwrap();
1110 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1111
1112 let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1113 panic!("expected History");
1114 };
1115 let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1116 assert!(
1117 !texts.iter().any(|t| t == "drop: me"),
1118 "the dropped message never landed: {texts:?}"
1119 );
1120 assert!(
1121 texts.iter().any(|t| t == "[message from user]\nkeep me!"),
1122 "the rewrite landed: {texts:?}"
1123 );
1124 }
1125
1126 #[tokio::test]
1127 async fn interrupt_reaches_the_next_turn_while_a_tool_is_blocked() {
1128 let rec = Recorder::default();
1129 rec.push(vec![
1130 LlmStreamEvent::ToolCall {
1131 id: "c1".into(),
1132 name: "gate".into(),
1133 arguments: json!({ "text": "hi" }),
1134 },
1135 LlmStreamEvent::Done {
1136 stop_reason: StopReason::ToolUse,
1137 usage: None,
1138 },
1139 ]);
1140 rec.push(vec![
1141 LlmStreamEvent::TextDelta("after interrupt".into()),
1142 LlmStreamEvent::Done {
1143 stop_reason: StopReason::Stop,
1144 usage: None,
1145 },
1146 ]);
1147
1148 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1149 let release = Arc::new(tokio::sync::Notify::new());
1150 let gate = erased(GateTool {
1151 entered: entered_tx,
1152 release: release.clone(),
1153 });
1154
1155 let handle =
1156 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1157 let mut rx = handle.subscribe();
1158 handle.send(Request::Submit { text: "hi".into() }).unwrap();
1159
1160 entered_rx.recv().await.unwrap(); handle
1162 .send(Request::Interrupt {
1163 content: "mid-run interrupt".into(),
1164 })
1165 .unwrap();
1166 release.notify_one();
1167
1168 wait_for_end(&mut rx).await;
1169
1170 let calls = rec.calls();
1171 assert_eq!(calls.len(), 2, "two stream calls: the tool turn and the next");
1172 assert!(
1173 calls[1]
1174 .iter()
1175 .any(|m| matches!(m, AgentMessage::User { .. })
1176 && m.as_text() == "[message from user]\nmid-run interrupt"),
1177 "the interrupt drained into the next turn: {:?}",
1178 calls[1]
1179 );
1180 }
1181
1182 #[tokio::test]
1183 async fn send_from_attributes_the_sender() {
1184 let rec = Recorder::default();
1185 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1186 let mut rx = handle.subscribe();
1187
1188 handle
1189 .send_from(
1190 SessionId::agent("b"),
1191 Request::Notify {
1192 content: "ping".into(),
1193 },
1194 )
1195 .unwrap();
1196
1197 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1198 .await
1199 .expect("an event")
1200 .expect("open");
1201 assert!(
1202 matches!(&event, AgentEvent::MessageReceived { from, content }
1203 if from.as_str() == "agent:b" && content == "ping"),
1204 "{event:?}"
1205 );
1206 }
1207
1208 struct SenderPolicy;
1209
1210 #[async_trait::async_trait]
1211 impl crate::hooks::Hooks for SenderPolicy {
1212 async fn before_inbound(
1213 &self,
1214 from: Option<&SessionId>,
1215 _request: &mut Request,
1216 ) -> Option<String> {
1217 (from.map(SessionId::as_str) == Some("agent:evil")).then(|| "blocked sender".into())
1218 }
1219 }
1220
1221 #[tokio::test]
1222 async fn before_inbound_sees_the_sender() {
1223 let rec = Recorder::default();
1224 let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1225 cfg.hooks = HooksSet::one(Arc::new(SenderPolicy));
1226 let handle = SessionActor::spawn(Agent::new(cfg));
1227
1228 let reply = handle
1230 .ask_from(
1231 SessionId::agent("evil"),
1232 Request::Notify {
1233 content: "let me in".into(),
1234 },
1235 )
1236 .await
1237 .unwrap();
1238 assert!(
1239 matches!(&reply, AgentEvent::Error { message } if message.contains("blocked sender")),
1240 "{reply:?}"
1241 );
1242
1243 let reply = handle
1245 .ask_from(
1246 SessionId::agent("friend"),
1247 Request::Notify {
1248 content: "hello".into(),
1249 },
1250 )
1251 .await
1252 .unwrap();
1253 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1254
1255 let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1256 panic!("expected History");
1257 };
1258 let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1259 assert!(
1260 texts
1261 .iter()
1262 .any(|t| t == "[message from agent:friend]\nhello"),
1263 "{texts:?}"
1264 );
1265 assert!(!texts.iter().any(|t| t.contains("let me in")), "{texts:?}");
1266 }
1267}