1use std::collections::VecDeque;
115use std::fmt;
116
117use tokio::sync::{broadcast, mpsc, oneshot, watch};
118
119use crate::agent::Agent;
120use crate::compaction::CompactOutcome;
121use crate::event::AgentEvent;
122use crate::hooks::HooksSet;
123use crate::loop_::LoopError;
124use crate::message::{AgentMessage, StopReason};
125use crate::protocol::{MemberState, Request, SessionId};
126use crate::streamfn::LlmOpts;
127
128pub const EVENT_BUFFER: usize = 1024;
132
133#[derive(Clone)]
138pub struct SessionHandle {
139 inbox: mpsc::UnboundedSender<Message>,
140 events: broadcast::Sender<AgentEvent>,
141 state: watch::Sender<MemberState>,
146}
147
148impl SessionHandle {
149 pub fn send(&self, request: Request) -> Result<(), SessionClosed> {
156 self.tell(None, request)
157 }
158
159 pub fn send_from(&self, from: SessionId, request: Request) -> Result<(), SessionClosed> {
163 self.tell(Some(from), request)
164 }
165
166 fn tell(&self, from: Option<SessionId>, request: Request) -> Result<(), SessionClosed> {
167 self.inbox
168 .send(Message::Tell { from, request })
169 .map_err(|_| SessionClosed)
170 }
171
172 pub async fn ask(&self, request: Request) -> Result<AgentEvent, SessionClosed> {
185 self.question(None, request).await
186 }
187
188 pub async fn ask_from(
190 &self,
191 from: SessionId,
192 request: Request,
193 ) -> Result<AgentEvent, SessionClosed> {
194 self.question(Some(from), request).await
195 }
196
197 async fn question(
198 &self,
199 from: Option<SessionId>,
200 request: Request,
201 ) -> Result<AgentEvent, SessionClosed> {
202 let (reply_tx, reply_rx) = oneshot::channel();
203 self.inbox
204 .send(Message::Ask {
205 from,
206 request,
207 reply: reply_tx,
208 })
209 .map_err(|_| SessionClosed)?;
210 reply_rx.await.map_err(|_| SessionClosed)
211 }
212
213 pub fn state(&self) -> MemberState {
216 *self.state.borrow()
217 }
218
219 pub fn state_rx(&self) -> watch::Receiver<MemberState> {
223 self.state.subscribe()
224 }
225 pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
228 self.events.subscribe()
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub struct SessionClosed;
235
236impl fmt::Display for SessionClosed {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.write_str("session actor has shut down")
239 }
240}
241
242impl std::error::Error for SessionClosed {}
243
244enum Message {
246 Tell {
249 from: Option<SessionId>,
250 request: Request,
251 },
252 Ask {
254 from: Option<SessionId>,
255 request: Request,
256 reply: oneshot::Sender<AgentEvent>,
257 },
258}
259
260pub struct SessionActor;
265
266impl SessionActor {
267 pub fn spawn(agent: Agent) -> SessionHandle {
268 let (inbox_tx, inbox_rx) = mpsc::unbounded_channel();
269 let (events_tx, _) = broadcast::channel(EVENT_BUFFER);
270 let events = events_tx.clone();
271 let (state_tx, _state_rx) = watch::channel(MemberState::Idle);
272 tokio::spawn(serve(agent, inbox_rx, events_tx, state_tx.clone()));
273 SessionHandle {
274 inbox: inbox_tx,
275 events,
276 state: state_tx,
277 }
278 }
279}
280
281async fn serve(
283 mut agent: Agent,
284 mut inbox: mpsc::UnboundedReceiver<Message>,
285 events: broadcast::Sender<AgentEvent>,
286 state: watch::Sender<MemberState>,
287) {
288 let mut deferred: VecDeque<Message> = VecDeque::new();
292
293 loop {
294 let message = match deferred.pop_front() {
295 Some(message) => message,
296 None => match inbox.recv().await {
297 Some(message) => message,
298 None => break, },
300 };
301
302 dispatch(&mut agent, message, &mut inbox, &events, &mut deferred, &state).await;
303 }
304}
305
306fn tag(sender: &SessionId, content: &str) -> String {
309 format!("[message from {sender}]\n{content}")
310}
311
312async fn gate_inbound(hooks: &HooksSet, message: &mut Message) -> Option<String> {
316 let (from, request) = match message {
317 Message::Tell { from, request } | Message::Ask { from, request, .. } => (from, request),
318 };
319 if !crate::protocol::is_inbound(request) {
320 return None;
321 }
322 hooks.before_inbound(from.as_ref(), request).await
323}
324
325fn usage_reply(messages: &[AgentMessage], llm: &LlmOpts) -> AgentEvent {
345 let mut stats = crate::stats::session_stats(messages);
346 stats.cost = crate::pricing::cost_of(messages, llm.base_url.as_deref(), &llm.model);
347 AgentEvent::Usage { stats }
348}
349
350async fn dispatch(
353 agent: &mut Agent,
354 mut message: Message,
355 inbox: &mut mpsc::UnboundedReceiver<Message>,
356 events: &broadcast::Sender<AgentEvent>,
357 deferred: &mut VecDeque<Message>,
358 state: &watch::Sender<MemberState>,
359) {
360 if let Some(reason) = gate_inbound(agent.hooks(), &mut message).await {
363 if let Message::Ask { reply, .. } = message {
364 let _ = reply.send(AgentEvent::Error {
365 message: format!("inbound blocked: {reason}"),
366 });
367 }
368 return;
369 }
370
371 let (from, request, reply) = match message {
372 Message::Tell { from, request } => (from, request, None),
373 Message::Ask { from, request, reply } => (from, request, Some(reply)),
374 };
375 let sender = from.unwrap_or_else(SessionId::user);
376
377 let event = match request {
378 Request::Submit { text } => match run(agent, &text, inbox, events, deferred, state).await {
379 Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
380 Err(e) => AgentEvent::Error {
381 message: e.to_string(),
382 },
383 },
384 Request::Notify { content } => match agent.notify(tag(&sender, &content)) {
387 Ok(_) => {
388 let _ = events.send(AgentEvent::MessageReceived {
389 from: sender.clone(),
390 content,
391 });
392 AgentEvent::Ack
393 }
394 Err(e) => AgentEvent::Error {
395 message: e.to_string(),
396 },
397 },
398 Request::Interrupt { content } => {
399 agent.steer(AgentMessage::user_text(tag(&sender, &content)));
400 let _ = events.send(AgentEvent::MessageReceived {
401 from: sender.clone(),
402 content,
403 });
404 AgentEvent::Ack
405 }
406 Request::Wake { content } => {
410 let _ = events.send(AgentEvent::MessageReceived {
411 from: sender.clone(),
412 content: content.clone(),
413 });
414 match run(agent, &tag(&sender, &content), inbox, events, deferred, state).await {
415 Ok(stop_reason) => AgentEvent::Stopped { stop_reason },
416 Err(e) => AgentEvent::Error {
417 message: e.to_string(),
418 },
419 }
420 }
421 Request::Cancel => AgentEvent::Ack,
425 Request::SetModel { model } => match agent.set_model(model) {
426 Ok(()) => AgentEvent::Ack,
427 Err(e) => AgentEvent::Error {
428 message: e.to_string(),
429 },
430 },
431 Request::SetEffort { effort } => match agent.set_effort(effort) {
432 Ok(()) => AgentEvent::Ack,
433 Err(e) => AgentEvent::Error {
434 message: e.to_string(),
435 },
436 },
437 Request::Compact { instructions } => match agent.compact(instructions.as_deref()).await {
438 Ok(CompactOutcome::NothingToDo) => AgentEvent::Ack,
439 Ok(CompactOutcome::Done {
440 summarized, kept, ..
441 }) => AgentEvent::Compaction { summarized, kept },
442 Err(e) => AgentEvent::Error { message: e },
443 },
444 Request::SideAsk { text } => match agent.side_ask(&text).await {
449 Ok(answer) => AgentEvent::SideAnswer {
450 text: answer.text,
451 usage: answer.usage,
452 },
453 Err(e) => AgentEvent::Error {
454 message: e.to_string(),
455 },
456 },
457 Request::SetPlanMode { on } => {
460 agent.set_plan_mode(on);
461 AgentEvent::Ack
462 }
463 Request::GetHistory => AgentEvent::History {
464 messages: agent.messages().to_vec(),
465 },
466 Request::Status => AgentEvent::Status {
467 last_assistant_text: crate::message::last_assistant_text(agent.messages()),
468 },
469 Request::Usage => usage_reply(agent.messages(), agent.llm()),
470 Request::Define { .. } => AgentEvent::Error {
473 message: "agent definition is served, not in-process".into(),
474 },
475 Request::ListSessions | Request::ListModels | Request::Unknown => AgentEvent::Ack,
476 };
477
478 if let Some(reply) = reply {
479 let _ = reply.send(event);
480 }
481}
482
483async fn run(
486 agent: &mut Agent,
487 text: &str,
488 inbox: &mut mpsc::UnboundedReceiver<Message>,
489 events: &broadcast::Sender<AgentEvent>,
490 deferred: &mut VecDeque<Message>,
491 state: &watch::Sender<MemberState>,
492) -> Result<StopReason, LoopError> {
493 state.send_replace(MemberState::Running);
500 let (sink_tx, mut sink_rx) = mpsc::unbounded_channel::<AgentEvent>();
501 let steer = agent.steer_sender();
504 let follow_up = agent.follow_up_sender();
505 let cancel = agent.cancel_token();
506 let hooks = agent.hooks().clone();
507 let side = agent.side_handle();
509
510 let mut run = Box::pin(agent.run(text, sink_tx));
511 let result = loop {
512 tokio::select! {
518 biased;
519 Some(mut message) = inbox.recv() => {
520 if let Some(reason) = gate_inbound(&hooks, &mut message).await {
521 if let Message::Ask { reply, .. } = message {
522 let _ = reply.send(AgentEvent::Error {
523 message: format!("inbound blocked: {reason}"),
524 });
525 }
526 continue;
527 }
528 match message {
529 Message::Tell {
535 request: Request::Cancel,
536 ..
537 } => cancel.cancel(),
538 Message::Ask {
539 request: Request::Cancel,
540 reply,
541 ..
542 } => {
543 cancel.cancel();
544 let _ = reply.send(AgentEvent::Ack);
545 }
546 Message::Tell {
547 request: Request::Notify { content } | Request::Interrupt { content },
548 from,
549 } => {
550 let from = from.unwrap_or_else(SessionId::user);
551 let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
552 let _ = events.send(AgentEvent::MessageReceived {
553 from,
554 content,
555 });
556 }
557 Message::Ask {
558 request: Request::Notify { content } | Request::Interrupt { content },
559 from,
560 reply,
561 } => {
562 let from = from.unwrap_or_else(SessionId::user);
563 let _ = steer.send(AgentMessage::user_text(tag(&from, &content)));
564 let _ = events.send(AgentEvent::MessageReceived {
565 from,
566 content,
567 });
568 let _ = reply.send(AgentEvent::Ack);
569 }
570 Message::Tell {
571 request: Request::Wake { content },
572 from,
573 } => {
574 let from = from.unwrap_or_else(SessionId::user);
575 let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
576 let _ = events.send(AgentEvent::MessageReceived {
577 from,
578 content,
579 });
580 }
581 Message::Ask {
582 request: Request::Wake { content },
583 from,
584 reply,
585 } => {
586 let from = from.unwrap_or_else(SessionId::user);
587 let _ = follow_up.send(AgentMessage::user_text(tag(&from, &content)));
588 let _ = events.send(AgentEvent::MessageReceived {
589 from,
590 content,
591 });
592 let _ = reply.send(AgentEvent::Ack);
593 }
594 Message::Ask {
599 request: Request::GetHistory,
600 reply,
601 ..
602 } => {
603 let _ = reply.send(AgentEvent::History { messages: side.history() });
604 }
605 Message::Ask {
606 request: Request::Status,
607 reply,
608 ..
609 } => {
610 let _ = reply.send(AgentEvent::Status {
611 last_assistant_text: side.last_assistant_text(),
612 });
613 }
614 Message::Ask {
621 request: Request::Usage,
622 reply,
623 ..
624 } => {
625 let _ = reply.send(usage_reply(&side.history(), side.llm()));
626 }
627 Message::Ask {
628 request: Request::SideAsk { text },
629 reply,
630 ..
631 } => {
632 let side = side.clone();
635 tokio::spawn(async move {
636 let event = match side.side_ask(&text).await {
637 Ok(answer) => AgentEvent::SideAnswer {
638 text: answer.text,
639 usage: answer.usage,
640 },
641 Err(e) => AgentEvent::Error {
642 message: e.to_string(),
643 },
644 };
645 let _ = reply.send(event);
646 });
647 }
648 Message::Ask {
653 from,
654 request:
655 req @ (Request::SetModel { .. }
656 | Request::SetEffort { .. }
657 | Request::SetPlanMode { .. }),
658 reply,
659 } => {
660 let _ = reply.send(AgentEvent::Ack);
661 deferred.push_back(Message::Tell { from, request: req });
662 }
663 other => deferred.push_back(other),
664 }
665 },
666 result = &mut run => break result,
667 Some(event) = sink_rx.recv() => {
668 let _ = events.send(event);
669 }
670 }
671 };
672
673 while let Ok(event) = sink_rx.try_recv() {
676 let _ = events.send(event);
677 }
678
679 if let Err(e) = &result {
683 let _ = events.send(AgentEvent::Error {
684 message: e.to_string(),
685 });
686 }
687 match &result {
691 Ok(StopReason::Error) | Err(_) => {
692 state.send_replace(MemberState::Failed);
693 }
694 Ok(_) => {
695 state.send_replace(MemberState::Done);
696 }
697 }
698 result
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use crate::agent::{Agent, AgentConfig};
705 use crate::compaction::CompactionPolicy;
706 use crate::event::LlmStreamEvent;
707 use crate::hooks::HooksSet;
708 use crate::message::StopReason;
709 use crate::streamfn::{LlmOpts, LlmStream, StreamFn};
710 use crate::tool::{ToolContext, ToolOutput, TypedTool, erased};
711 use futures::StreamExt as _;
712 use serde::Deserialize;
713 use serde_json::json;
714 use std::sync::{Arc, Mutex};
715 use std::time::Duration;
716
717 #[derive(Clone, Default)]
718 struct Recorder {
719 script: Arc<Mutex<VecDeque<Vec<LlmStreamEvent>>>>,
720 calls: Arc<Mutex<Vec<Vec<AgentMessage>>>>,
721 models: Arc<Mutex<Vec<String>>>,
722 }
723
724 impl Recorder {
725 fn push(&self, events: Vec<LlmStreamEvent>) {
726 self.script.lock().unwrap().push_back(events);
727 }
728 fn calls(&self) -> Vec<Vec<AgentMessage>> {
729 self.calls.lock().unwrap().clone()
730 }
731 fn models(&self) -> Vec<String> {
732 self.models.lock().unwrap().clone()
733 }
734 }
735
736 fn fake_stream_fn(rec: &Recorder) -> StreamFn {
737 let rec = rec.clone();
738 Arc::new(
739 move |ctx: &[AgentMessage], _system, _tools, opts: &LlmOpts| {
740 rec.calls.lock().unwrap().push(ctx.to_vec());
741 rec.models.lock().unwrap().push(opts.model.clone());
742 let events = rec.script.lock().unwrap().pop_front().unwrap_or_default();
743 Box::pin(futures::stream::iter(events)) as LlmStream
744 },
745 )
746 }
747
748 fn agent_config(stream_fn: StreamFn, tools: Vec<crate::tool::Tool>) -> AgentConfig {
749 AgentConfig {
750 system: "sys".into(),
751 tools,
752 llm: LlmOpts {
753 model: "m1".into(),
754 ..LlmOpts::default()
755 },
756 stream_fn,
757 hooks: HooksSet::default(),
758 session: None,
759 context: Vec::new(),
760 working_dir: std::path::PathBuf::new(),
761 max_turns: crate::loop_::DEFAULT_MAX_TURNS,
762 parallel_tools: true,
763 compaction: CompactionPolicy::default(),
764 plan_mode: crate::hooks::PlanModeHandle::new(),
765 }
766 }
767
768 async fn wait_for_end(rx: &mut broadcast::Receiver<AgentEvent>) {
770 loop {
771 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
772 .await
773 .expect("an event within the timeout")
774 .expect("the outbox stays open");
775 if matches!(event, AgentEvent::AgentEnd) {
776 return;
777 }
778 }
779 }
780
781 #[tokio::test]
782 async fn submit_streams_events_to_a_subscriber() {
783 let rec = Recorder::default();
784 rec.push(vec![
785 LlmStreamEvent::TextDelta("hello".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.send(Request::Submit { text: "hi".into() }).unwrap();
794
795 let mut saw_text = false;
796 loop {
797 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
798 .await
799 .expect("an event")
800 .expect("open");
801 match event {
802 AgentEvent::MessageUpdate { message } if message.as_text() == "hello" => {
803 saw_text = true;
804 }
805 AgentEvent::AgentEnd => break,
806 _ => {}
807 }
808 }
809 assert!(saw_text, "the streamed text reached the subscriber");
810 }
811
812 #[tokio::test]
813 async fn idle_cancel_does_not_poison_the_next_run() {
814 let rec = Recorder::default();
815 for text in ["one", "two"] {
816 rec.push(vec![
817 LlmStreamEvent::TextDelta(text.into()),
818 LlmStreamEvent::Done {
819 stop_reason: StopReason::Stop,
820 usage: None,
821 },
822 ]);
823 }
824 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
825 let mut rx = handle.subscribe();
826
827 handle.send(Request::Submit { text: "a".into() }).unwrap();
828 wait_for_end(&mut rx).await;
829
830 handle.send(Request::Cancel).unwrap();
832 handle.send(Request::Submit { text: "b".into() }).unwrap();
833
834 let mut saw_two = false;
835 loop {
836 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
837 .await
838 .expect("an event")
839 .expect("open");
840 match event {
841 AgentEvent::MessageUpdate { message } if message.as_text() == "two" => {
842 saw_two = true;
843 }
844 AgentEvent::AgentEnd => break,
845 _ => {}
846 }
847 }
848 assert!(saw_two, "the second run completed despite the idle cancel");
849 }
850
851 #[tokio::test]
852 async fn cancel_aborts_an_in_flight_run() {
853 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
855 let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
856 Box::pin(head.chain(futures::stream::pending())) as LlmStream
857 });
858 let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
859 let mut rx = handle.subscribe();
860 handle.send(Request::Submit { text: "hi".into() }).unwrap();
861
862 loop {
863 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
864 .await
865 .expect("an event")
866 .expect("open");
867 if matches!(event, AgentEvent::MessageUpdate { .. }) {
868 handle.send(Request::Cancel).unwrap();
869 break;
870 }
871 }
872 wait_for_end(&mut rx).await;
874 }
875
876 #[tokio::test]
877 async fn ask_cancel_aborts_an_in_flight_run() {
878 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
882 let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
883 Box::pin(head.chain(futures::stream::pending())) as LlmStream
884 });
885 let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
886 let mut rx = handle.subscribe();
887 handle.send(Request::Submit { text: "hi".into() }).unwrap();
888
889 loop {
890 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
891 .await
892 .expect("an event")
893 .expect("open");
894 if matches!(event, AgentEvent::MessageUpdate { .. }) {
895 let reply = tokio::time::timeout(Duration::from_secs(2), handle.ask(Request::Cancel))
898 .await
899 .expect("the cancel ask is answered mid-run, not deferred")
900 .unwrap();
901 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
902 break;
903 }
904 }
905 wait_for_end(&mut rx).await;
907 }
908
909 #[tokio::test]
910 async fn state_is_running_during_a_turn_then_done_after() {
911 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
914 let head = futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
915 Box::pin(head.chain(futures::stream::pending())) as LlmStream
916 });
917 let handle = SessionActor::spawn(Agent::new(agent_config(stream_fn, vec![])));
918 assert_eq!(handle.state(), MemberState::Idle, "a virgin session is idle");
919
920 let mut state_rx = handle.state_rx();
922 handle.send(Request::Submit { text: "hi".into() }).unwrap();
923 tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
924 .await
925 .expect("a state change")
926 .expect("open");
927 assert_eq!(*state_rx.borrow_and_update(), MemberState::Running);
928 assert_eq!(handle.state(), MemberState::Running, "the run is in flight");
929
930 handle.send(Request::Cancel).unwrap();
931 tokio::time::timeout(Duration::from_secs(5), state_rx.changed())
932 .await
933 .expect("a state change")
934 .expect("open");
935 assert_eq!(
936 *state_rx.borrow_and_update(),
937 MemberState::Done,
938 "an abort ends Done, not Failed"
939 );
940 assert_eq!(handle.state(), MemberState::Done);
941 }
942
943 #[tokio::test]
944 async fn a_fatal_stream_error_ends_failed() {
945 let rec = Recorder::default();
946 rec.push(vec![LlmStreamEvent::Error {
947 message: "boom".into(),
948 fatal: true,
949 }]);
950 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
951 let mut rx = handle.subscribe();
952 handle.send(Request::Submit { text: "hi".into() }).unwrap();
953 wait_for_end(&mut rx).await;
954 assert_eq!(handle.state(), MemberState::Failed);
955 }
956
957 #[derive(Deserialize, schemars::JsonSchema)]
958 struct GateArgs {
959 text: String,
960 }
961
962 struct GateTool {
965 entered: mpsc::UnboundedSender<()>,
966 release: Arc<tokio::sync::Notify>,
967 }
968
969 #[async_trait::async_trait]
970 impl TypedTool for GateTool {
971 type Args = GateArgs;
972 fn name(&self) -> &str {
973 "gate"
974 }
975 fn description(&self) -> &str {
976 "blocks until released"
977 }
978 async fn execute(&self, args: GateArgs, _ctx: &ToolContext) -> ToolOutput {
979 self.entered.send(()).ok();
980 self.release.notified().await;
981 ToolOutput {
982 output: format!("released:{}", args.text),
983 is_error: false,
984 diff: None,
985 path: None,
986 }
987 }
988 }
989
990 #[tokio::test]
991 async fn config_request_during_a_run_is_deferred_to_after_it() {
992 let rec = Recorder::default();
995 rec.push(vec![
996 LlmStreamEvent::ToolCall {
997 id: "c1".into(),
998 name: "gate".into(),
999 arguments: json!({ "text": "hi" }),
1000 },
1001 LlmStreamEvent::Done {
1002 stop_reason: StopReason::ToolUse,
1003 usage: None,
1004 },
1005 ]);
1006 for text in ["turn two", "run two"] {
1007 rec.push(vec![
1008 LlmStreamEvent::TextDelta(text.into()),
1009 LlmStreamEvent::Done {
1010 stop_reason: StopReason::Stop,
1011 usage: None,
1012 },
1013 ]);
1014 }
1015
1016 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1017 let release = Arc::new(tokio::sync::Notify::new());
1018 let gate = erased(GateTool {
1019 entered: entered_tx,
1020 release: release.clone(),
1021 });
1022
1023 let handle =
1024 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1025 let mut rx = handle.subscribe();
1026 handle.send(Request::Submit { text: "a".into() }).unwrap();
1027
1028 entered_rx.recv().await.unwrap(); handle
1030 .send(Request::SetModel { model: "m2".into() })
1031 .unwrap(); release.notify_one();
1033 wait_for_end(&mut rx).await; handle.send(Request::Submit { text: "b".into() }).unwrap();
1036 wait_for_end(&mut rx).await; assert_eq!(
1039 rec.models(),
1040 ["m1", "m1", "m2"],
1041 "the swap waited for the run, then applied to the next one"
1042 );
1043 }
1044
1045 #[tokio::test]
1046 async fn ask_history_returns_the_conversation() {
1047 let rec = Recorder::default();
1048 rec.push(vec![
1049 LlmStreamEvent::TextDelta("hi back".into()),
1050 LlmStreamEvent::Done {
1051 stop_reason: StopReason::Stop,
1052 usage: None,
1053 },
1054 ]);
1055 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1056 let mut rx = handle.subscribe();
1057 handle
1058 .send(Request::Submit {
1059 text: "hello".into(),
1060 })
1061 .unwrap();
1062 wait_for_end(&mut rx).await;
1063
1064 let reply = handle.ask(Request::GetHistory).await.unwrap();
1065 let AgentEvent::History { messages } = reply else {
1066 panic!("expected a History reply, got {reply:?}");
1067 };
1068 assert!(
1069 messages.iter().any(|m| m.as_text() == "hello"),
1070 "the read-back carries the user turn: {messages:?}"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn ask_set_model_replies_ack_and_takes_effect() {
1076 let rec = Recorder::default();
1077 rec.push(vec![
1078 LlmStreamEvent::TextDelta("x".into()),
1079 LlmStreamEvent::Done {
1080 stop_reason: StopReason::Stop,
1081 usage: None,
1082 },
1083 ]);
1084 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1085 let mut rx = handle.subscribe();
1086
1087 let reply = handle
1088 .ask(Request::SetModel { model: "m2".into() })
1089 .await
1090 .unwrap();
1091 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1092
1093 handle.send(Request::Submit { text: "go".into() }).unwrap();
1094 wait_for_end(&mut rx).await;
1095 assert_eq!(rec.models(), ["m2"], "the swap applied to the next run");
1096 }
1097
1098 #[tokio::test]
1099 async fn define_on_a_local_session_is_an_error() {
1100 let rec = Recorder::default();
1101 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1102 let reply = handle
1103 .ask(Request::Define {
1104 name: Some("w1".into()),
1105 model: None,
1106 role: None,
1107 tools: None,
1108 base_url: None,
1109 api_key: None,
1110 read_only: false,
1111 effort: None,
1112 })
1113 .await
1114 .unwrap();
1115 assert!(
1116 matches!(&reply, AgentEvent::Error { message } if message.contains("served")),
1117 "{reply:?}"
1118 );
1119 }
1120
1121 #[tokio::test]
1122 async fn ask_compact_with_nothing_to_do_replies_ack() {
1123 let handle = SessionActor::spawn(Agent::new(agent_config(
1124 fake_stream_fn(&Recorder::default()),
1125 vec![],
1126 )));
1127 let reply = handle
1128 .ask(Request::Compact { instructions: None })
1129 .await
1130 .unwrap();
1131 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1132 }
1133
1134 #[tokio::test]
1135 async fn ask_submit_replies_stopped_with_the_reason() {
1136 let rec = Recorder::default();
1137 rec.push(vec![
1138 LlmStreamEvent::TextDelta("done".into()),
1139 LlmStreamEvent::Done {
1140 stop_reason: StopReason::Stop,
1141 usage: None,
1142 },
1143 ]);
1144 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1145 let reply = handle
1146 .ask(Request::Submit { text: "hi".into() })
1147 .await
1148 .unwrap();
1149 assert!(
1150 matches!(
1151 reply,
1152 AgentEvent::Stopped {
1153 stop_reason: StopReason::Stop
1154 }
1155 ),
1156 "{reply:?}"
1157 );
1158 }
1159
1160 #[tokio::test]
1161 async fn notify_appends_without_starting_a_turn() {
1162 let rec = Recorder::default();
1163 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1164 let mut rx = handle.subscribe();
1165
1166 let reply = handle
1167 .ask(Request::Notify {
1168 content: "ping".into(),
1169 })
1170 .await
1171 .unwrap();
1172 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1173
1174 let surfaced = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1176 .await
1177 .expect("an event")
1178 .expect("open");
1179 assert!(
1180 matches!(&surfaced, AgentEvent::MessageReceived { content, .. } if content == "ping"),
1181 "{surfaced:?}"
1182 );
1183
1184 let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1186 panic!("expected History");
1187 };
1188 assert!(
1189 messages
1190 .iter()
1191 .any(|m| m.as_text() == "[message from user]\nping"),
1192 "{messages:?}"
1193 );
1194
1195 assert!(rec.calls().is_empty(), "notify must not run the loop");
1197 }
1198
1199 #[tokio::test]
1200 async fn wake_starts_a_run_when_idle() {
1201 let rec = Recorder::default();
1202 rec.push(vec![
1203 LlmStreamEvent::TextDelta("acted".into()),
1204 LlmStreamEvent::Done {
1205 stop_reason: StopReason::Stop,
1206 usage: None,
1207 },
1208 ]);
1209 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1210 let mut rx = handle.subscribe();
1211
1212 let reply = handle
1213 .ask(Request::Wake {
1214 content: "do the task".into(),
1215 })
1216 .await
1217 .unwrap();
1218 assert!(
1219 matches!(
1220 reply,
1221 AgentEvent::Stopped {
1222 stop_reason: StopReason::Stop
1223 }
1224 ),
1225 "{reply:?}"
1226 );
1227 wait_for_end(&mut rx).await;
1228
1229 let calls = rec.calls();
1230 assert_eq!(calls.len(), 1, "Wake ran the loop once");
1231 assert!(
1232 calls[0]
1233 .iter()
1234 .any(|m| m.as_text() == "[message from user]\ndo the task"),
1235 "the tagged task is the prompt: {:?}",
1236 calls[0]
1237 );
1238 }
1239
1240 struct InboundPolicy;
1241
1242 #[async_trait::async_trait]
1243 impl crate::hooks::Hooks for InboundPolicy {
1244 async fn before_inbound(
1245 &self,
1246 _from: Option<&SessionId>,
1247 request: &mut Request,
1248 ) -> Option<String> {
1249 let Request::Notify { content } = request else {
1250 return None;
1251 };
1252 if content.starts_with("drop:") {
1253 return Some("policy".into());
1254 }
1255 *content = format!("{content}!");
1256 None
1257 }
1258 }
1259
1260 #[tokio::test]
1261 async fn before_inbound_drops_and_rewrites_a_notify() {
1262 let rec = Recorder::default();
1263 let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1264 cfg.hooks = HooksSet::one(Arc::new(InboundPolicy));
1265 let handle = SessionActor::spawn(Agent::new(cfg));
1266
1267 let reply = handle
1269 .ask(Request::Notify {
1270 content: "drop: me".into(),
1271 })
1272 .await
1273 .unwrap();
1274 assert!(
1275 matches!(&reply, AgentEvent::Error { message } if message.contains("policy")),
1276 "{reply:?}"
1277 );
1278
1279 let reply = handle
1281 .ask(Request::Notify {
1282 content: "keep me".into(),
1283 })
1284 .await
1285 .unwrap();
1286 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1287
1288 let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1289 panic!("expected History");
1290 };
1291 let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1292 assert!(
1293 !texts.iter().any(|t| t == "drop: me"),
1294 "the dropped message never landed: {texts:?}"
1295 );
1296 assert!(
1297 texts.iter().any(|t| t == "[message from user]\nkeep me!"),
1298 "the rewrite landed: {texts:?}"
1299 );
1300 }
1301
1302 #[tokio::test]
1303 async fn interrupt_reaches_the_next_turn_while_a_tool_is_blocked() {
1304 let rec = Recorder::default();
1305 rec.push(vec![
1306 LlmStreamEvent::ToolCall {
1307 id: "c1".into(),
1308 name: "gate".into(),
1309 arguments: json!({ "text": "hi" }),
1310 },
1311 LlmStreamEvent::Done {
1312 stop_reason: StopReason::ToolUse,
1313 usage: None,
1314 },
1315 ]);
1316 rec.push(vec![
1317 LlmStreamEvent::TextDelta("after interrupt".into()),
1318 LlmStreamEvent::Done {
1319 stop_reason: StopReason::Stop,
1320 usage: None,
1321 },
1322 ]);
1323
1324 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1325 let release = Arc::new(tokio::sync::Notify::new());
1326 let gate = erased(GateTool {
1327 entered: entered_tx,
1328 release: release.clone(),
1329 });
1330
1331 let handle =
1332 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1333 let mut rx = handle.subscribe();
1334 handle.send(Request::Submit { text: "hi".into() }).unwrap();
1335
1336 entered_rx.recv().await.unwrap(); handle
1338 .send(Request::Interrupt {
1339 content: "mid-run interrupt".into(),
1340 })
1341 .unwrap();
1342 release.notify_one();
1343
1344 wait_for_end(&mut rx).await;
1345
1346 let calls = rec.calls();
1347 assert_eq!(calls.len(), 2, "two stream calls: the tool turn and the next");
1348 assert!(
1349 calls[1]
1350 .iter()
1351 .any(|m| matches!(m, AgentMessage::User { .. })
1352 && m.as_text() == "[message from user]\nmid-run interrupt"),
1353 "the interrupt drained into the next turn: {:?}",
1354 calls[1]
1355 );
1356 }
1357
1358 #[tokio::test]
1359 async fn send_from_attributes_the_sender() {
1360 let rec = Recorder::default();
1361 let handle = SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![])));
1362 let mut rx = handle.subscribe();
1363
1364 handle
1365 .send_from(
1366 SessionId::agent("b"),
1367 Request::Notify {
1368 content: "ping".into(),
1369 },
1370 )
1371 .unwrap();
1372
1373 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1374 .await
1375 .expect("an event")
1376 .expect("open");
1377 assert!(
1378 matches!(&event, AgentEvent::MessageReceived { from, content }
1379 if from.as_str() == "agent:b" && content == "ping"),
1380 "{event:?}"
1381 );
1382 }
1383
1384 struct SenderPolicy;
1385
1386 #[async_trait::async_trait]
1387 impl crate::hooks::Hooks for SenderPolicy {
1388 async fn before_inbound(
1389 &self,
1390 from: Option<&SessionId>,
1391 _request: &mut Request,
1392 ) -> Option<String> {
1393 (from.map(SessionId::as_str) == Some("agent:evil")).then(|| "blocked sender".into())
1394 }
1395 }
1396
1397 #[tokio::test]
1398 async fn before_inbound_sees_the_sender() {
1399 let rec = Recorder::default();
1400 let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1401 cfg.hooks = HooksSet::one(Arc::new(SenderPolicy));
1402 let handle = SessionActor::spawn(Agent::new(cfg));
1403
1404 let reply = handle
1406 .ask_from(
1407 SessionId::agent("evil"),
1408 Request::Notify {
1409 content: "let me in".into(),
1410 },
1411 )
1412 .await
1413 .unwrap();
1414 assert!(
1415 matches!(&reply, AgentEvent::Error { message } if message.contains("blocked sender")),
1416 "{reply:?}"
1417 );
1418
1419 let reply = handle
1421 .ask_from(
1422 SessionId::agent("friend"),
1423 Request::Notify {
1424 content: "hello".into(),
1425 },
1426 )
1427 .await
1428 .unwrap();
1429 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1430
1431 let AgentEvent::History { messages } = handle.ask(Request::GetHistory).await.unwrap() else {
1432 panic!("expected History");
1433 };
1434 let texts: Vec<String> = messages.iter().map(|m| m.as_text()).collect();
1435 assert!(
1436 texts
1437 .iter()
1438 .any(|t| t == "[message from agent:friend]\nhello"),
1439 "{texts:?}"
1440 );
1441 assert!(!texts.iter().any(|t| t.contains("let me in")), "{texts:?}");
1442 }
1443
1444 #[tokio::test]
1445 async fn side_ask_is_answered_mid_run() {
1446 let rec = Recorder::default();
1449 rec.push(vec![
1450 LlmStreamEvent::ToolCall {
1451 id: "c1".into(),
1452 name: "gate".into(),
1453 arguments: json!({ "text": "hi" }),
1454 },
1455 LlmStreamEvent::Done {
1456 stop_reason: StopReason::ToolUse,
1457 usage: None,
1458 },
1459 ]);
1460 rec.push(vec![
1462 LlmStreamEvent::TextDelta("aside".into()),
1463 LlmStreamEvent::Done {
1464 stop_reason: StopReason::Stop,
1465 usage: None,
1466 },
1467 ]);
1468 rec.push(vec![
1470 LlmStreamEvent::TextDelta("done".into()),
1471 LlmStreamEvent::Done {
1472 stop_reason: StopReason::Stop,
1473 usage: None,
1474 },
1475 ]);
1476
1477 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1478 let release = Arc::new(tokio::sync::Notify::new());
1479 let gate = erased(GateTool {
1480 entered: entered_tx,
1481 release: release.clone(),
1482 });
1483 let handle =
1484 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1485 let mut rx = handle.subscribe();
1486 handle.send(Request::Submit { text: "a".into() }).unwrap();
1487
1488 entered_rx.recv().await.unwrap(); let reply = tokio::time::timeout(
1492 Duration::from_secs(5),
1493 handle.ask(Request::SideAsk { text: "why?".into() }),
1494 )
1495 .await
1496 .expect("the side ask must not wait for the run")
1497 .unwrap();
1498 assert!(
1499 matches!(&reply, AgentEvent::SideAnswer { text, .. } if text == "aside"),
1500 "{reply:?}"
1501 );
1502
1503 release.notify_one();
1504 wait_for_end(&mut rx).await;
1505 }
1506
1507 #[tokio::test]
1508 async fn get_history_is_answered_mid_run_from_the_snapshot() {
1509 let rec = Recorder::default();
1510 rec.push(vec![
1511 LlmStreamEvent::ToolCall {
1512 id: "c1".into(),
1513 name: "gate".into(),
1514 arguments: json!({ "text": "hi" }),
1515 },
1516 LlmStreamEvent::Done {
1517 stop_reason: StopReason::ToolUse,
1518 usage: None,
1519 },
1520 ]);
1521 rec.push(vec![
1522 LlmStreamEvent::TextDelta("done".into()),
1523 LlmStreamEvent::Done {
1524 stop_reason: StopReason::Stop,
1525 usage: None,
1526 },
1527 ]);
1528
1529 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1530 let release = Arc::new(tokio::sync::Notify::new());
1531 let gate = erased(GateTool {
1532 entered: entered_tx,
1533 release: release.clone(),
1534 });
1535 let handle =
1536 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1537 let mut rx = handle.subscribe();
1538 handle.send(Request::Submit { text: "a".into() }).unwrap();
1539
1540 entered_rx.recv().await.unwrap();
1541
1542 let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::GetHistory))
1543 .await
1544 .expect("history must not wait for the run")
1545 .unwrap();
1546 let AgentEvent::History { messages } = reply else {
1547 panic!("expected History, got {reply:?}");
1548 };
1549 assert!(
1550 messages.iter().any(|m| m.as_text() == "a"),
1551 "the mid-run snapshot carries the submit: {messages:?}"
1552 );
1553
1554 release.notify_one();
1555 wait_for_end(&mut rx).await;
1556 }
1557
1558 #[tokio::test]
1559 async fn a_deferred_command_is_acked_before_the_run_ends() {
1560 let rec = Recorder::default();
1561 rec.push(vec![
1562 LlmStreamEvent::ToolCall {
1563 id: "c1".into(),
1564 name: "gate".into(),
1565 arguments: json!({ "text": "hi" }),
1566 },
1567 LlmStreamEvent::Done {
1568 stop_reason: StopReason::ToolUse,
1569 usage: None,
1570 },
1571 ]);
1572 for text in ["turn two", "run two"] {
1573 rec.push(vec![
1574 LlmStreamEvent::TextDelta(text.into()),
1575 LlmStreamEvent::Done {
1576 stop_reason: StopReason::Stop,
1577 usage: None,
1578 },
1579 ]);
1580 }
1581
1582 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1583 let release = Arc::new(tokio::sync::Notify::new());
1584 let gate = erased(GateTool {
1585 entered: entered_tx,
1586 release: release.clone(),
1587 });
1588 let handle =
1589 SessionActor::spawn(Agent::new(agent_config(fake_stream_fn(&rec), vec![gate])));
1590 let mut rx = handle.subscribe();
1591 handle.send(Request::Submit { text: "a".into() }).unwrap();
1592
1593 entered_rx.recv().await.unwrap();
1594
1595 let reply = tokio::time::timeout(
1597 Duration::from_secs(5),
1598 handle.ask(Request::SetModel { model: "m2".into() }),
1599 )
1600 .await
1601 .expect("a deferred command must be acked, not hang")
1602 .unwrap();
1603 assert!(matches!(reply, AgentEvent::Ack), "{reply:?}");
1604
1605 release.notify_one();
1606 wait_for_end(&mut rx).await; handle.send(Request::Submit { text: "b".into() }).unwrap();
1609 wait_for_end(&mut rx).await; assert_eq!(
1611 rec.models(),
1612 ["m1", "m1", "m2"],
1613 "the swap applied after the run, not before"
1614 );
1615 }
1616
1617 #[tokio::test]
1618 async fn usage_reply_is_answered_mid_run_without_waiting() {
1619 let rec = Recorder::default();
1623 rec.push(vec![
1624 LlmStreamEvent::ToolCall {
1625 id: "c1".into(),
1626 name: "gate".into(),
1627 arguments: json!({ "text": "hi" }),
1628 },
1629 LlmStreamEvent::Done {
1630 stop_reason: StopReason::ToolUse,
1631 usage: None,
1632 },
1633 ]);
1634 rec.push(vec![
1635 LlmStreamEvent::TextDelta("done".into()),
1636 LlmStreamEvent::Done {
1637 stop_reason: StopReason::Stop,
1638 usage: None,
1639 },
1640 ]);
1641
1642 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1643 let release = Arc::new(tokio::sync::Notify::new());
1644 let gate = erased(GateTool {
1645 entered: entered_tx,
1646 release: release.clone(),
1647 });
1648 let mut cfg = agent_config(fake_stream_fn(&rec), vec![gate]);
1651 cfg.llm.base_url = Some("https://opencode.ai/zen/go/v1".into());
1652 cfg.llm.model = "deepseek-v4.1-flash".into();
1653 cfg.context = vec![AgentMessage::Assistant {
1654 content: vec![crate::message::ContentBlock::Text {
1655 text: "seeded work".into(),
1656 }],
1657 stop_reason: StopReason::Stop,
1658 usage: Some(crate::message::Usage {
1659 input_tokens: 1_000,
1660 output_tokens: 500,
1661 cache_read_tokens: Some(400),
1662 cache_write_tokens: None,
1663 }),
1664 model: Some("deepseek-v4.1-flash".into()),
1665 }];
1666 let handle = SessionActor::spawn(Agent::new(cfg));
1667 let mut rx = handle.subscribe();
1668 handle.send(Request::Submit { text: "a".into() }).unwrap();
1669
1670 entered_rx.recv().await.unwrap(); let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Usage))
1673 .await
1674 .expect("usage must not wait for the run")
1675 .unwrap();
1676 let AgentEvent::Usage { stats } = reply else {
1677 panic!("expected Usage, got {reply:?}");
1678 };
1679 assert_eq!(stats.turns, 1);
1680 assert!(stats.cost.is_some(), "a priced model reports a cost mid-run");
1681
1682 release.notify_one();
1683 wait_for_end(&mut rx).await;
1684 }
1685
1686 #[tokio::test]
1687 async fn an_idle_usage_reply_folds_the_live_transcript() {
1688 let rec = Recorder::default();
1690 let mut cfg = agent_config(fake_stream_fn(&rec), vec![]);
1691 cfg.llm.base_url = None; cfg.context = vec![AgentMessage::Assistant {
1693 content: vec![crate::message::ContentBlock::Text {
1694 text: "done".into(),
1695 }],
1696 stop_reason: StopReason::Stop,
1697 usage: Some(crate::message::Usage {
1698 input_tokens: 10,
1699 output_tokens: 20,
1700 cache_read_tokens: None,
1701 cache_write_tokens: None,
1702 }),
1703 model: None,
1704 }];
1705 let handle = SessionActor::spawn(Agent::new(cfg));
1706
1707 let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Usage))
1708 .await
1709 .expect("an idle usage read answers")
1710 .unwrap();
1711 let AgentEvent::Usage { stats } = reply else {
1712 panic!("expected Usage, got {reply:?}");
1713 };
1714 assert_eq!(stats.turns, 1);
1715 assert_eq!(stats.input_tokens, 10);
1716 assert_eq!(stats.cost, None, "no base_url ⇒ no price, never $0");
1717 }
1718
1719 #[tokio::test]
1720 async fn ask_status_is_answered_mid_run_from_the_snapshot() {
1721 let rec = Recorder::default();
1724 rec.push(vec![
1725 LlmStreamEvent::ToolCall {
1726 id: "c1".into(),
1727 name: "gate".into(),
1728 arguments: json!({ "text": "hi" }),
1729 },
1730 LlmStreamEvent::Done {
1731 stop_reason: StopReason::ToolUse,
1732 usage: None,
1733 },
1734 ]);
1735 rec.push(vec![
1736 LlmStreamEvent::TextDelta("done".into()),
1737 LlmStreamEvent::Done {
1738 stop_reason: StopReason::Stop,
1739 usage: None,
1740 },
1741 ]);
1742
1743 let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<()>();
1744 let release = Arc::new(tokio::sync::Notify::new());
1745 let gate = erased(GateTool {
1746 entered: entered_tx,
1747 release: release.clone(),
1748 });
1749 let mut cfg = agent_config(fake_stream_fn(&rec), vec![gate]);
1751 cfg.context = vec![AgentMessage::Assistant {
1752 content: vec![crate::message::ContentBlock::Text {
1753 text: "seeded work".into(),
1754 }],
1755 stop_reason: StopReason::Stop,
1756 usage: None,
1757 model: None,
1758 }];
1759 let handle = SessionActor::spawn(Agent::new(cfg));
1760 let mut rx = handle.subscribe();
1761 handle.send(Request::Submit { text: "a".into() }).unwrap();
1762
1763 entered_rx.recv().await.unwrap(); let reply = tokio::time::timeout(Duration::from_secs(5), handle.ask(Request::Status))
1766 .await
1767 .expect("status must not wait for the run")
1768 .unwrap();
1769 let AgentEvent::Status { last_assistant_text } = reply else {
1770 panic!("expected Status, got {reply:?}");
1771 };
1772 assert_eq!(last_assistant_text.as_deref(), Some("seeded work"));
1773
1774 release.notify_one();
1775 wait_for_end(&mut rx).await;
1776 }
1777}