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::protocol::ContextUpdate;
7use gemini_genai_rs::session::{SessionError, SessionHandle, SessionWriter};
8use parking_lot::Mutex;
9use serde::de::DeserializeOwned;
10use tokio::sync::{broadcast, mpsc};
11use tokio::task::JoinHandle;
12use tokio_util::sync::CancellationToken;
13
14use crate::flow::{FlowExplanation, FlowSnapshot, SharedFlowStack};
15use crate::state::State;
16
17use super::background_tool::BackgroundToolTracker;
18use super::context_writer::PendingContext;
19use super::effect_executor::LiveEffectExecutor;
20use super::input_vad::{
21 ActivityAuthority, BackendInputVad, BackendVadSnapshot, InputAudioProcessor,
22};
23use super::processor::ControlEvent;
24use super::reactor::{LiveReactor, ReactorEvent, VoiceRuntimeState};
25use super::telemetry::SessionTelemetry;
26use super::turn_commit::TurnSignal;
27
28/// Handle for interacting with a running Live session.
29///
30/// Provides send methods for audio/text/video, system instruction updates,
31/// event subscription, state access, telemetry, and graceful shutdown.
32///
33/// When [`ContextDelivery::Deferred`](super::steering::ContextDelivery::Deferred) is
34/// enabled, `send_audio`, `send_text`, and `send_video` automatically flush
35/// any pending context turns before forwarding the user content.
36#[derive(Clone)]
37pub struct LiveHandle {
38 session: SessionHandle,
39 /// Writer used for user-facing sends. When deferred context delivery is
40 /// enabled, this is a `DeferredWriter` that flushes pending context.
41 /// Otherwise it's the raw `SessionHandle`.
42 writer: Arc<dyn SessionWriter>,
43 /// Fast-lane task. Held in `Arc<Mutex<Option<..>>>` so `LiveHandle` stays
44 /// `Clone` while [`disconnect`](Self::disconnect) can take ownership to
45 /// grace-await and then abort the lane.
46 fast_task: Arc<Mutex<Option<JoinHandle<()>>>>,
47 /// Control-lane task (same ownership scheme as `fast_task`).
48 ctrl_task: Arc<Mutex<Option<JoinHandle<()>>>>,
49 /// Cancellation token for the telemetry lane, cancelled on disconnect.
50 telem_cancel: CancellationToken,
51 state: State,
52 telemetry: Arc<SessionTelemetry>,
53 event_tx: broadcast::Sender<super::events::LiveEvent>,
54 pending_context: Option<Arc<PendingContext>>,
55 reactor: Arc<LiveReactor>,
56 effect_executor: LiveEffectExecutor,
57 input_vad: Arc<Mutex<BackendInputVad>>,
58 /// Mic-chain processors run over outgoing audio inside `send_audio`.
59 input_processors: Arc<Mutex<Vec<Box<dyn InputAudioProcessor>>>>,
60 /// Whether this client's VAD sends activityStart/activityEnd marks.
61 client_activity_authority: Arc<std::sync::atomic::AtomicBool>,
62 /// Turn-commit policy between VAD edges and activity marks (None = raw
63 /// edge forwarding). See [`set_turn_commit`](Self::set_turn_commit).
64 turn_commit: Arc<Mutex<Option<super::turn_commit::TurnCommitPolicy>>>,
65 /// Monotonic audio clock in milliseconds, advanced by each chunk's
66 /// duration — the policy's time base (deterministic, not wall time).
67 audio_clock_ms: Arc<std::sync::atomic::AtomicU64>,
68 /// Governed-flow monitor shared with the control lane (None when the
69 /// session is not governed by a flow).
70 flow: Option<SharedFlowStack>,
71 task_status: Option<Arc<Mutex<crate::tasks::TaskSessionSnapshot>>>,
72 /// Tracker for in-flight background tool tasks. Shared with the control
73 /// lane (which spawns/cancels per-call tasks) so [`disconnect`](Self::disconnect)
74 /// can cancel every outstanding background tool — otherwise orphaned tasks
75 /// could keep running and post stale `ToolCompleted` events after shutdown.
76 background_tracker: Arc<BackgroundToolTracker>,
77 /// Control-lane sender used by [`send_text`](Self::send_text) to record the
78 /// typed turn on the transcript, so a text-driven session is visible to
79 /// extractors exactly as a spoken one is.
80 ///
81 /// Deliberately a [`WeakSender`](mpsc::WeakSender): the control channel must
82 /// still close once the router drops its strong sender, or the lane would
83 /// never drain and shut down.
84 ctrl_tx: Option<mpsc::WeakSender<ControlEvent>>,
85 /// Where the listener's playback is reported; shared with the router.
86 playback: super::playback::PlaybackClock,
87}
88
89impl LiveHandle {
90 #[allow(
91 clippy::too_many_arguments,
92 reason = "crate-internal constructor called once from spawn_lanes; the runtime parts are deliberately enumerated rather than re-bundled"
93 )]
94 pub(crate) fn new(
95 session: SessionHandle,
96 writer: Arc<dyn SessionWriter>,
97 fast_task: JoinHandle<()>,
98 ctrl_task: JoinHandle<()>,
99 state: State,
100 telemetry: Arc<SessionTelemetry>,
101 event_tx: broadcast::Sender<super::events::LiveEvent>,
102 pending_context: Option<Arc<PendingContext>>,
103 flow: Option<SharedFlowStack>,
104 background_tracker: Arc<BackgroundToolTracker>,
105 telem_cancel: CancellationToken,
106 ) -> Self {
107 let reactor = Arc::new(LiveReactor::voice_defaults().with_clock(state.clock()));
108 let state_clock = state.clock();
109 let effect_executor = LiveEffectExecutor::new(
110 Arc::new(session.clone()),
111 pending_context.clone(),
112 event_tx.clone(),
113 );
114
115 Self {
116 session,
117 writer,
118 fast_task: Arc::new(Mutex::new(Some(fast_task))),
119 ctrl_task: Arc::new(Mutex::new(Some(ctrl_task))),
120 telem_cancel,
121 state,
122 telemetry,
123 event_tx,
124 pending_context,
125 reactor,
126 effect_executor,
127 input_vad: Arc::new(Mutex::new(BackendInputVad::default())),
128 input_processors: Arc::new(Mutex::new(Vec::new())),
129 client_activity_authority: Arc::new(std::sync::atomic::AtomicBool::new(false)),
130 turn_commit: Arc::new(Mutex::new(None)),
131 audio_clock_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)),
132 flow,
133 task_status: None,
134 background_tracker,
135 ctrl_tx: None,
136 playback: super::playback::PlaybackClock::new(state_clock),
137 }
138 }
139
140 pub(crate) fn with_task_status(
141 mut self,
142 status: Option<Arc<Mutex<crate::tasks::TaskSessionSnapshot>>>,
143 ) -> Self {
144 self.task_status = status;
145 self
146 }
147
148 /// Observe the installed skill catalog and task lifecycle.
149 pub fn task_snapshot(&self) -> Option<crate::tasks::TaskSessionSnapshot> {
150 self.task_status
151 .as_ref()
152 .map(|status| status.lock().clone())
153 }
154
155 /// Apply a trusted task command on the control lane.
156 ///
157 /// Approval decisions belong here or in an authenticated application UI;
158 /// they are never exposed as model-callable tools.
159 pub async fn task_command(
160 &self,
161 command: crate::tasks::TaskCommand,
162 ) -> Result<crate::tasks::TaskSessionSnapshot, String> {
163 if self.task_status.is_none() {
164 return Err("this session has no installed skills".into());
165 }
166 let sender = self
167 .ctrl_tx
168 .as_ref()
169 .and_then(mpsc::WeakSender::upgrade)
170 .ok_or_else(|| "the session control lane has stopped".to_string())?;
171 let (reply, result) = tokio::sync::oneshot::channel();
172 sender
173 .send(ControlEvent::TaskCommand { command, reply })
174 .await
175 .map_err(|_| "the session control lane has stopped".to_string())?;
176 result
177 .await
178 .map_err(|_| "the session ended before the command completed".to_string())?
179 }
180
181 /// Attach the control-lane sender used to record typed turns on the
182 /// transcript. Called once from the builder's `spawn_lanes`.
183 pub(crate) fn with_control_sender(mut self, ctrl_tx: mpsc::WeakSender<ControlEvent>) -> Self {
184 self.ctrl_tx = Some(ctrl_tx);
185 self
186 }
187
188 /// Share the router's playback clock. Called once from `spawn_lanes`.
189 pub(crate) fn with_playback(mut self, playback: super::playback::PlaybackClock) -> Self {
190 self.playback = playback;
191 self
192 }
193
194 /// The session's playback clock. Whatever plays the model's audio to the
195 /// listener reports to it, so an interruption cuts the model's transcript
196 /// to what was heard. The voice pump and the telephony bridges report on
197 /// their own; a custom player calls
198 /// [`queued`](super::PlaybackClock::queued) as it plays audio and
199 /// [`flushed`](super::PlaybackClock::flushed) when it drops audio on
200 /// barge-in. See [`playback`](super::playback).
201 pub fn playback(&self) -> &super::playback::PlaybackClock {
202 &self.playback
203 }
204
205 /// Send audio data (raw PCM16 16kHz bytes).
206 ///
207 /// Configured input processors (see [`configure_input_audio`](Self::configure_input_audio))
208 /// run over the frame first; the backend input VAD then sees the
209 /// processed stream, and under client activity authority its speech
210 /// edges are forwarded to the server as activityStart/activityEnd.
211 /// When deferred context delivery is enabled, any pending model-role
212 /// context turns are flushed to the wire before the audio frame.
213 pub async fn send_audio(&self, data: impl Into<bytes::Bytes>) -> Result<(), SessionError> {
214 let data: bytes::Bytes = data.into();
215 // The active stage's voice timing (see `flow::timing`).
216 let timing = self
217 .state
218 .get::<crate::flow::VoiceTiming>(crate::flow::VOICE_TIMING_KEY);
219 // A stage that holds the floor (a statutory readout) replaces the
220 // mic with silence while the model speaks, so neither the server's
221 // VAD nor ours can cut the model off. The stream keeps its cadence.
222 let data = if timing
223 .as_ref()
224 .is_some_and(crate::flow::VoiceTiming::holds_floor)
225 && self
226 .state
227 .session()
228 .get::<bool>("is_model_speaking")
229 .unwrap_or(false)
230 {
231 bytes::Bytes::from(vec![0u8; data.len()])
232 } else {
233 data
234 };
235 let data = {
236 let mut processors = self.input_processors.lock();
237 if processors.is_empty() {
238 data
239 } else {
240 let mut frame: Vec<i16> = data
241 .as_chunks::<2>()
242 .0
243 .iter()
244 .map(|b| i16::from_le_bytes([b[0], b[1]]))
245 .collect();
246 for processor in processors.iter_mut() {
247 processor.process_frame(&mut frame);
248 }
249 frame.iter().flat_map(|s| s.to_le_bytes()).collect()
250 }
251 };
252 let (vad_events, sample_rate) = {
253 let mut input_vad = self.input_vad.lock();
254 (input_vad.process_pcm_bytes(&data), input_vad.sample_rate())
255 };
256 let client_authority = self
257 .client_activity_authority
258 .load(std::sync::atomic::Ordering::Relaxed);
259
260 // Advance the audio clock by this chunk's duration and run the
261 // turn-commit policy (if configured) over the observed edges. The
262 // policy decides which activity marks reach the wire; raw-edge
263 // callbacks below stay untouched so watchers and transcript
264 // bookkeeping see VAD truth either way.
265 let commit_signals = {
266 let samples = (data.len() / 2) as u64;
267 let chunk_ms = samples * 1000 / u64::from(sample_rate.max(1));
268 let now_ms = chunk_ms
269 + self
270 .audio_clock_ms
271 .fetch_add(chunk_ms, std::sync::atomic::Ordering::Relaxed);
272 let mut policy = self.turn_commit.lock();
273 // A stage's endpointing sets the end-of-turn hold. It needs a
274 // turn-commit policy, so one is installed (otherwise edges pass
275 // through unchanged) under client authority.
276 if let Some(hold) = timing
277 .as_ref()
278 .and_then(|t| t.end_of_speech_ms)
279 .map(std::time::Duration::from_millis)
280 && client_authority
281 {
282 let policy = policy.get_or_insert_with(|| {
283 super::turn_commit::TurnCommitPolicy::new(
284 super::turn_commit::TurnCommitConfig::immediate(),
285 )
286 });
287 if policy.config().eot_hold != hold {
288 policy.set_eot_hold(hold);
289 }
290 }
291 policy.as_mut().map(|p| {
292 let model_speaking = self
293 .state
294 .session()
295 .get::<bool>("is_model_speaking")
296 .unwrap_or(false);
297 p.advance(now_ms, &vad_events, model_speaking)
298 })
299 };
300
301 if vad_events.contains(&VadEvent::SpeechStart) {
302 if client_authority && commit_signals.is_none() {
303 self.writer.signal_activity_start().await?;
304 }
305 self.user_speech_started().await?;
306 }
307 if let Some(signals) = &commit_signals {
308 // Start-type commits go out before the audio, like raw marks.
309 for signal in signals {
310 if client_authority
311 && matches!(
312 signal,
313 TurnSignal::ActivityStart | TurnSignal::InterruptionStart
314 )
315 {
316 self.writer.signal_activity_start().await?;
317 }
318 }
319 }
320
321 self.writer.send_audio(data).await?;
322
323 if vad_events.contains(&VadEvent::SpeechEnd) {
324 if client_authority && commit_signals.is_none() {
325 self.writer.signal_activity_end().await?;
326 }
327 self.user_speech_ended().await?;
328 }
329 if let Some(signals) = &commit_signals {
330 for signal in signals {
331 if client_authority && matches!(signal, TurnSignal::ActivityEnd) {
332 self.writer.signal_activity_end().await?;
333 }
334 }
335 }
336
337 Ok(())
338 }
339
340 /// Install a turn-commit policy between the input VAD's speech edges and
341 /// the activity marks sent under client activity authority.
342 ///
343 /// Raw edges make two measured mistakes as turn signals (TurnBench dev
344 /// set): committing end-of-turn during mid-turn pauses, and treating
345 /// backchannels ("mm-hm") over model speech as barge-ins. The policy's
346 /// end-hold and interruption-sustain rules suppress both — see
347 /// [`TurnCommitConfig`](super::turn_commit::TurnCommitConfig) for the
348 /// measured operating points. Without a
349 /// policy, edges forward to the wire unchanged.
350 pub fn set_turn_commit(&self, config: super::turn_commit::TurnCommitConfig) {
351 *self.turn_commit.lock() = Some(super::turn_commit::TurnCommitPolicy::new(config));
352 }
353
354 /// Configure the input audio path: mic-chain processors applied inside
355 /// [`send_audio`](Self::send_audio), an optional replacement input-VAD
356 /// configuration, and the interruption authority. Call once after
357 /// connect, before streaming audio; a mid-stream call resets the VAD's
358 /// adaptive state.
359 pub fn configure_input_audio(
360 &self,
361 processors: Vec<Box<dyn InputAudioProcessor>>,
362 vad: Option<gemini_genai_rs::vad::VadConfig>,
363 authority: ActivityAuthority,
364 ) {
365 *self.input_processors.lock() = processors;
366 if let Some(config) = vad {
367 *self.input_vad.lock() = BackendInputVad::new(config);
368 }
369 self.client_activity_authority.store(
370 authority == ActivityAuthority::Client,
371 std::sync::atomic::Ordering::Relaxed,
372 );
373 }
374
375 /// Send a text message.
376 ///
377 /// When deferred context delivery is enabled, any pending model-role
378 /// context turns are flushed to the wire before the text message.
379 ///
380 /// The text is also recorded on the session transcript as the user side of
381 /// the current turn, through the same internal control event that ASR of
382 /// audio produces. Without this a text-driven session would hand every
383 /// [`TurnExtractor`](super::extractor::TurnExtractor) an empty user turn,
384 /// since the transcript's user side is otherwise written only by ASR.
385 /// Routing through the control event rather than poking the buffer keeps a
386 /// typed turn and a spoken one the *same* event downstream.
387 pub async fn send_text(&self, text: impl Into<String>) -> Result<(), SessionError> {
388 let text = text.into();
389 self.telemetry.record_text_send();
390 self.writer.send_text(text.clone()).await?;
391
392 // Record only after a *successful* send: a turn the model never
393 // received is not part of the conversation.
394 if let Some(tx) = self.ctrl_tx.as_ref().and_then(mpsc::WeakSender::upgrade) {
395 let _ = tx.send(ControlEvent::InputTranscript(text)).await;
396 }
397
398 Ok(())
399 }
400
401 /// Send a video/image frame (raw JPEG bytes).
402 ///
403 /// When deferred context delivery is enabled, any pending model-role
404 /// context turns are flushed to the wire before the video frame.
405 pub async fn send_video(&self, jpeg_data: impl Into<bytes::Bytes>) -> Result<(), SessionError> {
406 self.writer.send_video(jpeg_data.into()).await
407 }
408
409 /// Update the system instruction mid-session.
410 pub async fn update_instruction(
411 &self,
412 instruction: impl Into<String>,
413 ) -> Result<(), SessionError> {
414 SessionWriter::update_instruction(&self.session, instruction.into()).await
415 }
416
417 /// Replace the declared tools and/or the system instruction mid-session
418 /// with a `contextUpdate` message (Gemini 3.8 Live). See
419 /// [`SessionHandle::update_context`](gemini_genai_rs::session::SessionHandle::update_context).
420 ///
421 /// With [`SteeringMode::ContextUpdate`](crate::live::SteeringMode::ContextUpdate)
422 /// the runtime sends these itself at phase and step changes; call this
423 /// only for changes the runtime does not know about.
424 pub async fn update_context(&self, update: ContextUpdate) -> Result<(), SessionError> {
425 SessionWriter::update_context(&self.session, update).await
426 }
427
428 /// Send tool responses manually (if not using auto-dispatch).
429 pub async fn send_tool_response(
430 &self,
431 responses: Vec<FunctionResponse>,
432 ) -> Result<(), SessionError> {
433 self.session.send_tool_response(responses).await
434 }
435
436 /// Notify the runtime that client-side playback has drained.
437 ///
438 /// Voice UIs should call this only when it is safe for the model to speak,
439 /// for example after browser speaker playback has drained and the user is
440 /// not actively speaking. User audio/text sends intentionally flush context
441 /// only and leave the prompt armed.
442 pub async fn playback_drained(&self) -> Result<(), SessionError> {
443 let prompt_pending = self
444 .pending_context
445 .as_ref()
446 .is_some_and(|pending| pending.has_prompt());
447 let reactions = self
448 .reactor
449 .react(&ReactorEvent::PlaybackDrained { prompt_pending });
450 self.effect_executor.execute_reactions(reactions).await
451 }
452
453 /// Notify the runtime that client-side user speech has started.
454 ///
455 /// This is the barge-in edge for voice clients: pending model prompts are
456 /// cancelled before they can race with user audio, while queued context is
457 /// kept so the next user send can still carry it.
458 pub async fn user_speech_started(&self) -> Result<(), SessionError> {
459 let reactions = self.reactor.react(&ReactorEvent::UserSpeechStarted);
460 self.effect_executor.execute_reactions(reactions).await
461 }
462
463 /// Notify the runtime that client-side user speech has ended.
464 pub async fn user_speech_ended(&self) -> Result<(), SessionError> {
465 let prompt_pending = self
466 .pending_context
467 .as_ref()
468 .is_some_and(|pending| pending.has_prompt());
469 let reactions = self
470 .reactor
471 .react(&ReactorEvent::UserSpeechEnded { prompt_pending });
472 self.effect_executor.execute_reactions(reactions).await
473 }
474
475 /// Snapshot the reactor-owned voice runtime state.
476 pub fn voice_state(&self) -> VoiceRuntimeState {
477 self.reactor.voice_state()
478 }
479
480 /// Snapshot backend input VAD state.
481 pub fn input_vad_state(&self) -> BackendVadSnapshot {
482 self.input_vad.lock().snapshot()
483 }
484
485 /// Flush deferred context and any pending model prompt.
486 ///
487 /// Prefer [`Self::playback_drained`] for voice clients. This compatibility
488 /// method routes through the same reactor/effect executor path.
489 pub async fn flush_deferred_prompt(&self) -> Result<(), SessionError> {
490 self.playback_drained().await
491 }
492
493 /// Get the user-facing session writer.
494 ///
495 /// When deferred context delivery is enabled, this returns the
496 /// `DeferredWriter` that flushes pending context before sends.
497 pub fn writer(&self) -> Arc<dyn SessionWriter> {
498 self.writer.clone()
499 }
500
501 /// Subscribe to raw session events (for custom processing).
502 pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
503 self.session.subscribe()
504 }
505
506 /// Get the current session phase.
507 pub fn phase(&self) -> SessionPhase {
508 self.session.phase()
509 }
510
511 /// Gracefully disconnect the session.
512 ///
513 /// Shutdown sequence:
514 /// 1. Cancel all in-flight background tool tasks (they are aborted at an
515 /// await point; tool futures must therefore be drop-safe).
516 /// 2. Close the L0 session. The terminal `Disconnected` event makes the
517 /// event router exit, which closes the lane channels.
518 /// 3. Grace-await the fast and control lanes (~250 ms each) so they can
519 /// drain queued events and run their final persistence drain, then
520 /// abort whatever is still stuck (e.g. a lane blocked in a slow tool).
521 /// 4. Cancel the telemetry lane.
522 pub async fn disconnect(&self) -> Result<(), SessionError> {
523 if self.task_status.is_some()
524 && let Some(sender) = self.ctrl_tx.as_ref().and_then(mpsc::WeakSender::upgrade)
525 {
526 let (reply, result) = tokio::sync::oneshot::channel();
527 let _ = tokio::time::timeout(Self::LANE_SHUTDOWN_GRACE, async {
528 if sender
529 .send(ControlEvent::ShutdownTasks(reply))
530 .await
531 .is_ok()
532 {
533 let _ = result.await;
534 }
535 })
536 .await;
537 }
538 // Cancel background tool tasks FIRST: once the session is closing,
539 // their results can no longer be delivered, and leaving them running
540 // would let them post stale ToolCompleted events to a dead lane.
541 self.background_tracker.cancel_all();
542 let result = SessionWriter::disconnect(&self.session).await;
543
544 // Grace-await the lanes, then abort. Taking the JoinHandles out of
545 // their mutexes gives us the ownership `await` requires; a second
546 // disconnect (or a clone's disconnect) simply finds them gone.
547 for lane in [&self.fast_task, &self.ctrl_task] {
548 let task = lane.lock().take();
549 if let Some(mut task) = task
550 && tokio::time::timeout(Self::LANE_SHUTDOWN_GRACE, &mut task)
551 .await
552 .is_err()
553 {
554 task.abort();
555 }
556 }
557
558 // Stop the telemetry lane now rather than when the router exits; until
559 // then it would keep running its debounce timer.
560 self.telem_cancel.cancel();
561 result
562 }
563
564 /// How long [`disconnect`](Self::disconnect) waits for each lane to drain
565 /// before aborting it.
566 const LANE_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
567
568 /// Wait for the session to end (disconnect, GoAway, or error).
569 pub async fn done(&self) -> Result<(), SessionError> {
570 self.session
571 .join()
572 .await
573 .map_err(|_| SessionError::ChannelClosed)
574 }
575
576 /// Get the underlying SessionHandle for advanced usage.
577 pub fn session(&self) -> &SessionHandle {
578 &self.session
579 }
580
581 /// Latest session-resumption handle issued by the server, if any.
582 ///
583 /// While session resumption is enabled
584 /// ([`SessionConfig::session_resumption`](gemini_genai_rs::prelude::SessionConfig::session_resumption);
585 /// L2: `Live::builder().session_resume(true)`), the Gemini server
586 /// periodically sends `SessionResumptionUpdate` messages; this returns the
587 /// most recent handle (also captured in persistence snapshots as
588 /// [`SessionSnapshot::resume_handle`](crate::live::persistence::SessionSnapshot::resume_handle)).
589 ///
590 /// To survive a server-initiated `GoAway` or a planned restart, read this
591 /// handle (e.g. from the `on_go_away` callback) and pass it to
592 /// `resume_from(handle)` on the next connect's
593 /// [`SessionConfig`](gemini_genai_rs::prelude::SessionConfig). No
594 /// automatic reconnect is performed — resumption is an explicit caller
595 /// decision.
596 ///
597 /// Returns `None` when resumption is disabled or no update has arrived yet.
598 pub fn resume_handle(&self) -> Option<String> {
599 self.session.resume_handle()
600 }
601
602 /// Access the shared State container.
603 ///
604 /// Extraction results from `TurnExtractor`s are stored here under the
605 /// extractor's name. Use `state().get::<T>(name)` to read typed values.
606 pub fn state(&self) -> &State {
607 &self.state
608 }
609
610 /// Access the session telemetry (auto-collected by the telemetry lane).
611 ///
612 /// Use `telemetry().snapshot()` to get a JSON snapshot of all metrics.
613 pub fn telemetry(&self) -> &Arc<SessionTelemetry> {
614 &self.telemetry
615 }
616
617 /// Subscribe to semantic events from the processor.
618 ///
619 /// Returns a broadcast receiver. Call multiple times for independent
620 /// subscribers. A subscriber that falls more than
621 /// [`LiveSessionBuilder::event_capacity`](super::builder::LiveSessionBuilder::event_capacity)
622 /// events behind receives `RecvError::Lagged` and skips the oldest.
623 pub fn events(&self) -> broadcast::Receiver<super::events::LiveEvent> {
624 self.event_tx.subscribe()
625 }
626
627 /// Subscribe to semantic events as a [`futures_util::Stream`].
628 ///
629 /// Stream-flavored sibling of [`events`](Self::events): each call creates
630 /// an independent subscriber starting from the current point in the event
631 /// flow. If the subscriber falls behind the broadcast buffer, the missed
632 /// events are skipped and the stream continues; the stream ends when the
633 /// session's event channel closes. See
634 /// [`LiveEventStream`](super::events::LiveEventStream).
635 ///
636 /// # Example
637 ///
638 /// ```rust,ignore
639 /// use futures_util::StreamExt;
640 ///
641 /// let mut stream = handle.stream();
642 /// while let Some(ev) = stream.next().await {
643 /// match ev {
644 /// LiveEvent::TextDelta(t) => print!("{t}"),
645 /// LiveEvent::TurnComplete => println!(),
646 /// _ => {}
647 /// }
648 /// }
649 /// ```
650 pub fn stream(&self) -> super::events::LiveEventStream {
651 super::events::LiveEventStream::new(self.event_tx.subscribe())
652 }
653
654 /// Convenience: get the latest extraction result by extractor name.
655 pub fn extracted<T: DeserializeOwned>(&self, name: &str) -> Option<T> {
656 self.state.get(name)
657 }
658
659 /// Snapshot the governed flow's control-plane state: active steps, which
660 /// tools are admitted vs blocked (with reasons), and unmet requirements.
661 ///
662 /// The deterministic answer to "why did the assistant ask that?" — computed
663 /// against the live [`State`] and the marking the control lane maintains.
664 /// Returns `None` when the session is not governed by a flow
665 /// (`Live::govern`/`observe` was not used).
666 ///
667 /// When a digression is active the snapshot describes that layer; the
668 /// `flow:overlay` state key names it.
669 ///
670 /// This is a synchronous snapshot: it briefly locks the shared
671 /// [`FlowStack`](crate::flow::FlowStack) and never blocks on session
672 /// I/O.
673 pub fn explain(&self) -> Option<FlowExplanation> {
674 self.flow
675 .as_ref()
676 .map(|mon| mon.lock().explain(&self.state))
677 }
678
679 /// Observe governed steps, digressions, and whole-stack completion.
680 ///
681 /// Returns `None` for an ungoverned session. Reads the stack under one
682 /// short lock without session I/O; shared business state can still change
683 /// independently. The result is not a resumable session checkpoint.
684 pub fn flow_snapshot(&self) -> Option<FlowSnapshot> {
685 self.flow
686 .as_ref()
687 .map(|stack| stack.lock().snapshot(&self.state))
688 }
689
690 /// Replace a governed step's posture mid-session. Returns `true` when the
691 /// session is governed and the step exists.
692 ///
693 /// Postures are re-projected at every turn boundary, so the edit steers
694 /// the very next turn. This is the *safe* subset of live spec editing:
695 /// the DAG, guards, and tool gates stay fixed (tool declarations cannot
696 /// change mid-session at the wire level anyway).
697 pub fn update_step_posture(&self, step_id: &str, posture: Option<String>) -> bool {
698 self.flow
699 .as_ref()
700 .map(|mon| mon.lock().set_posture(step_id, posture))
701 .unwrap_or(false)
702 }
703
704 /// Replace a governed step's grounding template mid-session. Same
705 /// semantics as [`update_step_posture`](Self::update_step_posture).
706 pub fn update_step_ground(&self, step_id: &str, ground: Option<String>) -> bool {
707 self.flow
708 .as_ref()
709 .map(|mon| mon.lock().set_ground(step_id, ground))
710 .unwrap_or(false)
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use crate::live::telemetry::SessionTelemetry;
718 use gemini_genai_rs::session::{SessionCommand, SessionState};
719 use tokio_util::sync::CancellationToken;
720
721 /// Build a LiveHandle wired to an in-memory SessionHandle (no transport).
722 /// The command receiver is returned so `disconnect()` sends succeed.
723 fn make_handle_with_lanes(
724 fast: JoinHandle<()>,
725 ctrl: JoinHandle<()>,
726 ) -> (LiveHandle, tokio::sync::mpsc::Receiver<SessionCommand>) {
727 let (command_tx, command_rx) = tokio::sync::mpsc::channel(8);
728 let (event_tx, _) = broadcast::channel(16);
729 let (phase_tx, phase_rx) = tokio::sync::watch::channel(SessionPhase::Active);
730 let state = Arc::new(SessionState::with_events(phase_tx, event_tx.clone()));
731 let session = SessionHandle::new(command_tx, event_tx, state, phase_rx);
732 let writer: Arc<dyn SessionWriter> = Arc::new(session.clone());
733 let (live_tx, _) = broadcast::channel(16);
734 let handle = LiveHandle::new(
735 session,
736 writer,
737 fast,
738 ctrl,
739 State::new(),
740 Arc::new(SessionTelemetry::new()),
741 live_tx,
742 None,
743 None,
744 Arc::new(BackgroundToolTracker::new()),
745 CancellationToken::new(),
746 );
747 (handle, command_rx)
748 }
749
750 fn make_handle() -> (LiveHandle, tokio::sync::mpsc::Receiver<SessionCommand>) {
751 make_handle_with_lanes(tokio::spawn(async {}), tokio::spawn(async {}))
752 }
753
754 /// Like [`make_handle`] but also hands back the L0 session state, for
755 /// tests that simulate what the transport writes there.
756 fn make_handle_and_state() -> (
757 LiveHandle,
758 tokio::sync::mpsc::Receiver<SessionCommand>,
759 Arc<SessionState>,
760 ) {
761 let (command_tx, command_rx) = tokio::sync::mpsc::channel(8);
762 let (event_tx, _) = broadcast::channel(16);
763 let (phase_tx, phase_rx) = tokio::sync::watch::channel(SessionPhase::Active);
764 let state = Arc::new(SessionState::with_events(phase_tx, event_tx.clone()));
765 let session = SessionHandle::new(command_tx, event_tx, state.clone(), phase_rx);
766 let writer: Arc<dyn SessionWriter> = Arc::new(session.clone());
767 let (live_tx, _) = broadcast::channel(16);
768 let handle = LiveHandle::new(
769 session,
770 writer,
771 tokio::spawn(async {}),
772 tokio::spawn(async {}),
773 State::new(),
774 Arc::new(SessionTelemetry::new()),
775 live_tx,
776 None,
777 None,
778 Arc::new(BackgroundToolTracker::new()),
779 CancellationToken::new(),
780 );
781 (handle, command_rx, state)
782 }
783
784 /// Sets a flag when dropped — observes that an aborted task's future was
785 /// actually torn down.
786 struct SetOnDrop(Arc<std::sync::atomic::AtomicBool>);
787 impl Drop for SetOnDrop {
788 fn drop(&mut self) {
789 self.0.store(true, std::sync::atomic::Ordering::SeqCst);
790 }
791 }
792
793 #[tokio::test]
794 async fn disconnect_cancels_background_tool_tasks() {
795 let (handle, _cmd_rx) = make_handle();
796 let tracker = handle.background_tracker.clone();
797
798 // Register a never-finishing background tool task.
799 let token = CancellationToken::new();
800 let t = token.clone();
801 let task = tokio::spawn(async move {
802 t.cancelled().await;
803 std::future::pending::<()>().await;
804 });
805 tracker.spawn("call-1".into(), task, token.clone());
806 assert_eq!(tracker.active_count(), 1);
807
808 handle.disconnect().await.expect("disconnect");
809
810 assert_eq!(
811 tracker.active_count(),
812 0,
813 "disconnect must cancel all tracked background tool tasks"
814 );
815 assert!(token.is_cancelled(), "cooperative token must be cancelled");
816 }
817
818 #[tokio::test]
819 async fn disconnect_aborts_stuck_lanes_within_grace_period() {
820 use std::sync::atomic::{AtomicBool, Ordering};
821
822 // Lanes that never finish on their own (simulating a lane blocked in a
823 // slow tool); drop guards record that abort tore the futures down.
824 let fast_dropped = Arc::new(AtomicBool::new(false));
825 let ctrl_dropped = Arc::new(AtomicBool::new(false));
826 let f = fast_dropped.clone();
827 let c = ctrl_dropped.clone();
828 let fast = tokio::spawn(async move {
829 let _guard = SetOnDrop(f);
830 std::future::pending::<()>().await;
831 });
832 let ctrl = tokio::spawn(async move {
833 let _guard = SetOnDrop(c);
834 std::future::pending::<()>().await;
835 });
836
837 let (handle, _cmd_rx) = make_handle_with_lanes(fast, ctrl);
838 let telem_cancel = handle.telem_cancel.clone();
839
840 // disconnect() must return in bounded time even with stuck lanes.
841 tokio::time::timeout(std::time::Duration::from_secs(2), handle.disconnect())
842 .await
843 .expect("disconnect must not hang on stuck lanes")
844 .expect("disconnect");
845
846 // Give the aborts a beat to take effect, then verify teardown.
847 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
848 assert!(
849 fast_dropped.load(Ordering::SeqCst),
850 "fast lane must be aborted after the grace period"
851 );
852 assert!(
853 ctrl_dropped.load(Ordering::SeqCst),
854 "control lane must be aborted after the grace period"
855 );
856 assert!(
857 telem_cancel.is_cancelled(),
858 "telemetry lane must be cancelled on disconnect"
859 );
860 }
861
862 #[tokio::test]
863 async fn resume_handle_surfaces_latest_server_handle() {
864 let (handle, _cmd_rx, state) = make_handle_and_state();
865 assert_eq!(handle.resume_handle(), None, "no update yet");
866
867 // Simulate the L0 transport storing a SessionResumptionUpdate.
868 *state.resume_handle.lock() = Some("rh-42".into());
869 assert_eq!(handle.resume_handle(), Some("rh-42".to_string()));
870 }
871
872 #[tokio::test]
873 async fn disconnect_is_idempotent_across_clones() {
874 let (handle, _cmd_rx) = make_handle();
875 let clone = handle.clone();
876 handle.disconnect().await.expect("first disconnect");
877 // The clone's disconnect finds the lane handles already taken.
878 clone.disconnect().await.expect("second disconnect");
879 }
880}