Skip to main content

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}