gemini_adk_rs/live/
handle.rs

1//! LiveHandle — runtime interaction with a Live session.
2
3use std::sync::Arc;
4
5use gemini_genai_rs::prelude::{FunctionResponse, SessionEvent, SessionPhase, VadEvent};
6use gemini_genai_rs::session::{SessionError, SessionHandle, SessionWriter};
7use parking_lot::Mutex;
8use serde::de::DeserializeOwned;
9use tokio::sync::{broadcast, mpsc};
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12
13use crate::flow::{FlowExplanation, SharedFlowMonitor};
14use crate::state::State;
15
16use super::background_tool::BackgroundToolTracker;
17use super::context_writer::PendingContext;
18use super::effect_executor::LiveEffectExecutor;
19use super::input_vad::{BackendInputVad, BackendVadSnapshot};
20use super::processor::ControlEvent;
21use super::reactor::{LiveReactor, ReactorEvent, VoiceRuntimeState};
22use super::telemetry::SessionTelemetry;
23
24/// Handle for interacting with a running Live session.
25///
26/// Provides send methods for audio/text/video, system instruction updates,
27/// event subscription, state access, telemetry, and graceful shutdown.
28///
29/// When [`ContextDelivery::Deferred`](super::steering::ContextDelivery::Deferred) is
30/// enabled, `send_audio`, `send_text`, and `send_video` automatically flush
31/// any pending context turns before forwarding the user content.
32#[derive(Clone)]
33pub struct LiveHandle {
34    session: SessionHandle,
35    /// Writer used for user-facing sends.  When deferred context delivery is
36    /// enabled, this is a `DeferredWriter` that flushes pending context.
37    /// Otherwise it's the raw `SessionHandle`.
38    writer: Arc<dyn SessionWriter>,
39    /// Fast-lane task. Held in `Arc<Mutex<Option<..>>>` so `LiveHandle` stays
40    /// `Clone` while [`disconnect`](Self::disconnect) can take ownership to
41    /// grace-await and then abort the lane.
42    fast_task: Arc<Mutex<Option<JoinHandle<()>>>>,
43    /// Control-lane task (same ownership scheme as `fast_task`).
44    ctrl_task: Arc<Mutex<Option<JoinHandle<()>>>>,
45    /// Cancellation token for the telemetry lane, cancelled on disconnect.
46    telem_cancel: CancellationToken,
47    state: State,
48    telemetry: Arc<SessionTelemetry>,
49    event_tx: broadcast::Sender<super::events::LiveEvent>,
50    pending_context: Option<Arc<PendingContext>>,
51    reactor: Arc<LiveReactor>,
52    effect_executor: LiveEffectExecutor,
53    input_vad: Arc<Mutex<BackendInputVad>>,
54    /// Governed-flow monitor shared with the control lane (None when the
55    /// session is not governed by a flow).
56    flow: Option<SharedFlowMonitor>,
57    /// Tracker for in-flight background tool tasks. Shared with the control
58    /// lane (which spawns/cancels per-call tasks) so [`disconnect`](Self::disconnect)
59    /// can cancel every outstanding background tool — otherwise orphaned tasks
60    /// could keep running and post stale `ToolCompleted` events after shutdown.
61    background_tracker: Arc<BackgroundToolTracker>,
62    /// Control-lane sender used by [`send_text`](Self::send_text) to record the
63    /// typed turn on the transcript, so a text-driven session is visible to
64    /// extractors exactly as a spoken one is.
65    ///
66    /// Deliberately a [`WeakSender`](mpsc::WeakSender): the control channel must
67    /// still close once the router drops its strong sender, or the lane would
68    /// never drain and shut down.
69    ctrl_tx: Option<mpsc::WeakSender<ControlEvent>>,
70}
71
72impl LiveHandle {
73    #[allow(
74        clippy::too_many_arguments,
75        reason = "crate-internal constructor called once from spawn_lanes; the runtime parts are deliberately enumerated rather than re-bundled"
76    )]
77    pub(crate) fn new(
78        session: SessionHandle,
79        writer: Arc<dyn SessionWriter>,
80        fast_task: JoinHandle<()>,
81        ctrl_task: JoinHandle<()>,
82        state: State,
83        telemetry: Arc<SessionTelemetry>,
84        event_tx: broadcast::Sender<super::events::LiveEvent>,
85        pending_context: Option<Arc<PendingContext>>,
86        flow: Option<SharedFlowMonitor>,
87        background_tracker: Arc<BackgroundToolTracker>,
88        telem_cancel: CancellationToken,
89    ) -> Self {
90        let reactor = Arc::new(LiveReactor::voice_defaults());
91        let effect_executor = LiveEffectExecutor::new(
92            Arc::new(session.clone()),
93            pending_context.clone(),
94            event_tx.clone(),
95        );
96
97        Self {
98            session,
99            writer,
100            fast_task: Arc::new(Mutex::new(Some(fast_task))),
101            ctrl_task: Arc::new(Mutex::new(Some(ctrl_task))),
102            telem_cancel,
103            state,
104            telemetry,
105            event_tx,
106            pending_context,
107            reactor,
108            effect_executor,
109            input_vad: Arc::new(Mutex::new(BackendInputVad::default())),
110            flow,
111            background_tracker,
112            ctrl_tx: None,
113        }
114    }
115
116    /// Attach the control-lane sender used to record typed turns on the
117    /// transcript. Called once from the builder's `spawn_lanes`.
118    pub(crate) fn with_control_sender(mut self, ctrl_tx: mpsc::WeakSender<ControlEvent>) -> Self {
119        self.ctrl_tx = Some(ctrl_tx);
120        self
121    }
122
123    /// Send audio data (raw PCM16 16kHz bytes).
124    ///
125    /// When deferred context delivery is enabled, any pending model-role
126    /// context turns are flushed to the wire before the audio frame.
127    pub async fn send_audio(&self, data: Vec<u8>) -> Result<(), SessionError> {
128        let vad_events = {
129            let mut input_vad = self.input_vad.lock();
130            input_vad.process_pcm_bytes(&data)
131        };
132
133        if vad_events.contains(&VadEvent::SpeechStart) {
134            self.user_speech_started().await?;
135        }
136
137        self.writer.send_audio(data).await?;
138
139        if vad_events.contains(&VadEvent::SpeechEnd) {
140            self.user_speech_ended().await?;
141        }
142
143        Ok(())
144    }
145
146    /// Send a text message.
147    ///
148    /// When deferred context delivery is enabled, any pending model-role
149    /// context turns are flushed to the wire before the text message.
150    ///
151    /// The text is also recorded on the session transcript as the user side of
152    /// the current turn, through the same internal control event that ASR of
153    /// audio produces. Without this a text-driven session would hand every
154    /// [`TurnExtractor`](super::extractor::TurnExtractor) an empty user turn,
155    /// since the transcript's user side is otherwise written only by ASR.
156    /// Routing through the control event rather than poking the buffer keeps a
157    /// typed turn and a spoken one the *same* event downstream.
158    pub async fn send_text(&self, text: impl Into<String>) -> Result<(), SessionError> {
159        let text = text.into();
160        self.telemetry.record_text_send();
161        self.writer.send_text(text.clone()).await?;
162
163        // Record only after a *successful* send: a turn the model never
164        // received is not part of the conversation.
165        if let Some(tx) = self.ctrl_tx.as_ref().and_then(mpsc::WeakSender::upgrade) {
166            let _ = tx.send(ControlEvent::InputTranscript(text)).await;
167        }
168
169        Ok(())
170    }
171
172    /// Send a video/image frame (raw JPEG bytes).
173    ///
174    /// When deferred context delivery is enabled, any pending model-role
175    /// context turns are flushed to the wire before the video frame.
176    pub async fn send_video(&self, jpeg_data: Vec<u8>) -> Result<(), SessionError> {
177        self.writer.send_video(jpeg_data).await
178    }
179
180    /// Update the system instruction mid-session.
181    pub async fn update_instruction(
182        &self,
183        instruction: impl Into<String>,
184    ) -> Result<(), SessionError> {
185        SessionWriter::update_instruction(&self.session, instruction.into()).await
186    }
187
188    /// Send tool responses manually (if not using auto-dispatch).
189    pub async fn send_tool_response(
190        &self,
191        responses: Vec<FunctionResponse>,
192    ) -> Result<(), SessionError> {
193        self.session.send_tool_response(responses).await
194    }
195
196    /// Notify the runtime that client-side playback has drained.
197    ///
198    /// Voice UIs should call this only when it is safe for the model to speak,
199    /// for example after browser speaker playback has drained and the user is
200    /// not actively speaking. User audio/text sends intentionally flush context
201    /// only and leave the prompt armed.
202    pub async fn playback_drained(&self) -> Result<(), SessionError> {
203        let prompt_pending = self
204            .pending_context
205            .as_ref()
206            .is_some_and(|pending| pending.has_prompt());
207        let reactions = self
208            .reactor
209            .react(&ReactorEvent::PlaybackDrained { prompt_pending });
210        self.effect_executor.execute_reactions(reactions).await
211    }
212
213    /// Notify the runtime that client-side user speech has started.
214    ///
215    /// This is the barge-in edge for voice clients: pending model prompts are
216    /// cancelled before they can race with user audio, while queued context is
217    /// kept so the next user send can still carry it.
218    pub async fn user_speech_started(&self) -> Result<(), SessionError> {
219        let reactions = self.reactor.react(&ReactorEvent::UserSpeechStarted);
220        self.effect_executor.execute_reactions(reactions).await
221    }
222
223    /// Notify the runtime that client-side user speech has ended.
224    pub async fn user_speech_ended(&self) -> Result<(), SessionError> {
225        let prompt_pending = self
226            .pending_context
227            .as_ref()
228            .is_some_and(|pending| pending.has_prompt());
229        let reactions = self
230            .reactor
231            .react(&ReactorEvent::UserSpeechEnded { prompt_pending });
232        self.effect_executor.execute_reactions(reactions).await
233    }
234
235    /// Snapshot the reactor-owned voice runtime state.
236    pub fn voice_state(&self) -> VoiceRuntimeState {
237        self.reactor.voice_state()
238    }
239
240    /// Snapshot backend input VAD state.
241    pub fn input_vad_state(&self) -> BackendVadSnapshot {
242        self.input_vad.lock().snapshot()
243    }
244
245    /// Flush deferred context and any pending model prompt.
246    ///
247    /// Prefer [`Self::playback_drained`] for voice clients. This compatibility
248    /// method routes through the same reactor/effect executor path.
249    pub async fn flush_deferred_prompt(&self) -> Result<(), SessionError> {
250        self.playback_drained().await
251    }
252
253    /// Get the user-facing session writer.
254    ///
255    /// When deferred context delivery is enabled, this returns the
256    /// `DeferredWriter` that flushes pending context before sends.
257    pub fn writer(&self) -> Arc<dyn SessionWriter> {
258        self.writer.clone()
259    }
260
261    /// Subscribe to raw session events (for custom processing).
262    pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
263        self.session.subscribe()
264    }
265
266    /// Get the current session phase.
267    pub fn phase(&self) -> SessionPhase {
268        self.session.phase()
269    }
270
271    /// Gracefully disconnect the session.
272    ///
273    /// Shutdown sequence:
274    /// 1. Cancel all in-flight background tool tasks (they are aborted at an
275    ///    await point; tool futures must therefore be drop-safe).
276    /// 2. Close the L0 session. The terminal `Disconnected` event makes the
277    ///    event router exit, which closes the lane channels.
278    /// 3. Grace-await the fast and control lanes (~250 ms each) so they can
279    ///    drain queued events and run their final persistence drain, then
280    ///    abort whatever is still stuck (e.g. a lane blocked in a slow tool).
281    /// 4. Cancel the telemetry lane.
282    pub async fn disconnect(&self) -> Result<(), SessionError> {
283        // Cancel background tool tasks FIRST: once the session is closing,
284        // their results can no longer be delivered, and leaving them running
285        // would let them post stale ToolCompleted events to a dead lane.
286        self.background_tracker.cancel_all();
287        let result = SessionWriter::disconnect(&self.session).await;
288
289        // Grace-await the lanes, then abort. Taking the JoinHandles out of
290        // their mutexes gives us the ownership `await` requires; a second
291        // disconnect (or a clone's disconnect) simply finds them gone.
292        for lane in [&self.fast_task, &self.ctrl_task] {
293            let task = lane.lock().take();
294            if let Some(mut task) = task {
295                if tokio::time::timeout(Self::LANE_SHUTDOWN_GRACE, &mut task)
296                    .await
297                    .is_err()
298                {
299                    task.abort();
300                }
301            }
302        }
303
304        // Stop the telemetry lane (it runs on its own broadcast receiver and
305        // would otherwise idle on its debounce timer for the handle's lifetime).
306        self.telem_cancel.cancel();
307        result
308    }
309
310    /// How long [`disconnect`](Self::disconnect) waits for each lane to drain
311    /// before aborting it.
312    const LANE_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
313
314    /// Wait for the session to end (disconnect, GoAway, or error).
315    pub async fn done(&self) -> Result<(), SessionError> {
316        self.session
317            .join()
318            .await
319            .map_err(|_| SessionError::ChannelClosed)
320    }
321
322    /// Get the underlying SessionHandle for advanced usage.
323    pub fn session(&self) -> &SessionHandle {
324        &self.session
325    }
326
327    /// Latest session-resumption handle issued by the server, if any.
328    ///
329    /// While session resumption is enabled
330    /// ([`SessionConfig::session_resumption`](gemini_genai_rs::prelude::SessionConfig::session_resumption);
331    /// L2: `Live::builder().session_resume(true)`), the Gemini server
332    /// periodically sends `SessionResumptionUpdate` messages; this returns the
333    /// most recent handle (also captured in persistence snapshots as
334    /// [`SessionSnapshot::resume_handle`](crate::live::persistence::SessionSnapshot::resume_handle)).
335    ///
336    /// To survive a server-initiated `GoAway` or a planned restart, read this
337    /// handle (e.g. from the `on_go_away` callback) and pass it to
338    /// `session_resumption(Some(handle))` on the next connect's
339    /// [`SessionConfig`](gemini_genai_rs::prelude::SessionConfig). No
340    /// automatic reconnect is performed — resumption is an explicit caller
341    /// decision.
342    ///
343    /// Returns `None` when resumption is disabled or no update has arrived yet.
344    pub fn resume_handle(&self) -> Option<String> {
345        self.session.state.resume_handle.lock().clone()
346    }
347
348    /// Access the shared State container.
349    ///
350    /// Extraction results from `TurnExtractor`s are stored here under the
351    /// extractor's name. Use `state().get::<T>(name)` to read typed values.
352    pub fn state(&self) -> &State {
353        &self.state
354    }
355
356    /// Access the session telemetry (auto-collected by the telemetry lane).
357    ///
358    /// Use `telemetry().snapshot()` to get a JSON snapshot of all metrics.
359    pub fn telemetry(&self) -> &Arc<SessionTelemetry> {
360        &self.telemetry
361    }
362
363    /// Subscribe to semantic events from the processor.
364    ///
365    /// Returns a broadcast receiver. Call multiple times for independent
366    /// subscribers. Zero-cost when no subscribers exist.
367    pub fn events(&self) -> broadcast::Receiver<super::events::LiveEvent> {
368        self.event_tx.subscribe()
369    }
370
371    /// Subscribe to semantic events as a [`futures::Stream`].
372    ///
373    /// Stream-flavored sibling of [`events`](Self::events): each call creates
374    /// an independent subscriber starting from the current point in the event
375    /// flow. If the subscriber falls behind the broadcast buffer, the missed
376    /// events are skipped and the stream continues; the stream ends when the
377    /// session's event channel closes. See
378    /// [`LiveEventStream`](super::events::LiveEventStream).
379    ///
380    /// # Example
381    ///
382    /// ```rust,ignore
383    /// use futures::StreamExt;
384    ///
385    /// let mut stream = handle.stream();
386    /// while let Some(ev) = stream.next().await {
387    ///     match ev {
388    ///         LiveEvent::TextDelta(t) => print!("{t}"),
389    ///         LiveEvent::TurnComplete => println!(),
390    ///         _ => {}
391    ///     }
392    /// }
393    /// ```
394    pub fn stream(&self) -> super::events::LiveEventStream {
395        super::events::LiveEventStream::new(self.event_tx.subscribe())
396    }
397
398    /// Convenience: get the latest extraction result by extractor name.
399    pub fn extracted<T: DeserializeOwned>(&self, name: &str) -> Option<T> {
400        self.state.get(name)
401    }
402
403    /// Snapshot the governed flow's control-plane state: active steps, which
404    /// tools are admitted vs blocked (with reasons), and unmet requirements.
405    ///
406    /// The deterministic answer to "why did the assistant ask that?" — computed
407    /// against the live [`State`] and the marking the control lane maintains.
408    /// Returns `None` when the session is not governed by a flow
409    /// (`Live::govern`/`observe` was not used).
410    ///
411    /// This is a synchronous snapshot: it briefly locks the shared
412    /// [`FlowMonitor`](crate::flow::FlowMonitor) and never blocks on session
413    /// I/O.
414    pub fn explain(&self) -> Option<FlowExplanation> {
415        self.flow
416            .as_ref()
417            .map(|mon| mon.lock().explain(&self.state))
418    }
419
420    /// Why the governed flow is blocked right now — alias of
421    /// [`explain`](Self::explain), named for the common debugging question.
422    /// Returns `None` when the session is not governed by a flow.
423    pub fn why_blocked(&self) -> Option<FlowExplanation> {
424        self.explain()
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::live::telemetry::SessionTelemetry;
432    use gemini_genai_rs::session::{SessionCommand, SessionState};
433    use tokio_util::sync::CancellationToken;
434
435    /// Build a LiveHandle wired to an in-memory SessionHandle (no transport).
436    /// The command receiver is returned so `disconnect()` sends succeed.
437    fn make_handle_with_lanes(
438        fast: JoinHandle<()>,
439        ctrl: JoinHandle<()>,
440    ) -> (LiveHandle, tokio::sync::mpsc::Receiver<SessionCommand>) {
441        let (command_tx, command_rx) = tokio::sync::mpsc::channel(8);
442        let (event_tx, _) = broadcast::channel(16);
443        let (phase_tx, phase_rx) = tokio::sync::watch::channel(SessionPhase::Active);
444        let state = Arc::new(SessionState::with_events(phase_tx, event_tx.clone()));
445        let session = SessionHandle::new(command_tx, event_tx, state, phase_rx);
446        let writer: Arc<dyn SessionWriter> = Arc::new(session.clone());
447        let (live_tx, _) = broadcast::channel(16);
448        let handle = LiveHandle::new(
449            session,
450            writer,
451            fast,
452            ctrl,
453            State::new(),
454            Arc::new(SessionTelemetry::new()),
455            live_tx,
456            None,
457            None,
458            Arc::new(BackgroundToolTracker::new()),
459            CancellationToken::new(),
460        );
461        (handle, command_rx)
462    }
463
464    fn make_handle() -> (LiveHandle, tokio::sync::mpsc::Receiver<SessionCommand>) {
465        make_handle_with_lanes(tokio::spawn(async {}), tokio::spawn(async {}))
466    }
467
468    /// Sets a flag when dropped — observes that an aborted task's future was
469    /// actually torn down.
470    struct SetOnDrop(Arc<std::sync::atomic::AtomicBool>);
471    impl Drop for SetOnDrop {
472        fn drop(&mut self) {
473            self.0.store(true, std::sync::atomic::Ordering::SeqCst);
474        }
475    }
476
477    #[tokio::test]
478    async fn disconnect_cancels_background_tool_tasks() {
479        let (handle, _cmd_rx) = make_handle();
480        let tracker = handle.background_tracker.clone();
481
482        // Register a never-finishing background tool task.
483        let token = CancellationToken::new();
484        let t = token.clone();
485        let task = tokio::spawn(async move {
486            t.cancelled().await;
487            std::future::pending::<()>().await;
488        });
489        tracker.spawn("call-1".into(), task, token.clone());
490        assert_eq!(tracker.active_count(), 1);
491
492        handle.disconnect().await.expect("disconnect");
493
494        assert_eq!(
495            tracker.active_count(),
496            0,
497            "disconnect must cancel all tracked background tool tasks"
498        );
499        assert!(token.is_cancelled(), "cooperative token must be cancelled");
500    }
501
502    #[tokio::test]
503    async fn disconnect_aborts_stuck_lanes_within_grace_period() {
504        use std::sync::atomic::{AtomicBool, Ordering};
505
506        // Lanes that never finish on their own (simulating a lane blocked in a
507        // slow tool); drop guards record that abort tore the futures down.
508        let fast_dropped = Arc::new(AtomicBool::new(false));
509        let ctrl_dropped = Arc::new(AtomicBool::new(false));
510        let f = fast_dropped.clone();
511        let c = ctrl_dropped.clone();
512        let fast = tokio::spawn(async move {
513            let _guard = SetOnDrop(f);
514            std::future::pending::<()>().await;
515        });
516        let ctrl = tokio::spawn(async move {
517            let _guard = SetOnDrop(c);
518            std::future::pending::<()>().await;
519        });
520
521        let (handle, _cmd_rx) = make_handle_with_lanes(fast, ctrl);
522        let telem_cancel = handle.telem_cancel.clone();
523
524        // disconnect() must return in bounded time even with stuck lanes.
525        tokio::time::timeout(std::time::Duration::from_secs(2), handle.disconnect())
526            .await
527            .expect("disconnect must not hang on stuck lanes")
528            .expect("disconnect");
529
530        // Give the aborts a beat to take effect, then verify teardown.
531        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
532        assert!(
533            fast_dropped.load(Ordering::SeqCst),
534            "fast lane must be aborted after the grace period"
535        );
536        assert!(
537            ctrl_dropped.load(Ordering::SeqCst),
538            "control lane must be aborted after the grace period"
539        );
540        assert!(
541            telem_cancel.is_cancelled(),
542            "telemetry lane must be cancelled on disconnect"
543        );
544    }
545
546    #[tokio::test]
547    async fn resume_handle_surfaces_latest_server_handle() {
548        let (handle, _cmd_rx) = make_handle();
549        assert_eq!(handle.resume_handle(), None, "no update yet");
550
551        // Simulate the L0 transport storing a SessionResumptionUpdate.
552        *handle.session.state.resume_handle.lock() = Some("rh-42".into());
553        assert_eq!(handle.resume_handle(), Some("rh-42".to_string()));
554    }
555
556    #[tokio::test]
557    async fn disconnect_is_idempotent_across_clones() {
558        let (handle, _cmd_rx) = make_handle();
559        let clone = handle.clone();
560        handle.disconnect().await.expect("first disconnect");
561        // The clone's disconnect finds the lane handles already taken.
562        clone.disconnect().await.expect("second disconnect");
563    }
564}