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