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