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}