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}