1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13use bytes::Bytes;
14use tokio::sync::{broadcast, mpsc};
15use tokio_util::sync::CancellationToken;
16
17use gemini_genai_rs::prelude::{SessionEvent, SessionPhase};
18use gemini_genai_rs::session::SessionWriter;
19
20use crate::state::State;
21use crate::tool::ToolDispatcher;
22
23use super::background_tool::BackgroundToolTracker;
24use super::callbacks::EventCallbacks;
25use super::computed::ComputedRegistry;
26use super::context_writer::PendingContext;
27use super::control_plane::run_control_lane;
28use super::events::LiveEvent;
29use super::extractor::TurnExtractor;
30use super::needs::NeedsFulfillment;
31use super::persistence::SessionPersistence;
32use super::phase::PhaseMachine;
33use super::session_signals::SessionSignals;
34use super::soft_turn::SoftTurnDetector;
35use super::steering::{ContextDelivery, SteeringMode};
36use super::telemetry::SessionTelemetry;
37use super::temporal::TemporalRegistry;
38use super::watcher::WatcherRegistry;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum Delivery {
52 #[default]
58 Lossless,
59 LossyDropNewest,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct DeliveryConfig {
83 pub audio: Delivery,
85 pub text: Delivery,
87 pub transcript: Delivery,
90 pub thought: Delivery,
92 pub vad: Delivery,
94 pub phase: Delivery,
96}
97
98impl Default for DeliveryConfig {
99 fn default() -> Self {
100 Self {
101 audio: Delivery::Lossless,
102 text: Delivery::Lossless,
103 transcript: Delivery::Lossless,
104 thought: Delivery::Lossless,
105 vad: Delivery::Lossless,
106 phase: Delivery::Lossless,
107 }
108 }
109}
110
111impl DeliveryConfig {
112 pub fn lossless() -> Self {
115 Self::default()
116 }
117
118 pub fn audio(mut self, d: Delivery) -> Self {
120 self.audio = d;
121 self
122 }
123
124 pub fn text(mut self, d: Delivery) -> Self {
126 self.text = d;
127 self
128 }
129
130 pub fn transcript(mut self, d: Delivery) -> Self {
132 self.transcript = d;
133 self
134 }
135
136 pub fn thought(mut self, d: Delivery) -> Self {
138 self.thought = d;
139 self
140 }
141
142 pub fn vad(mut self, d: Delivery) -> Self {
144 self.vad = d;
145 self
146 }
147
148 pub fn phase(mut self, d: Delivery) -> Self {
150 self.phase = d;
151 self
152 }
153}
154
155#[derive(Debug, Default)]
161pub(crate) struct DroppedFrames {
162 pub audio: AtomicU64,
163 pub text: AtomicU64,
164 pub transcript: AtomicU64,
165 pub thought: AtomicU64,
166 pub vad: AtomicU64,
167 pub phase: AtomicU64,
168}
169
170impl DroppedFrames {
171 #[cfg(test)]
176 pub fn total(&self) -> u64 {
177 self.audio.load(Ordering::Relaxed)
178 + self.text.load(Ordering::Relaxed)
179 + self.transcript.load(Ordering::Relaxed)
180 + self.thought.load(Ordering::Relaxed)
181 + self.vad.load(Ordering::Relaxed)
182 + self.phase.load(Ordering::Relaxed)
183 }
184}
185
186async fn deliver_fast(
195 tx: &mpsc::Sender<FastEvent>,
196 ev: FastEvent,
197 policy: Delivery,
198 dropped: &AtomicU64,
199) {
200 match policy {
201 Delivery::Lossless => {
202 let _ = tx.send(ev).await;
203 }
204 Delivery::LossyDropNewest => {
205 if let Err(mpsc::error::TrySendError::Full(_)) = tx.try_send(ev) {
206 dropped.fetch_add(1, Ordering::Relaxed);
207 }
208 }
211 }
212}
213
214pub(crate) enum FastEvent {
216 Audio(Bytes),
217 Text(String),
218 TextComplete(String),
219 InputTranscript(String),
220 OutputTranscript(String),
221 Thought(String),
222 VadStart,
223 VadEnd,
224 Phase(SessionPhase),
225 Interrupted,
227}
228
229pub(crate) enum ControlEvent {
231 ToolCall(Vec<gemini_genai_rs::prelude::FunctionCall>),
232 ToolCallCancelled(Vec<String>),
233 ToolCompleted {
237 call_id: String,
239 name: String,
241 ok: bool,
243 },
244 Interrupted,
245 TurnComplete,
246 GenerationComplete,
248 GoAway(Option<String>),
249 Connected,
250 Disconnected(Option<String>),
251 SessionResumeUpdate(gemini_genai_rs::session::ResumeInfo),
252 Error(String),
253 InputTranscript(String),
255 OutputTranscript(String),
256}
257
258pub(crate) struct SharedState {
260 pub interrupted: AtomicBool,
262 pub barge_in: parking_lot::Mutex<CancellationToken>,
270 pub resume_handle: parking_lot::Mutex<Option<String>>,
272 pub last_instruction: parking_lot::Mutex<Option<String>>,
274 pub last_context: parking_lot::Mutex<Vec<String>>,
283 pub pending_context: Option<Arc<PendingContext>>,
285 pub delivery: DeliveryConfig,
287 pub dropped: DroppedFrames,
289}
290
291pub(crate) struct ControlPlaneConfig {
297 pub base_instruction: Option<String>,
307 pub soft_turn: Option<SoftTurnDetector>,
309 pub steering_mode: SteeringMode,
311 pub context_delivery: ContextDelivery,
315 pub needs_fulfillment: Option<NeedsFulfillment>,
317 pub persistence: Option<Arc<dyn SessionPersistence>>,
319 pub session_id: Option<String>,
321 pub tool_advisory: bool,
323 pub pending_context: Option<Arc<PendingContext>>,
327 pub middleware: Arc<crate::middleware::MiddlewareChain>,
330 pub flow: Option<crate::flow::SharedFlowMonitor>,
336 pub delivery: DeliveryConfig,
339}
340
341impl Default for ControlPlaneConfig {
342 fn default() -> Self {
343 Self {
344 base_instruction: None,
345 soft_turn: None,
346 steering_mode: SteeringMode::default(),
347 context_delivery: ContextDelivery::default(),
348 needs_fulfillment: None,
349 persistence: None,
350 session_id: None,
351 tool_advisory: true,
352 pending_context: None,
353 middleware: Arc::new(crate::middleware::MiddlewareChain::new()),
354 flow: None,
355 delivery: DeliveryConfig::default(),
356 }
357 }
358}
359
360#[allow(
361 clippy::too_many_arguments,
362 reason = "lane spawn site: parameters are the owned subsystem handles split between the fast and control lanes"
363)]
364pub(crate) fn spawn_event_processor(
365 mut event_rx: broadcast::Receiver<SessionEvent>,
366 callbacks: Arc<EventCallbacks>,
367 dispatcher: Option<Arc<ToolDispatcher>>,
368 writer: Arc<dyn SessionWriter>,
369 extractors: Vec<Arc<dyn TurnExtractor>>,
370 state: State,
371 computed: Option<ComputedRegistry>,
372 phase_machine: Option<tokio::sync::Mutex<PhaseMachine>>,
373 watchers: Option<WatcherRegistry>,
374 temporal: Option<Arc<TemporalRegistry>>,
375 background_tracker: Option<Arc<BackgroundToolTracker>>,
376 execution_modes: std::collections::HashMap<String, super::background_tool::ToolExecutionMode>,
377 control_plane: ControlPlaneConfig,
378 live_event_tx: broadcast::Sender<LiveEvent>,
379) -> (
380 tokio::task::JoinHandle<()>,
381 tokio::task::JoinHandle<()>,
382 mpsc::WeakSender<ControlEvent>,
383) {
384 let shared = Arc::new(SharedState {
385 interrupted: AtomicBool::new(false),
386 barge_in: parking_lot::Mutex::new(CancellationToken::new()),
387 resume_handle: parking_lot::Mutex::new(None),
388 last_instruction: parking_lot::Mutex::new(None),
389 last_context: parking_lot::Mutex::new(Vec::new()),
390 pending_context: control_plane.pending_context.clone(),
391 delivery: control_plane.delivery,
392 dropped: DroppedFrames::default(),
393 });
394
395 let timer_cancel = CancellationToken::new();
396
397 let (fast_tx, fast_rx) = mpsc::channel::<FastEvent>(512);
408 let (ctrl_tx, ctrl_rx) = mpsc::channel::<ControlEvent>(512);
409
410 let fast_tx_clone = fast_tx.clone();
413 let ctrl_tx_clone = ctrl_tx.clone();
414 let shared_clone = shared.clone();
415 tokio::spawn(async move {
416 loop {
417 match event_rx.recv().await {
418 Ok(event) => {
419 let terminal = matches!(event, SessionEvent::Disconnected(_));
427 route_event(event, &fast_tx_clone, &ctrl_tx_clone, &shared_clone).await;
428 if terminal {
429 break;
430 }
431 }
432 Err(broadcast::error::RecvError::Lagged(n)) => {
433 #[cfg(feature = "tracing-support")]
434 tracing::warn!(skipped = n, "Event processor lagged, skipped events");
435 let _ = n;
436 }
437 Err(broadcast::error::RecvError::Closed) => break,
438 }
439 }
440 });
441
442 let fast_callbacks = callbacks.clone();
444 let fast_shared = shared.clone();
445 let fast_event_tx = live_event_tx.clone();
446 let fast_handle = tokio::spawn(async move {
447 run_fast_lane(fast_rx, fast_callbacks, fast_shared, fast_event_tx).await;
448 });
449
450 let timer_temporal = temporal.clone();
452 let timer_state = state.clone();
453 let timer_writer = writer.clone();
454
455 let ctrl_callbacks = callbacks;
457 let ctrl_shared = shared;
458 let ctrl_timer_cancel = timer_cancel.clone();
459 let ctrl_tx_weak = ctrl_tx.downgrade();
464 let ctrl_handle = tokio::spawn(async move {
465 run_control_lane(
466 ctrl_rx,
467 ctrl_tx_weak,
468 ctrl_callbacks,
469 dispatcher,
470 writer,
471 ctrl_shared,
472 extractors,
473 state,
474 computed,
475 phase_machine,
476 watchers,
477 temporal,
478 background_tracker,
479 execution_modes,
480 control_plane,
481 live_event_tx,
482 )
483 .await;
484 ctrl_timer_cancel.cancel();
485 });
486
487 if let Some(ref temporal_ref) = timer_temporal {
489 if temporal_ref.needs_timer() {
490 let t = temporal_ref.clone();
491 let cancel = timer_cancel.clone();
492 tokio::spawn(async move {
493 let mut interval = tokio::time::interval(Duration::from_millis(500));
494 loop {
495 tokio::select! {
496 _ = cancel.cancelled() => break,
497 _ = interval.tick() => {
498 for action in t.check_all(&timer_state, None, &timer_writer) {
499 tokio::spawn(action);
500 }
501 }
502 }
503 }
504 });
505 }
506 }
507
508 let ctrl_tx_handle = ctrl_tx.downgrade();
513
514 (fast_handle, ctrl_handle, ctrl_tx_handle)
515}
516
517pub(crate) fn spawn_telemetry_lane(
523 mut telem_rx: broadcast::Receiver<SessionEvent>,
524 signals: SessionSignals,
525 telemetry: Arc<SessionTelemetry>,
526 cancel: CancellationToken,
527 on_usage: Option<super::callbacks::UsageCallback>,
528) -> tokio::task::JoinHandle<()> {
529 tokio::spawn(async move {
530 let mut debounce = tokio::time::interval(Duration::from_millis(100));
531 debounce.tick().await;
533 loop {
534 tokio::select! {
535 biased;
536 result = telem_rx.recv() => {
537 match result {
538 Ok(event) => {
539 match &event {
541 SessionEvent::AudioData(data) => {
542 telemetry.record_audio_out(data.len());
543 }
544 SessionEvent::TextDelta(_) => {
545 telemetry.record_text_out();
546 }
547 SessionEvent::VoiceActivityEnd => {
548 telemetry.record_vad_end();
549 }
550 SessionEvent::Interrupted => {
551 telemetry.record_interruption();
552 }
553 SessionEvent::TurnComplete => {
554 telemetry.record_turn_complete();
555 }
556 SessionEvent::VoiceActivityStart => {
557 telemetry.mark_turn_start();
558 }
559 SessionEvent::Usage(ref usage) => {
560 telemetry.record_usage(
561 usage.total_token_count,
562 usage.prompt_token_count,
563 usage.response_token_count,
564 usage.cached_content_token_count,
565 usage.thoughts_token_count,
566 );
567 if let Some(cb) = &on_usage {
568 cb(usage);
569 }
570 }
571 _ => {}
572 }
573 signals.on_event(&event);
575 }
576 Err(broadcast::error::RecvError::Lagged(n)) => {
577 #[cfg(feature = "tracing-support")]
578 tracing::warn!(skipped = n, "Telemetry lane lagged");
579 let _ = n;
580 }
581 Err(broadcast::error::RecvError::Closed) => break,
582 }
583 }
584 _ = debounce.tick() => {
585 signals.flush_timing();
587 }
588 _ = cancel.cancelled() => break,
589 }
590 }
591 })
592}
593
594async fn route_event(
596 event: SessionEvent,
597 fast_tx: &mpsc::Sender<FastEvent>,
598 ctrl_tx: &mpsc::Sender<ControlEvent>,
599 shared: &SharedState,
600) {
601 let delivery = &shared.delivery;
602 let dropped = &shared.dropped;
603 match event {
604 SessionEvent::AudioData(data) => {
606 deliver_fast(
607 fast_tx,
608 FastEvent::Audio(data),
609 delivery.audio,
610 &dropped.audio,
611 )
612 .await;
613 }
614 SessionEvent::TextDelta(text) => {
615 deliver_fast(fast_tx, FastEvent::Text(text), delivery.text, &dropped.text).await;
616 }
617 SessionEvent::TextComplete(text) => {
618 deliver_fast(
619 fast_tx,
620 FastEvent::TextComplete(text),
621 delivery.text,
622 &dropped.text,
623 )
624 .await;
625 }
626 SessionEvent::InputTranscription(text) => {
629 deliver_fast(
630 fast_tx,
631 FastEvent::InputTranscript(text.clone()),
632 delivery.transcript,
633 &dropped.transcript,
634 )
635 .await;
636 let _ = ctrl_tx.send(ControlEvent::InputTranscript(text)).await;
637 }
638 SessionEvent::OutputTranscription(text) => {
639 deliver_fast(
640 fast_tx,
641 FastEvent::OutputTranscript(text.clone()),
642 delivery.transcript,
643 &dropped.transcript,
644 )
645 .await;
646 let _ = ctrl_tx.send(ControlEvent::OutputTranscript(text)).await;
647 }
648 SessionEvent::Thought(text) => {
649 deliver_fast(
650 fast_tx,
651 FastEvent::Thought(text),
652 delivery.thought,
653 &dropped.thought,
654 )
655 .await;
656 }
657 SessionEvent::VoiceActivityStart => {
658 deliver_fast(fast_tx, FastEvent::VadStart, delivery.vad, &dropped.vad).await;
659 }
660 SessionEvent::VoiceActivityEnd => {
661 deliver_fast(fast_tx, FastEvent::VadEnd, delivery.vad, &dropped.vad).await;
662 }
663 SessionEvent::PhaseChanged(phase) => {
664 deliver_fast(
665 fast_tx,
666 FastEvent::Phase(phase),
667 delivery.phase,
668 &dropped.phase,
669 )
670 .await;
671 }
672 SessionEvent::SessionResumeUpdate(info) => {
673 *shared.resume_handle.lock() = Some(info.handle.clone());
674 let _ = ctrl_tx.send(ControlEvent::SessionResumeUpdate(info)).await;
675 }
676 SessionEvent::GenerationComplete => {
677 let _ = ctrl_tx.send(ControlEvent::GenerationComplete).await;
678 }
679
680 SessionEvent::ToolCall(calls) => {
682 let _ = ctrl_tx.send(ControlEvent::ToolCall(calls)).await;
683 }
684 SessionEvent::ToolCallCancelled(ids) => {
685 let _ = ctrl_tx.send(ControlEvent::ToolCallCancelled(ids)).await;
686 }
687 SessionEvent::Interrupted => {
688 shared.interrupted.store(true, Ordering::Release);
690 shared.barge_in.lock().cancel();
694 let _ = fast_tx.send(FastEvent::Interrupted).await;
695 let _ = ctrl_tx.send(ControlEvent::Interrupted).await;
696 }
697 SessionEvent::TurnComplete => {
698 let _ = ctrl_tx.send(ControlEvent::TurnComplete).await;
699 }
700 SessionEvent::Usage(_) => {}
702 SessionEvent::GoAway(time_left) => {
703 let _ = ctrl_tx.send(ControlEvent::GoAway(time_left)).await;
704 }
705 SessionEvent::Connected => {
706 let _ = ctrl_tx.send(ControlEvent::Connected).await;
707 }
708 SessionEvent::Disconnected(reason) => {
709 let _ = ctrl_tx.send(ControlEvent::Disconnected(reason)).await;
710 }
711 SessionEvent::Error(err) => {
712 let _ = ctrl_tx.send(ControlEvent::Error(err)).await;
713 }
714 other => {
718 #[cfg(feature = "tracing-support")]
719 tracing::debug!(?other, "unhandled SessionEvent variant (newer wire event?)");
720 #[cfg(not(feature = "tracing-support"))]
721 let _ = other;
722 }
723 }
724}
725
726async fn run_fast_lane(
729 mut rx: mpsc::Receiver<FastEvent>,
730 callbacks: Arc<EventCallbacks>,
731 shared: Arc<SharedState>,
732 event_tx: broadcast::Sender<LiveEvent>,
733) {
734 while let Some(event) = rx.recv().await {
735 match event {
736 FastEvent::Audio(data) => {
737 if !shared.interrupted.load(Ordering::Acquire) {
739 if let Some(cb) = &callbacks.on_audio {
740 cb(&data);
741 }
742 let _ = event_tx.send(LiveEvent::Audio(data));
743 }
744 }
745 FastEvent::Text(delta) => {
746 if let Some(cb) = &callbacks.on_text {
747 cb(&delta);
748 }
749 let _ = event_tx.send(LiveEvent::TextDelta(delta));
750 }
751 FastEvent::TextComplete(text) => {
752 if let Some(cb) = &callbacks.on_text_complete {
753 cb(&text);
754 }
755 let _ = event_tx.send(LiveEvent::TextComplete(text));
756 }
757 FastEvent::InputTranscript(text) => {
758 if let Some(cb) = &callbacks.on_input_transcript {
760 cb(&text, false);
761 }
762 let _ = event_tx.send(LiveEvent::InputTranscript {
763 text,
764 is_final: false,
765 });
766 }
767 FastEvent::OutputTranscript(text) => {
768 if let Some(cb) = &callbacks.on_output_transcript {
770 cb(&text, false);
771 }
772 let _ = event_tx.send(LiveEvent::OutputTranscript {
773 text,
774 is_final: false,
775 });
776 }
777 FastEvent::Thought(text) => {
778 if let Some(cb) = &callbacks.on_thought {
779 cb(&text);
780 }
781 let _ = event_tx.send(LiveEvent::Thought(text));
782 }
783 FastEvent::VadStart => {
784 if let Some(cb) = &callbacks.on_vad_start {
785 cb();
786 }
787 let _ = event_tx.send(LiveEvent::VadStart);
788 }
789 FastEvent::VadEnd => {
790 if let Some(cb) = &callbacks.on_vad_end {
791 cb();
792 }
793 let _ = event_tx.send(LiveEvent::VadEnd);
794 }
795 FastEvent::Phase(phase) => {
796 if let Some(cb) = &callbacks.on_phase {
797 cb(phase);
798 }
799 }
801 FastEvent::Interrupted => {
802 }
805 }
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812 use std::sync::atomic::AtomicUsize;
813
814 use crate::live::events::LiveEvent;
815 use gemini_genai_rs::prelude::FunctionResponse;
816
817 fn dummy_event_tx() -> broadcast::Sender<LiveEvent> {
818 broadcast::channel::<LiveEvent>(16).0
819 }
820
821 #[test]
822 fn delivery_config_default_is_all_lossless() {
823 let cfg = DeliveryConfig::default();
824 assert_eq!(cfg.audio, Delivery::Lossless);
825 assert_eq!(cfg.text, Delivery::Lossless);
826 assert_eq!(cfg.transcript, Delivery::Lossless);
827 assert_eq!(cfg.thought, Delivery::Lossless);
828 assert_eq!(cfg.vad, Delivery::Lossless);
829 assert_eq!(cfg.phase, Delivery::Lossless);
830 assert_eq!(Delivery::default(), Delivery::Lossless);
832 }
833
834 #[tokio::test]
835 async fn lossy_drop_newest_does_not_block_and_counts_drops() {
836 let (tx, _rx) = mpsc::channel::<FastEvent>(1);
839 tx.send(FastEvent::VadStart).await.unwrap(); let dropped = AtomicU64::new(0);
841
842 let res = tokio::time::timeout(
845 Duration::from_millis(100),
846 deliver_fast(&tx, FastEvent::VadEnd, Delivery::LossyDropNewest, &dropped),
847 )
848 .await;
849 assert!(res.is_ok(), "deliver_fast blocked under LossyDropNewest");
850 assert_eq!(dropped.load(Ordering::Relaxed), 1);
851 }
852
853 #[tokio::test]
854 async fn lossless_delivers_on_non_full_channel() {
855 let (tx, mut rx) = mpsc::channel::<FastEvent>(4);
856 let dropped = AtomicU64::new(0);
857
858 deliver_fast(
859 &tx,
860 FastEvent::Text("hello".into()),
861 Delivery::Lossless,
862 &dropped,
863 )
864 .await;
865
866 assert_eq!(dropped.load(Ordering::Relaxed), 0);
868 match rx.recv().await {
869 Some(FastEvent::Text(s)) => assert_eq!(s, "hello"),
870 other => panic!("expected Text frame, got {:?}", other.is_some()),
871 }
872 }
873
874 #[test]
875 fn dropped_frames_total_sums_classes() {
876 let d = DroppedFrames::default();
877 d.audio.fetch_add(2, Ordering::Relaxed);
878 d.transcript.fetch_add(3, Ordering::Relaxed);
879 assert_eq!(d.total(), 5);
880 }
881
882 #[tokio::test]
883 async fn fast_lane_routes_audio() {
884 let count = Arc::new(AtomicUsize::new(0));
885 let count_clone = count.clone();
886
887 let callbacks = EventCallbacks {
888 on_audio: Some(Box::new(move |_| {
889 count_clone.fetch_add(1, Ordering::SeqCst);
890 })),
891 ..Default::default()
892 };
893 let callbacks = Arc::new(callbacks);
894
895 let (event_tx, _) = broadcast::channel(16);
896 let event_rx = event_tx.subscribe();
897
898 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
899
900 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
901 event_rx,
902 callbacks,
903 None,
904 writer,
905 vec![],
906 State::new(),
907 None,
908 None,
909 None,
910 None,
911 None,
912 std::collections::HashMap::new(),
913 ControlPlaneConfig::default(),
914 dummy_event_tx(),
915 );
916
917 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"audio1")));
919 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"audio2")));
920
921 tokio::time::sleep(Duration::from_millis(50)).await;
923
924 assert_eq!(count.load(Ordering::SeqCst), 2);
925
926 drop(event_tx);
928 let _ = fast_handle.await;
929 let _ = ctrl_handle.await;
930 }
931
932 #[tokio::test]
933 async fn interrupt_suppresses_audio() {
934 let count = Arc::new(AtomicUsize::new(0));
935 let count_clone = count.clone();
936
937 let callbacks = EventCallbacks {
938 on_audio: Some(Box::new(move |_| {
939 count_clone.fetch_add(1, Ordering::SeqCst);
940 })),
941 ..Default::default()
942 };
943 let callbacks = Arc::new(callbacks);
944
945 let (event_tx, _) = broadcast::channel(16);
946 let event_rx = event_tx.subscribe();
947
948 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
949
950 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
951 event_rx,
952 callbacks,
953 None,
954 writer,
955 vec![],
956 State::new(),
957 None,
958 None,
959 None,
960 None,
961 None,
962 std::collections::HashMap::new(),
963 ControlPlaneConfig::default(),
964 dummy_event_tx(),
965 );
966
967 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"before")));
969 tokio::time::sleep(Duration::from_millis(20)).await;
970 let _ = event_tx.send(SessionEvent::Interrupted);
971 tokio::time::sleep(Duration::from_millis(20)).await;
972 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"during")));
973 tokio::time::sleep(Duration::from_millis(50)).await;
974
975 assert!(count.load(Ordering::SeqCst) >= 1);
977
978 drop(event_tx);
979 let _ = fast_handle.await;
980 let _ = ctrl_handle.await;
981 }
982
983 #[tokio::test]
984 async fn control_lane_routes_turn_complete() {
985 let called = Arc::new(AtomicBool::new(false));
986 let called_clone = called.clone();
987
988 let callbacks = EventCallbacks {
989 on_turn_complete: Some(Arc::new(move || {
990 let c = called_clone.clone();
991 Box::pin(async move {
992 c.store(true, Ordering::SeqCst);
993 })
994 })),
995 ..Default::default()
996 };
997 let callbacks = Arc::new(callbacks);
998
999 let (event_tx, _) = broadcast::channel(16);
1000 let event_rx = event_tx.subscribe();
1001
1002 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1003
1004 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1005 event_rx,
1006 callbacks,
1007 None,
1008 writer,
1009 vec![],
1010 State::new(),
1011 None,
1012 None,
1013 None,
1014 None,
1015 None,
1016 std::collections::HashMap::new(),
1017 ControlPlaneConfig::default(),
1018 dummy_event_tx(),
1019 );
1020
1021 let _ = event_tx.send(SessionEvent::TurnComplete);
1022 tokio::time::sleep(Duration::from_millis(50)).await;
1023
1024 assert!(called.load(Ordering::SeqCst));
1025
1026 drop(event_tx);
1027 let _ = fast_handle.await;
1028 let _ = ctrl_handle.await;
1029 }
1030
1031 #[tokio::test]
1032 async fn transcript_accumulates_in_control_lane() {
1033 let callbacks = Arc::new(EventCallbacks::default());
1034
1035 let (event_tx, _) = broadcast::channel(16);
1036 let event_rx = event_tx.subscribe();
1037
1038 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1039
1040 let state = State::new();
1041 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1042 event_rx,
1043 callbacks,
1044 None,
1045 writer,
1046 vec![],
1047 state.clone(),
1048 None,
1049 None,
1050 None,
1051 None,
1052 None,
1053 std::collections::HashMap::new(),
1054 ControlPlaneConfig::default(),
1055 dummy_event_tx(),
1056 );
1057
1058 let _ = event_tx.send(SessionEvent::InputTranscription("Hello ".to_string()));
1060 let _ = event_tx.send(SessionEvent::InputTranscription("world".to_string()));
1061 let _ = event_tx.send(SessionEvent::OutputTranscription("Hi there!".to_string()));
1062 tokio::time::sleep(Duration::from_millis(50)).await;
1063
1064 let _ = event_tx.send(SessionEvent::TurnComplete);
1066 tokio::time::sleep(Duration::from_millis(50)).await;
1067
1068 let tc: u32 = state.session().get("turn_count").unwrap_or(0);
1070 assert_eq!(tc, 1);
1071
1072 drop(event_tx);
1073 let _ = fast_handle.await;
1074 let _ = ctrl_handle.await;
1075 }
1076
1077 #[tokio::test]
1078 async fn extractor_runs_on_turn_complete() {
1079 use crate::live::extractor::TurnExtractor;
1080 use crate::live::transcript::TranscriptTurn;
1081 use crate::llm::LlmError;
1082
1083 struct FixedExtractor;
1084
1085 #[async_trait::async_trait]
1086 impl TurnExtractor for FixedExtractor {
1087 fn name(&self) -> &str {
1088 "TestExtractor"
1089 }
1090 fn window_size(&self) -> usize {
1091 3
1092 }
1093 async fn extract(
1094 &self,
1095 _turns: &[TranscriptTurn],
1096 ) -> Result<serde_json::Value, LlmError> {
1097 Ok(serde_json::json!({"score": 0.9, "mood": "happy"}))
1098 }
1099 }
1100
1101 let callbacks = Arc::new(EventCallbacks::default());
1102
1103 let (event_tx, _) = broadcast::channel(16);
1104 let event_rx = event_tx.subscribe();
1105
1106 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1107
1108 let state = State::new();
1109
1110 let extractors: Vec<Arc<dyn TurnExtractor>> = vec![Arc::new(FixedExtractor)];
1111
1112 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1113 event_rx,
1114 callbacks,
1115 None,
1116 writer,
1117 extractors,
1118 state.clone(),
1119 None,
1120 None,
1121 None,
1122 None,
1123 None,
1124 std::collections::HashMap::new(),
1125 ControlPlaneConfig::default(),
1126 dummy_event_tx(),
1127 );
1128
1129 let _ = event_tx.send(SessionEvent::InputTranscription("hi".to_string()));
1131 tokio::time::sleep(Duration::from_millis(20)).await;
1132 let _ = event_tx.send(SessionEvent::TurnComplete);
1133 tokio::time::sleep(Duration::from_millis(100)).await;
1134
1135 let score: Option<f64> = state.get("score");
1137 assert_eq!(score, Some(0.9));
1138 let mood: Option<String> = state.get("mood");
1139 assert_eq!(mood, Some("happy".to_string()));
1140
1141 drop(event_tx);
1142 let _ = fast_handle.await;
1143 let _ = ctrl_handle.await;
1144 }
1145
1146 #[tokio::test]
1155 async fn control_sender_records_text_on_transcript() {
1156 use crate::live::extractor::TurnExtractor;
1157 use crate::live::transcript::TranscriptTurn;
1158 use crate::llm::LlmError;
1159
1160 struct WindowRecorder(Arc<parking_lot::Mutex<Vec<String>>>);
1162
1163 #[async_trait::async_trait]
1164 impl TurnExtractor for WindowRecorder {
1165 fn name(&self) -> &str {
1166 "WindowRecorder"
1167 }
1168 fn window_size(&self) -> usize {
1169 3
1170 }
1171 async fn extract(
1172 &self,
1173 turns: &[TranscriptTurn],
1174 ) -> Result<serde_json::Value, LlmError> {
1175 self.0.lock().extend(turns.iter().map(|t| t.user.clone()));
1176 Ok(serde_json::json!({}))
1177 }
1178 }
1179
1180 let seen = Arc::new(parking_lot::Mutex::new(Vec::new()));
1181
1182 let (event_tx, _) = broadcast::channel(16);
1183 let event_rx = event_tx.subscribe();
1184 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1185 let extractors: Vec<Arc<dyn TurnExtractor>> = vec![Arc::new(WindowRecorder(seen.clone()))];
1186
1187 let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
1188 event_rx,
1189 Arc::new(EventCallbacks::default()),
1190 None,
1191 writer,
1192 extractors,
1193 State::new(),
1194 None,
1195 None,
1196 None,
1197 None,
1198 None,
1199 std::collections::HashMap::new(),
1200 ControlPlaneConfig::default(),
1201 dummy_event_tx(),
1202 );
1203
1204 let tx = ctrl_tx.upgrade().expect("control lane is alive");
1206 tx.send(ControlEvent::InputTranscript("I am pescatarian".into()))
1207 .await
1208 .expect("control lane accepts the typed turn");
1209 drop(tx);
1210
1211 tokio::time::sleep(Duration::from_millis(20)).await;
1212 let _ = event_tx.send(SessionEvent::TurnComplete);
1213 tokio::time::sleep(Duration::from_millis(100)).await;
1214
1215 assert_eq!(
1216 seen.lock().as_slice(),
1217 ["I am pescatarian"],
1218 "typed turn must reach the extractor's transcript window"
1219 );
1220
1221 drop(event_tx);
1222 let _ = fast_handle.await;
1223 let _ = ctrl_handle.await;
1224 }
1225
1226 #[tokio::test]
1230 async fn control_sender_does_not_keep_lane_alive() {
1231 let (event_tx, _) = broadcast::channel(16);
1232 let event_rx = event_tx.subscribe();
1233 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1234
1235 let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
1236 event_rx,
1237 Arc::new(EventCallbacks::default()),
1238 None,
1239 writer,
1240 vec![],
1241 State::new(),
1242 None,
1243 None,
1244 None,
1245 None,
1246 None,
1247 std::collections::HashMap::new(),
1248 ControlPlaneConfig::default(),
1249 dummy_event_tx(),
1250 );
1251
1252 drop(event_tx);
1254
1255 tokio::time::timeout(Duration::from_secs(2), ctrl_handle)
1256 .await
1257 .expect("control lane must shut down while the handle's sender is held")
1258 .expect("control lane joined cleanly");
1259
1260 assert!(
1261 ctrl_tx.upgrade().is_none(),
1262 "control channel must be closed once the router is gone"
1263 );
1264
1265 let _ = fast_handle.await;
1266 }
1267
1268 #[tokio::test]
1269 async fn telemetry_lane_auto_collects() {
1270 let (event_tx, _) = broadcast::channel(16);
1271 let telem_rx = event_tx.subscribe();
1272
1273 let telemetry = Arc::new(SessionTelemetry::new());
1274 let signals = SessionSignals::new(State::new());
1275 let cancel = CancellationToken::new();
1276
1277 let telem_handle =
1278 spawn_telemetry_lane(telem_rx, signals, telemetry.clone(), cancel.clone(), None);
1279
1280 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"chunk1")));
1282 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"chunk2")));
1283 let _ = event_tx.send(SessionEvent::VoiceActivityEnd);
1284 tokio::time::sleep(Duration::from_millis(50)).await;
1285 let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"response")));
1286 tokio::time::sleep(Duration::from_millis(50)).await;
1287
1288 let snap = telemetry.snapshot();
1289 assert_eq!(snap["audio_chunks_out"], 3);
1290 assert!(snap["response_count"].as_u64().unwrap() >= 1);
1291
1292 cancel.cancel();
1293 let _ = telem_handle.await;
1294 }
1295
1296 #[tokio::test]
1297 async fn background_tool_sends_ack_immediately() {
1298 use crate::live::background_tool::{BackgroundToolTracker, ToolExecutionMode};
1299 use crate::tool::{SimpleTool, ToolDispatcher};
1300
1301 let tool = SimpleTool::new(
1303 "slow_search",
1304 "A slow search tool",
1305 Some(serde_json::json!({"type": "object", "properties": {"q": {"type": "string"}}})),
1306 |_args| async move {
1307 tokio::time::sleep(Duration::from_millis(200)).await;
1308 Ok(serde_json::json!({"results": ["found"]}))
1309 },
1310 );
1311
1312 let mut dispatcher = ToolDispatcher::new();
1313 dispatcher.register(tool);
1314
1315 let mut execution_modes = std::collections::HashMap::new();
1316 execution_modes.insert(
1317 "slow_search".to_string(),
1318 ToolExecutionMode::Background {
1319 formatter: None,
1320 scheduling: None,
1321 },
1322 );
1323
1324 let sent = Arc::new(parking_lot::Mutex::new(Vec::<Vec<FunctionResponse>>::new()));
1325 let sent_clone = sent.clone();
1326
1327 struct RecordingWriter {
1329 sent: Arc<parking_lot::Mutex<Vec<Vec<FunctionResponse>>>>,
1330 }
1331
1332 #[async_trait::async_trait]
1333 impl SessionWriter for RecordingWriter {
1334 async fn send_audio(
1335 &self,
1336 _data: Vec<u8>,
1337 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1338 Ok(())
1339 }
1340 async fn send_text(
1341 &self,
1342 _text: String,
1343 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1344 Ok(())
1345 }
1346 async fn send_video(
1347 &self,
1348 _data: Vec<u8>,
1349 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1350 Ok(())
1351 }
1352 async fn send_tool_response(
1353 &self,
1354 responses: Vec<FunctionResponse>,
1355 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1356 self.sent.lock().push(responses);
1357 Ok(())
1358 }
1359 async fn update_instruction(
1360 &self,
1361 _instruction: String,
1362 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1363 Ok(())
1364 }
1365 async fn send_client_content(
1366 &self,
1367 _content: Vec<gemini_genai_rs::prelude::Content>,
1368 _turn_complete: bool,
1369 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1370 Ok(())
1371 }
1372 async fn signal_activity_start(
1373 &self,
1374 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1375 Ok(())
1376 }
1377 async fn signal_activity_end(
1378 &self,
1379 ) -> Result<(), gemini_genai_rs::session::SessionError> {
1380 Ok(())
1381 }
1382 async fn disconnect(&self) -> Result<(), gemini_genai_rs::session::SessionError> {
1383 Ok(())
1384 }
1385 }
1386
1387 let writer: Arc<dyn SessionWriter> = Arc::new(RecordingWriter { sent: sent_clone });
1388 let callbacks = Arc::new(EventCallbacks::default());
1389 let tracker = Arc::new(BackgroundToolTracker::new());
1390
1391 let (event_tx, _) = broadcast::channel(16);
1392 let event_rx = event_tx.subscribe();
1393
1394 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1395 event_rx,
1396 callbacks,
1397 Some(Arc::new(dispatcher)),
1398 writer,
1399 vec![],
1400 State::new(),
1401 None,
1402 None,
1403 None,
1404 None,
1405 Some(tracker.clone()),
1406 execution_modes,
1407 ControlPlaneConfig::default(),
1408 dummy_event_tx(),
1409 );
1410
1411 let _ = event_tx.send(SessionEvent::ToolCall(vec![
1413 gemini_genai_rs::prelude::FunctionCall {
1414 name: "slow_search".to_string(),
1415 args: serde_json::json!({"q": "test"}),
1416 id: Some("fc_1".to_string()),
1417 },
1418 ]));
1419
1420 tokio::time::sleep(Duration::from_millis(50)).await;
1422
1423 {
1425 let responses = sent.lock();
1426 assert!(!responses.is_empty(), "Should have sent ack immediately");
1428 assert_eq!(responses[0][0].response["status"], "running");
1429 }
1430
1431 tokio::time::sleep(Duration::from_millis(300)).await;
1433
1434 {
1435 let responses = sent.lock();
1436 assert!(
1438 responses.len() >= 2,
1439 "Should have sent result after completion"
1440 );
1441 assert_eq!(responses[1][0].response["status"], "completed");
1442 }
1443
1444 drop(event_tx);
1445 let _ = fast_handle.await;
1446 let _ = ctrl_handle.await;
1447 }
1448
1449 #[tokio::test]
1450 async fn callback_mode_blocking_awaits_inline() {
1451 use crate::live::callbacks::CallbackMode;
1452 use std::sync::atomic::AtomicU32;
1453
1454 let order = Arc::new(AtomicU32::new(0));
1455 let order_clone = order.clone();
1456
1457 let callbacks = EventCallbacks {
1458 on_turn_complete: Some(Arc::new(move || {
1460 let o = order_clone.clone();
1461 Box::pin(async move {
1462 tokio::time::sleep(Duration::from_millis(10)).await;
1464 o.store(1, Ordering::SeqCst);
1465 })
1466 })),
1467 on_turn_complete_mode: CallbackMode::Blocking,
1468 ..Default::default()
1469 };
1470 let callbacks = Arc::new(callbacks);
1471
1472 let (event_tx, _) = broadcast::channel(16);
1473 let event_rx = event_tx.subscribe();
1474
1475 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1476
1477 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1478 event_rx,
1479 callbacks,
1480 None,
1481 writer,
1482 vec![],
1483 State::new(),
1484 None,
1485 None,
1486 None,
1487 None,
1488 None,
1489 std::collections::HashMap::new(),
1490 ControlPlaneConfig::default(),
1491 dummy_event_tx(),
1492 );
1493
1494 let _ = event_tx.send(SessionEvent::TurnComplete);
1495 tokio::time::sleep(Duration::from_millis(100)).await;
1496
1497 assert_eq!(order.load(Ordering::SeqCst), 1);
1499
1500 drop(event_tx);
1501 let _ = fast_handle.await;
1502 let _ = ctrl_handle.await;
1503 }
1504
1505 #[tokio::test]
1506 async fn interruption_beats_slow_inline_tool() {
1507 use crate::tool::{SimpleTool, ToolDispatcher};
1508
1509 let mut dispatcher = ToolDispatcher::new();
1511 dispatcher.register(SimpleTool::new("slow", "slow", None, |_args| async move {
1512 tokio::time::sleep(Duration::from_secs(5)).await;
1513 Ok(serde_json::json!({"done": true}))
1514 }));
1515
1516 let interrupted_at = Arc::new(parking_lot::Mutex::new(None::<std::time::Instant>));
1517 let flag = interrupted_at.clone();
1518 let callbacks = EventCallbacks {
1519 on_interrupted: Some(Arc::new(move || {
1520 let flag = flag.clone();
1521 Box::pin(async move {
1522 *flag.lock() = Some(std::time::Instant::now());
1523 })
1524 })),
1525 ..Default::default()
1526 };
1527
1528 let (event_tx, _) = broadcast::channel(16);
1529 let event_rx = event_tx.subscribe();
1530 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1531
1532 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1533 event_rx,
1534 Arc::new(callbacks),
1535 Some(Arc::new(dispatcher)),
1536 writer,
1537 vec![],
1538 State::new(),
1539 None,
1540 None,
1541 None,
1542 None,
1543 None,
1544 std::collections::HashMap::new(),
1545 ControlPlaneConfig::default(),
1546 dummy_event_tx(),
1547 );
1548
1549 let start = std::time::Instant::now();
1551 let _ = event_tx.send(SessionEvent::ToolCall(vec![
1552 gemini_genai_rs::prelude::FunctionCall {
1553 name: "slow".to_string(),
1554 args: serde_json::json!({}),
1555 id: Some("fc_slow".to_string()),
1556 },
1557 ]));
1558 tokio::time::sleep(Duration::from_millis(50)).await;
1559 let _ = event_tx.send(SessionEvent::Interrupted);
1560
1561 let mut waited = Duration::ZERO;
1564 while interrupted_at.lock().is_none() && waited < Duration::from_secs(2) {
1565 tokio::time::sleep(Duration::from_millis(25)).await;
1566 waited += Duration::from_millis(25);
1567 }
1568 let fired = (*interrupted_at.lock()).expect("on_interrupted must fire");
1569 assert!(
1570 fired.duration_since(start) < Duration::from_secs(2),
1571 "interruption must not wait for the slow tool"
1572 );
1573
1574 drop(event_tx);
1575 let _ = fast_handle.await;
1576 let _ = ctrl_handle.await;
1577 }
1578
1579 #[tokio::test]
1580 async fn control_lane_exit_persists_final_snapshot_synchronously() {
1581 use crate::live::persistence::{MemoryPersistence, SessionPersistence};
1582
1583 let persistence = Arc::new(MemoryPersistence::new());
1584 let control_plane = ControlPlaneConfig {
1585 persistence: Some(persistence.clone()),
1586 session_id: Some("final-drain".to_string()),
1587 ..Default::default()
1588 };
1589
1590 let (event_tx, _) = broadcast::channel(16);
1591 let event_rx = event_tx.subscribe();
1592 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1593
1594 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1595 event_rx,
1596 Arc::new(EventCallbacks::default()),
1597 None,
1598 writer,
1599 vec![],
1600 State::new(),
1601 None,
1602 None,
1603 None,
1604 None,
1605 None,
1606 std::collections::HashMap::new(),
1607 control_plane,
1608 dummy_event_tx(),
1609 );
1610
1611 let _ = event_tx.send(SessionEvent::InputTranscription("last words".to_string()));
1614 tokio::time::sleep(Duration::from_millis(50)).await;
1615
1616 drop(event_tx);
1619 let _ = fast_handle.await;
1620 let _ = ctrl_handle.await;
1621
1622 let snap = persistence
1623 .load("final-drain")
1624 .await
1625 .unwrap()
1626 .expect("control-lane exit must persist a final snapshot");
1627 assert_eq!(snap.turn_count, 0);
1628 }
1629
1630 #[tokio::test]
1631 async fn lanes_exit_after_terminal_disconnected_event() {
1632 let callbacks = Arc::new(EventCallbacks::default());
1637 let (event_tx, _) = broadcast::channel(16);
1638 let event_rx = event_tx.subscribe();
1639 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1640
1641 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1642 event_rx,
1643 callbacks,
1644 None,
1645 writer,
1646 vec![],
1647 State::new(),
1648 None,
1649 None,
1650 None,
1651 None,
1652 None,
1653 std::collections::HashMap::new(),
1654 ControlPlaneConfig::default(),
1655 dummy_event_tx(),
1656 );
1657
1658 let _ = event_tx.send(SessionEvent::Disconnected(None));
1659
1660 let joined = tokio::time::timeout(Duration::from_secs(2), async {
1663 let _ = fast_handle.await;
1664 let _ = ctrl_handle.await;
1665 })
1666 .await;
1667 assert!(
1668 joined.is_ok(),
1669 "lanes must exit after the terminal Disconnected event"
1670 );
1671 drop(event_tx);
1672 }
1673
1674 #[tokio::test]
1675 async fn callback_mode_concurrent_spawns_task() {
1676 use crate::live::callbacks::CallbackMode;
1677
1678 let called = Arc::new(AtomicBool::new(false));
1679 let called_clone = called.clone();
1680
1681 let callbacks = EventCallbacks {
1682 on_turn_complete: Some(Arc::new(move || {
1683 let c = called_clone.clone();
1684 Box::pin(async move {
1685 tokio::time::sleep(Duration::from_millis(10)).await;
1686 c.store(true, Ordering::SeqCst);
1687 })
1688 })),
1689 on_turn_complete_mode: CallbackMode::Concurrent,
1690 ..Default::default()
1691 };
1692 let callbacks = Arc::new(callbacks);
1693
1694 let (event_tx, _) = broadcast::channel(16);
1695 let event_rx = event_tx.subscribe();
1696
1697 let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1698
1699 let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1700 event_rx,
1701 callbacks,
1702 None,
1703 writer,
1704 vec![],
1705 State::new(),
1706 None,
1707 None,
1708 None,
1709 None,
1710 None,
1711 std::collections::HashMap::new(),
1712 ControlPlaneConfig::default(),
1713 dummy_event_tx(),
1714 );
1715
1716 let _ = event_tx.send(SessionEvent::TurnComplete);
1717 tokio::time::sleep(Duration::from_millis(100)).await;
1719
1720 assert!(called.load(Ordering::SeqCst));
1722
1723 drop(event_tx);
1724 let _ = fast_handle.await;
1725 let _ = ctrl_handle.await;
1726 }
1727}