gemini_adk_rs/live/
handle.rs

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