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}