Skip to main content

gemini_adk_rs/live/
processor.rs

1//! Three-lane event processor for Live sessions.
2//!
3//! **Fast lane**: audio, text, VAD (sync callbacks, never blocks)
4//! **Control lane**: tool calls, interruptions, lifecycle, transcript accumulation,
5//!   extractors, phases, watchers (async callbacks, can block)
6//! **Telemetry lane**: SessionSignals state writes, the usage callback and the
7//!   100 ms timing flush. The router records SessionTelemetry's atomic counters
8//!   inline and forwards only the events that write state, so audio and text
9//!   deltas never wake this lane.
10
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::time::Duration;
14
15use bytes::Bytes;
16use tokio::sync::{broadcast, mpsc};
17use tokio_util::sync::CancellationToken;
18use tracing::Instrument;
19
20use gemini_genai_rs::prelude::{SessionEvent, SessionPhase};
21use gemini_genai_rs::session::SessionWriter;
22
23use crate::state::State;
24use crate::tool::ToolDispatcher;
25
26use super::background_tool::BackgroundToolTracker;
27use super::callbacks::EventCallbacks;
28use super::computed::ComputedRegistry;
29use super::context_writer::PendingContext;
30use super::control_plane::run_control_lane;
31use super::events::LiveEvent;
32use super::extractor::TurnExtractor;
33use super::needs::NeedsFulfillment;
34use super::persistence::SessionPersistence;
35use super::phase::PhaseMachine;
36use super::session_signals::SessionSignals;
37use super::soft_turn::SoftTurnDetector;
38use super::steering::{ContextDelivery, SteeringMode};
39use super::telemetry::SessionTelemetry;
40use super::temporal::TemporalRegistry;
41use super::watcher::WatcherRegistry;
42
43/// Backpressure (delivery) policy for a single class of fast-lane events.
44///
45/// The event router forwards fast-lane frames (audio, text, transcripts,
46/// thoughts, VAD, phase) over a bounded channel to the fast-lane consumer. When
47/// that consumer falls behind and the channel fills, the policy decides what the
48/// router does — and crucially, whether the router *blocks*. Because the router
49/// is shared by both the fast lane and the control lane, a blocking fast-lane
50/// send stalls routing for *all* events, including control-lane lifecycle and
51/// tool events. The policy lets callers trade frame durability for router
52/// responsiveness on a per-class basis.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum Delivery {
55    /// Never drop a frame: `tx.send(ev).await` — the router awaits when the
56    /// channel is full. This is the historical (and default) behavior and is
57    /// byte-for-byte identical to the pre-policy code path. Use it when every
58    /// frame matters and a slow consumer applying backpressure to the router is
59    /// acceptable.
60    #[default]
61    Lossless,
62    /// Drop the *newest* frame on overflow: `tx.try_send(ev)` and, on
63    /// [`TrySendError::Full`](tokio::sync::mpsc::error::TrySendError::Full),
64    /// discard the just-produced frame and bump a dropped-frame counter. The
65    /// router never blocks on this class, so a slow fast-lane consumer can no
66    /// longer stall control-lane routing. Use it for high-frequency, loss-
67    /// tolerant streams (e.g. partial transcripts, thoughts) where freshness of
68    /// already-queued frames matters less than keeping the router moving.
69    ///
70    /// A drop-oldest / latest-only variant is intentionally *not* provided:
71    /// tokio's `mpsc` has no clean "evict the oldest queued item" primitive, so
72    /// implementing it correctly would require a custom ring buffer. That is
73    /// left as future work rather than shipped half-working.
74    LossyDropNewest,
75}
76
77/// Per-event-class delivery (backpressure) policy for the fast lane.
78///
79/// Each fast-lane event class carries its own [`Delivery`] policy. The
80/// [`Default`] impl sets **every** class to [`Delivery::Lossless`], which makes
81/// the whole feature behavior-preserving: with the default config the router
82/// uses the same `send().await` path it always has. Callers opt into lossy
83/// behavior per class via the builder setters.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct DeliveryConfig {
86    /// Policy for raw PCM audio frames.
87    pub audio: Delivery,
88    /// Policy for non-audio media chunks (Live Avatar video).
89    pub media: Delivery,
90    /// Policy for incremental text deltas (and text-complete frames).
91    pub text: Delivery,
92    /// Policy for input/output transcript frames (fast-lane callback copy only;
93    /// control-lane accumulation is unaffected and always lossless).
94    pub transcript: Delivery,
95    /// Policy for thought-summary frames.
96    pub thought: Delivery,
97    /// Policy for VAD start/end frames.
98    pub vad: Delivery,
99    /// Policy for phase-changed frames.
100    pub phase: Delivery,
101}
102
103impl Default for DeliveryConfig {
104    fn default() -> Self {
105        Self {
106            audio: Delivery::Lossless,
107            media: Delivery::Lossless,
108            text: Delivery::Lossless,
109            transcript: Delivery::Lossless,
110            thought: Delivery::Lossless,
111            vad: Delivery::Lossless,
112            phase: Delivery::Lossless,
113        }
114    }
115}
116
117impl DeliveryConfig {
118    /// A config with every class set to [`Delivery::Lossless`] (same as
119    /// [`Default`]).
120    pub fn lossless() -> Self {
121        Self::default()
122    }
123
124    /// Set the audio policy.
125    pub fn audio(mut self, d: Delivery) -> Self {
126        self.audio = d;
127        self
128    }
129
130    /// Set the media (Live Avatar video) policy.
131    pub fn media(mut self, d: Delivery) -> Self {
132        self.media = d;
133        self
134    }
135
136    /// Set the text policy.
137    pub fn text(mut self, d: Delivery) -> Self {
138        self.text = d;
139        self
140    }
141
142    /// Set the transcript policy.
143    pub fn transcript(mut self, d: Delivery) -> Self {
144        self.transcript = d;
145        self
146    }
147
148    /// Set the thought policy.
149    pub fn thought(mut self, d: Delivery) -> Self {
150        self.thought = d;
151        self
152    }
153
154    /// Set the VAD policy.
155    pub fn vad(mut self, d: Delivery) -> Self {
156        self.vad = d;
157        self
158    }
159
160    /// Set the phase policy.
161    pub fn phase(mut self, d: Delivery) -> Self {
162        self.phase = d;
163        self
164    }
165}
166
167/// Per-class counters for fast-lane frames dropped under a lossy policy.
168///
169/// Incremented with a single relaxed atomic add on the router hot path when a
170/// [`Delivery::LossyDropNewest`] send overflows. Reads are for observability /
171/// tests and never gate the hot path.
172#[derive(Debug, Default)]
173pub(crate) struct DroppedFrames {
174    pub audio: AtomicU64,
175    pub media: AtomicU64,
176    pub text: AtomicU64,
177    pub transcript: AtomicU64,
178    pub thought: AtomicU64,
179    pub vad: AtomicU64,
180    pub phase: AtomicU64,
181}
182
183impl DroppedFrames {
184    /// Total dropped frames across all classes.
185    ///
186    /// Currently only consumed by tests; the per-class atomics are read
187    /// directly elsewhere. Kept test-gated until a handle accessor surfaces it.
188    #[cfg(test)]
189    pub(crate) fn total(&self) -> u64 {
190        self.audio.load(Ordering::Relaxed)
191            + self.media.load(Ordering::Relaxed)
192            + self.text.load(Ordering::Relaxed)
193            + self.transcript.load(Ordering::Relaxed)
194            + self.thought.load(Ordering::Relaxed)
195            + self.vad.load(Ordering::Relaxed)
196            + self.phase.load(Ordering::Relaxed)
197    }
198}
199
200/// Forward one fast-lane frame according to its class delivery policy.
201///
202/// - [`Delivery::Lossless`]: `tx.send(ev).await` — awaits when the channel is
203///   full (identical to the pre-policy behavior).
204/// - [`Delivery::LossyDropNewest`]: `tx.try_send(ev)` — on a full channel, drop
205///   the frame and increment `dropped`.
206///
207/// Returns without ever blocking the router under a lossy policy.
208async fn deliver_fast(
209    tx: &mpsc::Sender<FastEvent>,
210    ev: FastEvent,
211    policy: Delivery,
212    dropped: &AtomicU64,
213) {
214    match policy {
215        Delivery::Lossless => {
216            let _ = tx.send(ev).await;
217        }
218        Delivery::LossyDropNewest => {
219            if let Err(mpsc::error::TrySendError::Full(_)) = tx.try_send(ev) {
220                dropped.fetch_add(1, Ordering::Relaxed);
221            }
222            // `TrySendError::Closed` is ignored, matching the `let _ = send`
223            // pattern used elsewhere (the consumer is gone; nothing to do).
224        }
225    }
226}
227
228/// Events routed to the fast lane (sync processing).
229pub(crate) enum FastEvent {
230    Audio(Bytes),
231    Media(gemini_genai_rs::session::InlineMedia),
232    Text(String),
233    TextComplete(String),
234    InputTranscript(String),
235    OutputTranscript(String),
236    Thought(String),
237    VadStart,
238    VadEnd,
239    Phase(SessionPhase),
240    /// Interruption flag — tells fast lane to stop forwarding audio.
241    Interrupted,
242    /// Replay lockstep: answered once everything queued before it is handled.
243    Barrier(tokio::sync::oneshot::Sender<()>),
244}
245
246/// Events routed to the control lane (async processing).
247pub(crate) enum ControlEvent {
248    /// Replay lockstep: answered once everything queued before it is handled.
249    Barrier(tokio::sync::oneshot::Sender<()>),
250    ToolCall(Vec<gemini_genai_rs::prelude::FunctionCall>),
251    TaskCommand {
252        command: crate::tasks::TaskCommand,
253        reply: tokio::sync::oneshot::Sender<Result<crate::tasks::TaskSessionSnapshot, String>>,
254    },
255    TaskCompleted(crate::tasks::InvocationCompletion),
256    TaskServicesCompleted(crate::tasks::TaskServiceCompletion),
257    TaskTimer,
258    ShutdownTasks(tokio::sync::oneshot::Sender<()>),
259    ToolCallCancelled(Vec<String>),
260    /// A background tool finished. Posted by the detached background task (which
261    /// can't reach the synchronous `FlowMonitor`) so the control lane can advance
262    /// the governed flow through the same gate as inline tools (#7).
263    ToolCompleted {
264        /// The tool call's correlation id (for once-per-call_id flow dedup).
265        call_id: String,
266        /// The tool name (matches `FunctionCall::name`).
267        name: String,
268        /// Whether the tool completed successfully.
269        ok: bool,
270    },
271    /// The model was interrupted. `heard_chars`: how many of the turn's
272    /// output transcript characters the listener heard, when it can be told.
273    Interrupted {
274        heard_chars: Option<usize>,
275    },
276    TurnComplete,
277    /// Model finished generating (even if interrupted). Fires before TurnComplete.
278    GenerationComplete,
279    GoAway(Option<std::time::Duration>),
280    Connected,
281    Disconnected(Option<String>),
282    SessionResumeUpdate(gemini_genai_rs::session::ResumeInfo),
283    Error(String),
284    /// Transcript accumulation — pushed from router, exclusive to control lane.
285    InputTranscript(String),
286    OutputTranscript(String),
287}
288
289/// Shared state between the two lanes.
290pub(crate) struct SharedState {
291    /// When true, fast lane suppresses audio callbacks.
292    pub interrupted: AtomicBool,
293    /// Barge-in signal for in-flight inline tool dispatch.
294    ///
295    /// Cancelled by the ROUTER the moment an `Interrupted` event arrives,
296    /// then re-armed (replaced with a fresh token) by the control lane once
297    /// it has processed the interruption. The control lane races inline tool
298    /// dispatch against this token, so a user barge-in is never stuck waiting
299    /// behind a slow tool.
300    pub barge_in: parking_lot::Mutex<CancellationToken>,
301    /// Latest resume handle from server.
302    pub resume_handle: parking_lot::Mutex<Option<String>>,
303    /// Last instruction sent via instruction_template (for dedup).
304    pub last_instruction: parking_lot::Mutex<Option<String>>,
305    /// Steering lines projected on the previous turn boundary, for repeat
306    /// suppression.
307    ///
308    /// Context turns are *appended* to the server-side conversation and cannot
309    /// be retracted, so re-sending an unchanged steering line does not
310    /// re-emphasise it — it adds a second standing directive the model has to
311    /// weigh against everything that follows. Holding the previous turn's lines
312    /// lets an unchanged one be dropped while a changed one still lands.
313    pub last_context: parking_lot::Mutex<Vec<String>>,
314    /// Pending context buffer for deferred delivery (None when Immediate mode).
315    pub pending_context: Option<Arc<PendingContext>>,
316    /// Fast-lane delivery policy per event class.
317    pub delivery: DeliveryConfig,
318    /// Per-class counters for frames dropped under a lossy delivery policy.
319    pub dropped: DroppedFrames,
320    /// Transcript redaction, applied at the router before either lane sees
321    /// the text. `None` = pass through untouched.
322    pub redactor: Option<Arc<crate::live::redaction::TranscriptRedactor>>,
323    /// Where the listener's playback is reported.
324    pub playback: super::playback::PlaybackClock,
325    /// The current model turn's audio and transcript, for cutting the
326    /// transcript to what was heard on an interruption.
327    pub speech: parking_lot::Mutex<super::playback::TurnSpeech>,
328}
329
330/// Runs the three-lane event processor.
331///
332/// Returns JoinHandles for the fast consumer and control processor tasks.
333/// The telemetry lane is spawned separately via [`spawn_telemetry_lane`] and
334/// fed by the router through a [`TelemetryTap`].
335/// Configuration for the control plane's new capabilities.
336pub(crate) struct ControlPlaneConfig {
337    /// The connect-time system instruction, as text.
338    ///
339    /// What an `instruction_amendment` composes onto when no phase supplies a
340    /// base. Amendments are additive by contract, so they need something to be
341    /// added to; a session with no phase machine has no phase instruction, and
342    /// before this was carried the amendment was computed each turn and then
343    /// silently discarded. Sending the amendment alone was not an option —
344    /// under `SteeringMode::InstructionUpdate` that replaces the system
345    /// instruction, so it would have deleted the caller's own prompt.
346    pub base_instruction: Option<String>,
347    /// Soft turn detector for proactive silence awareness.
348    pub soft_turn: Option<SoftTurnDetector>,
349    /// Steering mode for phase instruction delivery.
350    pub steering_mode: SteeringMode,
351    /// When to deliver context turns to the wire.
352    /// Deferred = synchronize with user activity (speech, interruption);
353    /// Immediate = send during TurnComplete processing.
354    pub context_delivery: ContextDelivery,
355    /// Conversation repair tracker.
356    pub needs_fulfillment: Option<NeedsFulfillment>,
357    /// Session persistence backend.
358    pub persistence: Option<Arc<dyn SessionPersistence>>,
359    /// Session ID for persistence key.
360    pub session_id: Option<String>,
361    /// Whether to inject tool availability advisory on phase transitions.
362    pub tool_advisory: bool,
363    /// Every tool declaration and the names declared now, under
364    /// [`SteeringMode::ContextUpdate`] on a model that accepts it. `None`
365    /// otherwise: then tools are declared once, at connect.
366    pub tool_scope: Option<Arc<super::tool_scope::ToolScope>>,
367    /// Every task tool declaration and the names declared now, in a task
368    /// session on a model that accepts `contextUpdate`. `None` otherwise:
369    /// then every skill's tools are declared at connect.
370    pub task_scope: Option<Arc<super::tool_scope::ToolScope>>,
371    /// The session's code tool: the runtime runs its programs, each call
372    /// down the governed path. `None` without code mode.
373    pub code_mode: Option<Arc<crate::code_mode::CodeMode>>,
374    /// Shared pending context buffer for deferred delivery (None when Immediate).
375    /// Must be the same Arc given to the DeferredWriter so the control lane
376    /// can push context and the DeferredWriter can drain it.
377    pub pending_context: Option<Arc<PendingContext>>,
378    /// Middleware layers run around tool dispatch in the control lane
379    /// (`before_tool` / `after_tool` / `on_tool_error`).
380    pub middleware: Arc<crate::middleware::MiddlewareChain>,
381    /// Optional governed-flow stack (main flow plus digressions): gates tool
382    /// calls, projects the active layer's postures into steering, drives
383    /// repair from unmet requirements, and suspends/resumes the main flow
384    /// around digressions. Shared (`Arc<Mutex<..>>`) so the
385    /// [`LiveHandle`](super::handle::LiveHandle) can snapshot `explain` while
386    /// the control lane advances it. Lock briefly; never hold the guard across
387    /// an `await`.
388    pub flow: Option<crate::flow::SharedFlowStack>,
389    /// Questions a decision model answers about the conversation, asked at
390    /// the turn boundary and before tools are admitted.
391    pub decisions: Option<Arc<crate::decision::Decisions>>,
392    /// Task runtime, exclusively advanced by the control lane.
393    pub tasks: Option<crate::tasks::TaskRuntime>,
394    /// Read-only observation cache shared with LiveHandle.
395    pub task_status: Option<Arc<parking_lot::Mutex<crate::tasks::TaskSessionSnapshot>>>,
396    /// Fast-lane delivery (backpressure) policy per event class. Defaults to
397    /// all-`Lossless`, preserving the historical `send().await` behavior.
398    pub delivery: DeliveryConfig,
399    /// Transcript redaction applied at the router, before either lane sees
400    /// the text. `None` = pass through.
401    pub redactor: Option<Arc<crate::live::redaction::TranscriptRedactor>>,
402    /// Replay only: the router waits for both lanes to finish each event and
403    /// counts it here. See [`Lockstep`].
404    pub lockstep: Option<Arc<Lockstep>>,
405    /// The session's playback clock, shared with its
406    /// [`LiveHandle`](super::handle::LiveHandle).
407    pub playback: super::playback::PlaybackClock,
408}
409
410/// Counts the session events the lanes have fully handled, for an offline
411/// replay that must not move its clock past a frame the session is still
412/// processing. With lockstep on, the router waits for both lanes to drain
413/// each event before taking the next, which a live session never should.
414#[derive(Debug, Default)]
415pub(crate) struct Lockstep {
416    settled: AtomicU64,
417    notify: tokio::sync::Notify,
418}
419
420impl Lockstep {
421    /// Events the lanes have finished with (or the router skipped).
422    pub(crate) fn settled(&self) -> u64 {
423        self.settled.load(Ordering::Acquire)
424    }
425
426    fn settle(&self, n: u64) {
427        self.settled.fetch_add(n, Ordering::AcqRel);
428        self.notify.notify_waiters();
429    }
430
431    /// Wait until at least `count` events are settled, or `timeout` passes.
432    pub(crate) async fn wait_for(&self, count: u64, timeout: std::time::Duration) {
433        let deadline = tokio::time::Instant::now() + timeout;
434        loop {
435            let notified = self.notify.notified();
436            if self.settled() >= count {
437                return;
438            }
439            if tokio::time::timeout_at(deadline, notified).await.is_err() {
440                return;
441            }
442        }
443    }
444}
445
446impl Default for ControlPlaneConfig {
447    fn default() -> Self {
448        Self {
449            base_instruction: None,
450            soft_turn: None,
451            steering_mode: SteeringMode::default(),
452            context_delivery: ContextDelivery::default(),
453            needs_fulfillment: None,
454            persistence: None,
455            session_id: None,
456            tool_advisory: true,
457            tool_scope: None,
458            task_scope: None,
459            code_mode: None,
460            pending_context: None,
461            middleware: Arc::new(crate::middleware::MiddlewareChain::new()),
462            flow: None,
463            decisions: None,
464            tasks: None,
465            task_status: None,
466            delivery: DeliveryConfig::default(),
467            redactor: None,
468            lockstep: None,
469            playback: super::playback::PlaybackClock::new(crate::clock::system_clock()),
470        }
471    }
472}
473
474#[allow(
475    clippy::too_many_arguments,
476    reason = "lane spawn site: parameters are the owned subsystem handles split between the fast and control lanes"
477)]
478pub(crate) fn spawn_event_processor(
479    mut event_rx: broadcast::Receiver<SessionEvent>,
480    callbacks: Arc<EventCallbacks>,
481    dispatcher: Option<Arc<ToolDispatcher>>,
482    writer: Arc<dyn SessionWriter>,
483    extractors: Vec<Arc<dyn TurnExtractor>>,
484    state: State,
485    computed: Option<ComputedRegistry>,
486    phase_machine: Option<tokio::sync::Mutex<PhaseMachine>>,
487    watchers: Option<WatcherRegistry>,
488    temporal: Option<Arc<TemporalRegistry>>,
489    background_tracker: Option<Arc<BackgroundToolTracker>>,
490    execution_modes: std::collections::HashMap<String, super::background_tool::ToolExecutionMode>,
491    control_plane: ControlPlaneConfig,
492    live_event_tx: broadcast::Sender<LiveEvent>,
493    telemetry: Option<TelemetryTap>,
494) -> (
495    tokio::task::JoinHandle<()>,
496    tokio::task::JoinHandle<()>,
497    mpsc::WeakSender<ControlEvent>,
498) {
499    let shared = Arc::new(SharedState {
500        interrupted: AtomicBool::new(false),
501        barge_in: parking_lot::Mutex::new(CancellationToken::new()),
502        resume_handle: parking_lot::Mutex::new(None),
503        last_instruction: parking_lot::Mutex::new(None),
504        // The opening steering was sent at connect: the first turn boundary
505        // must not send it again.
506        last_context: parking_lot::Mutex::new(super::control_plane::opening_steering(
507            &control_plane.flow,
508            &state,
509        )),
510        pending_context: control_plane.pending_context.clone(),
511        delivery: control_plane.delivery,
512        dropped: DroppedFrames::default(),
513        redactor: control_plane.redactor.clone(),
514        playback: control_plane.playback.clone(),
515        speech: parking_lot::Mutex::default(),
516    });
517
518    let timer_cancel = CancellationToken::new();
519
520    // Channels between router and lanes.
521    //
522    // The control channel matches the fast channel at 512: control events
523    // are routed with a lossless `send().await`, so a *full* control queue
524    // blocks the shared router — and a blocked router stops forwarding audio
525    // frames too, causing playback glitches. Transcript accumulation events
526    // (one per ASR chunk) flow through this channel, so a slow control-lane
527    // consumer (e.g. a blocking turn-complete pipeline) could realistically
528    // fill 64 slots; 512 gives the lane room to fall behind transiently
529    // without starving the fast lane.
530    let (fast_tx, fast_rx) = mpsc::channel::<FastEvent>(512);
531    let (ctrl_tx, ctrl_rx) = mpsc::channel::<ControlEvent>(512);
532
533    // Spawn the router task (reads broadcast, records telemetry counters,
534    // routes to lanes). SessionSignals' state writes stay on the telemetry lane.
535    let mut telemetry = telemetry;
536    let fast_tx_clone = fast_tx.clone();
537    let ctrl_tx_clone = ctrl_tx.clone();
538    let shared_clone = shared.clone();
539    let lockstep = control_plane.lockstep.clone();
540    tokio::spawn(
541        async move {
542            // One span per turn; see `turn_trace` for why each lane keeps its own.
543            let mut turn = super::turn_trace::TurnTrace::new();
544            loop {
545                match event_rx.recv().await {
546                    Ok(event) => {
547                        // `Disconnected` is terminal in L0 (the session loop returns
548                        // after emitting it), so the router exits after routing it.
549                        // Dropping the router's lane senders closes the fast/control
550                        // channels, letting both lanes drain their queues and shut
551                        // down gracefully (final persistence drain, etc.) instead of
552                        // idling forever on a broadcast channel that never closes
553                        // while the `SessionHandle` is alive.
554                        let terminal = matches!(event, SessionEvent::Disconnected(_));
555                        let boundary = matches!(event, SessionEvent::TurnComplete);
556                        if let Some(tap) = telemetry.as_mut() {
557                            turn.span().in_scope(|| tap.observe(&event));
558                        }
559                        route_event(event, &fast_tx_clone, &ctrl_tx_clone, &shared_clone)
560                            .instrument(turn.span())
561                            .await;
562                        if let Some(lockstep) = &lockstep {
563                            let (fast_done, fast_rx) = tokio::sync::oneshot::channel();
564                            let (ctrl_done, ctrl_rx) = tokio::sync::oneshot::channel();
565                            let _ = fast_tx_clone.send(FastEvent::Barrier(fast_done)).await;
566                            let _ = ctrl_tx_clone.send(ControlEvent::Barrier(ctrl_done)).await;
567                            let _ = fast_rx.await;
568                            let _ = ctrl_rx.await;
569                            lockstep.settle(1);
570                        }
571                        if boundary {
572                            turn.advance();
573                            tracing::debug!(parent: &turn.span(), "turn started");
574                        }
575                        if terminal {
576                            break;
577                        }
578                    }
579                    Err(broadcast::error::RecvError::Lagged(n)) => {
580                        tracing::warn!(skipped = n, "Event processor lagged, skipped events");
581                        if let Some(lockstep) = &lockstep {
582                            lockstep.settle(n);
583                        }
584                    }
585                    Err(broadcast::error::RecvError::Closed) => break,
586                }
587            }
588        }
589        .in_current_span(),
590    );
591
592    // Spawn fast consumer (no transcript buffer — transcripts are in control lane)
593    let fast_callbacks = callbacks.clone();
594    let fast_shared = shared.clone();
595    let fast_event_tx = live_event_tx.clone();
596    let fast_handle = tokio::spawn(
597        async move {
598            run_fast_lane(fast_rx, fast_callbacks, fast_shared, fast_event_tx).await;
599        }
600        .in_current_span(),
601    );
602
603    // Clone for the timer task (before moving into ctrl spawn)
604    let timer_temporal = temporal.clone();
605    let task_timer = control_plane.tasks.is_some();
606    // Reprompt-on-silence runs only when some stage asks for it.
607    let reprompt = control_plane.flow.as_ref().is_some_and(|stack| {
608        stack
609            .lock()
610            .timing_policies()
611            .values()
612            .any(|t| t.reprompt_after_ms.is_some())
613    });
614    let reprompt_inputs = reprompt.then(|| (state.clone(), writer.clone(), live_event_tx.clone()));
615    let timer_state = state.clone();
616    let timer_writer = writer.clone();
617
618    // Spawn control processor (owns TranscriptBuffer exclusively — no mutex needed)
619    let ctrl_callbacks = callbacks;
620    let ctrl_shared = shared;
621    let ctrl_timer_cancel = timer_cancel.clone();
622    // Weak sender handed to the control lane so background tool tasks can post
623    // completions back without keeping the channel open on shutdown (the lane
624    // upgrades it per background spawn; the channel closes once the router and
625    // all in-flight background tasks drop their strong senders).
626    let ctrl_tx_weak = ctrl_tx.downgrade();
627    let ctrl_handle = tokio::spawn(
628        async move {
629            run_control_lane(
630                ctrl_rx,
631                ctrl_tx_weak,
632                ctrl_callbacks,
633                dispatcher,
634                writer,
635                ctrl_shared,
636                extractors,
637                state,
638                computed,
639                phase_machine,
640                watchers,
641                temporal,
642                background_tracker,
643                execution_modes,
644                control_plane,
645                live_event_tx,
646            )
647            .await;
648            ctrl_timer_cancel.cancel();
649        }
650        .in_current_span(),
651    );
652
653    // Optional timer task for sustained temporal patterns
654    if task_timer {
655        let sender = ctrl_tx.downgrade();
656        let cancel = timer_cancel.clone();
657        tokio::spawn(async move {
658            let mut interval = tokio::time::interval(Duration::from_millis(500));
659            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
660            interval.tick().await;
661            loop {
662                tokio::select! {
663                    _ = cancel.cancelled() => break,
664                    _ = interval.tick() => {
665                        let Some(sender) = sender.upgrade() else { break };
666                        if sender.send(ControlEvent::TaskTimer).await.is_err() {
667                            break;
668                        }
669                    }
670                }
671            }
672        });
673    }
674
675    if let Some(ref temporal_ref) = timer_temporal
676        && temporal_ref.needs_timer()
677    {
678        let t = temporal_ref.clone();
679        let cancel = timer_cancel.clone();
680        tokio::spawn(async move {
681            let mut interval = tokio::time::interval(Duration::from_millis(500));
682            loop {
683                tokio::select! {
684                    _ = cancel.cancelled() => break,
685                    _ = interval.tick() => {
686                        for action in t.check_all(&timer_state, None, &timer_writer) {
687                            tokio::spawn(action);
688                        }
689                    }
690                }
691            }
692        });
693    }
694
695    if let Some((state, writer, events)) = reprompt_inputs {
696        tokio::spawn(super::reprompt::run_reprompt_timer(
697            state,
698            writer,
699            events,
700            timer_cancel.clone(),
701        ));
702    }
703
704    // Handed to `LiveHandle` so `send_text` can record a typed turn on the
705    // transcript. Deliberately *weak*: the control channel must still close when
706    // the router and all in-flight background tasks drop their strong senders,
707    // otherwise the lane would never drain and shut down (see `ctrl_tx_weak`).
708    let ctrl_tx_handle = ctrl_tx.downgrade();
709
710    (fast_handle, ctrl_handle, ctrl_tx_handle)
711}
712
713/// What the router hands the telemetry lane.
714pub(crate) enum TelemetryMsg {
715    /// An event whose [`SessionSignals`] handling writes state.
716    Event(SessionEvent),
717    /// The turn's response latency, measured by the router on the model's
718    /// first output.
719    ResponseLatency(Duration),
720}
721
722/// How many forwarded events the telemetry lane can fall behind by before
723/// the router drops them. Only low-rate events are forwarded.
724const TELEMETRY_LANE_CAPACITY: usize = 256;
725
726/// The router's side of telemetry.
727///
728/// [`SessionTelemetry`] is atomic counters, so the router records them inline,
729/// in event order, at a cost of a few atomic operations per event. Events
730/// whose [`SessionSignals`] handling writes state go on to the telemetry lane.
731/// Audio and text, the high-rate events, only touch an atomic timestamp and
732/// are not forwarded: before this, every audio frame woke a separate task,
733/// which cost more CPU than the rest of the runtime's per-frame work.
734pub(crate) struct TelemetryTap {
735    telemetry: Arc<SessionTelemetry>,
736    signals: Arc<SessionSignals>,
737    lane: mpsc::Sender<TelemetryMsg>,
738    /// The turn's latest usage report, added to the totals at its end.
739    turn_usage: Option<gemini_genai_rs::prelude::UsageMetadata>,
740}
741
742impl TelemetryTap {
743    /// A tap feeding a new telemetry lane, and the lane's receiving end for
744    /// [`spawn_telemetry_lane`].
745    pub(crate) fn new(
746        telemetry: Arc<SessionTelemetry>,
747        signals: Arc<SessionSignals>,
748    ) -> (Self, mpsc::Receiver<TelemetryMsg>) {
749        let (lane, rx) = mpsc::channel(TELEMETRY_LANE_CAPACITY);
750        let tap = Self {
751            telemetry,
752            signals,
753            lane,
754            turn_usage: None,
755        };
756        (tap, rx)
757    }
758
759    /// Record one event's telemetry. Synchronous and cheap; never waits on
760    /// the telemetry lane.
761    pub(crate) fn observe(&mut self, event: &SessionEvent) {
762        match event {
763            SessionEvent::AudioData(data) => {
764                if let Some(latency) = self.telemetry.record_audio_out(data.len()) {
765                    tracing::info!(
766                        latency_ms = latency.as_millis() as u64,
767                        "first model audio after the user's turn"
768                    );
769                    self.forward(TelemetryMsg::ResponseLatency(latency));
770                }
771                self.signals.touch_activity();
772                return;
773            }
774            SessionEvent::TextDelta(_) => {
775                if let Some(latency) = self.telemetry.record_text_out() {
776                    tracing::info!(
777                        latency_ms = latency.as_millis() as u64,
778                        "first model text after the user's turn"
779                    );
780                    self.forward(TelemetryMsg::ResponseLatency(latency));
781                }
782                self.signals.touch_activity();
783                return;
784            }
785            SessionEvent::TextComplete(_) => {
786                self.signals.touch_activity();
787                return;
788            }
789            SessionEvent::VoiceActivityEnd => self.telemetry.record_vad_end(),
790            SessionEvent::Interrupted => self.telemetry.record_interruption(),
791            SessionEvent::TurnComplete => {
792                self.telemetry.record_turn_complete();
793                if let Some(usage) = self.turn_usage.take() {
794                    self.telemetry.record_turn_usage(&usage);
795                }
796            }
797            SessionEvent::VoiceActivityStart => self.telemetry.mark_turn_start(),
798            SessionEvent::Usage(usage) => {
799                self.telemetry.record_usage(
800                    usage.total_token_count,
801                    usage.prompt_token_count,
802                    usage.response_token_count,
803                    usage.cached_content_token_count,
804                    usage.thoughts_token_count,
805                );
806                self.turn_usage = Some(usage.clone());
807            }
808            _ => {}
809        }
810        self.forward(TelemetryMsg::Event(event.clone()));
811    }
812
813    fn forward(&self, msg: TelemetryMsg) {
814        // A closed lane means the session is shutting down.
815        if let Err(mpsc::error::TrySendError::Full(_)) = self.lane.try_send(msg) {
816            tracing::warn!("Telemetry lane lagged, dropped a session signal");
817        }
818    }
819}
820
821/// Spawns the telemetry lane: [`SessionSignals`] state writes for the events
822/// a [`TelemetryTap`] forwards, the `on_usage` callback, and the derived
823/// timing signals (`silence_ms`, `elapsed_ms`, `remaining_budget_ms`),
824/// flushed to state every 100 ms.
825pub(crate) fn spawn_telemetry_lane(
826    mut rx: mpsc::Receiver<TelemetryMsg>,
827    signals: Arc<SessionSignals>,
828    cancel: CancellationToken,
829    on_usage: Option<super::callbacks::UsageCallback>,
830) -> tokio::task::JoinHandle<()> {
831    tokio::spawn(async move {
832        let mut debounce = tokio::time::interval(Duration::from_millis(100));
833        // Consume the first immediate tick
834        debounce.tick().await;
835        // One span per turn; see `turn_trace` for why each lane keeps its own.
836        let mut turn = super::turn_trace::TurnTrace::new();
837        loop {
838            tokio::select! {
839                biased;
840                msg = rx.recv() => match msg {
841                    Some(TelemetryMsg::ResponseLatency(latency)) => {
842                        signals.record_response_latency(latency);
843                    }
844                    Some(TelemetryMsg::Event(event)) => {
845                        turn.span().in_scope(|| {
846                            if let (SessionEvent::Usage(usage), Some(cb)) = (&event, &on_usage) {
847                                cb(usage);
848                            }
849                            signals.on_event(&event);
850                        });
851                        if matches!(event, SessionEvent::TurnComplete) {
852                            turn.advance();
853                        }
854                    }
855                    // The router is gone.
856                    None => break,
857                },
858                _ = debounce.tick() => {
859                    // Flush derived timing signals to state (debounced)
860                    signals.flush_timing();
861                }
862                _ = cancel.cancelled() => break,
863            }
864        }
865    }.in_current_span())
866}
867
868/// Routes a SessionEvent to the appropriate lane.
869async fn route_event(
870    event: SessionEvent,
871    fast_tx: &mpsc::Sender<FastEvent>,
872    ctrl_tx: &mpsc::Sender<ControlEvent>,
873    shared: &SharedState,
874) {
875    let delivery = &shared.delivery;
876    let dropped = &shared.dropped;
877    match event {
878        // Fast lane events
879        SessionEvent::AudioData(data) => {
880            shared.speech.lock().on_audio(data.len(), &shared.playback);
881            deliver_fast(
882                fast_tx,
883                FastEvent::Audio(data),
884                delivery.audio,
885                &dropped.audio,
886            )
887            .await;
888        }
889        SessionEvent::Media(media) => {
890            deliver_fast(
891                fast_tx,
892                FastEvent::Media(media),
893                delivery.media,
894                &dropped.media,
895            )
896            .await;
897        }
898        SessionEvent::TextDelta(text) => {
899            deliver_fast(fast_tx, FastEvent::Text(text), delivery.text, &dropped.text).await;
900        }
901        SessionEvent::TextComplete(text) => {
902            let text = match &shared.redactor {
903                Some(redactor) => redactor.redact(text),
904                None => text,
905            };
906            deliver_fast(
907                fast_tx,
908                FastEvent::TextComplete(text),
909                delivery.text,
910                &dropped.text,
911            )
912            .await;
913        }
914        // Transcripts: fast lane for callbacks, control lane for accumulation.
915        // The control-lane accumulation send keeps its lossless `send().await`.
916        SessionEvent::InputTranscription(text) => {
917            let text = match &shared.redactor {
918                Some(redactor) => redactor.redact(text),
919                None => text,
920            };
921            deliver_fast(
922                fast_tx,
923                FastEvent::InputTranscript(text.clone()),
924                delivery.transcript,
925                &dropped.transcript,
926            )
927            .await;
928            let _ = ctrl_tx.send(ControlEvent::InputTranscript(text)).await;
929        }
930        SessionEvent::OutputTranscription(text) => {
931            let text = match &shared.redactor {
932                Some(redactor) => redactor.redact(text),
933                None => text,
934            };
935            shared.speech.lock().on_text(&text);
936            deliver_fast(
937                fast_tx,
938                FastEvent::OutputTranscript(text.clone()),
939                delivery.transcript,
940                &dropped.transcript,
941            )
942            .await;
943            let _ = ctrl_tx.send(ControlEvent::OutputTranscript(text)).await;
944        }
945        SessionEvent::Thought(text) => {
946            deliver_fast(
947                fast_tx,
948                FastEvent::Thought(text),
949                delivery.thought,
950                &dropped.thought,
951            )
952            .await;
953        }
954        SessionEvent::VoiceActivityStart => {
955            tracing::debug!("user started speaking");
956            deliver_fast(fast_tx, FastEvent::VadStart, delivery.vad, &dropped.vad).await;
957        }
958        SessionEvent::VoiceActivityEnd => {
959            tracing::debug!("user stopped speaking");
960            deliver_fast(fast_tx, FastEvent::VadEnd, delivery.vad, &dropped.vad).await;
961        }
962        SessionEvent::PhaseChanged(phase) => {
963            deliver_fast(
964                fast_tx,
965                FastEvent::Phase(phase),
966                delivery.phase,
967                &dropped.phase,
968            )
969            .await;
970        }
971        SessionEvent::SessionResumeUpdate(info) => {
972            *shared.resume_handle.lock() = Some(info.handle.clone());
973            let _ = ctrl_tx.send(ControlEvent::SessionResumeUpdate(info)).await;
974        }
975        SessionEvent::GenerationComplete => {
976            let _ = ctrl_tx.send(ControlEvent::GenerationComplete).await;
977        }
978
979        // Control lane events
980        SessionEvent::ToolCall(calls) => {
981            let _ = ctrl_tx.send(ControlEvent::ToolCall(calls)).await;
982        }
983        SessionEvent::ToolCallCancelled(ids) => {
984            let _ = ctrl_tx.send(ControlEvent::ToolCallCancelled(ids)).await;
985        }
986        SessionEvent::Interrupted => {
987            tracing::debug!("user interrupted the model");
988            // Signal BOTH lanes
989            shared.interrupted.store(true, Ordering::Release);
990            // Cancel any in-flight inline tool dispatch immediately: the
991            // control lane may be blocked awaiting a slow tool and would
992            // otherwise not see this interruption until the tool finished.
993            shared.barge_in.lock().cancel();
994            // Read the playback clock now, while the listener's speaker is
995            // where the interruption found it.
996            let heard_chars = shared.speech.lock().on_interrupted(&shared.playback);
997            let _ = fast_tx.send(FastEvent::Interrupted).await;
998            let _ = ctrl_tx
999                .send(ControlEvent::Interrupted { heard_chars })
1000                .await;
1001        }
1002        SessionEvent::TurnComplete => {
1003            shared.speech.lock().on_turn_complete();
1004            let _ = ctrl_tx.send(ControlEvent::TurnComplete).await;
1005        }
1006        // Usage metadata is handled by the telemetry lane (SessionSignals)
1007        SessionEvent::Usage(_) => {}
1008        SessionEvent::GoAway(time_left) => {
1009            let _ = ctrl_tx.send(ControlEvent::GoAway(time_left)).await;
1010        }
1011        SessionEvent::Connected => {
1012            let _ = ctrl_tx.send(ControlEvent::Connected).await;
1013        }
1014        SessionEvent::Disconnected(reason) => {
1015            let _ = ctrl_tx.send(ControlEvent::Disconnected(reason)).await;
1016        }
1017        SessionEvent::Error(err) => {
1018            let _ = ctrl_tx.send(ControlEvent::Error(err.to_string())).await;
1019        }
1020        // SessionEvent is #[non_exhaustive]: future wire events the runtime
1021        // doesn't understand yet are surfaced (not silently dropped) so
1022        // applications on an older runtime can observe them.
1023        other => {
1024            tracing::debug!(?other, "unhandled SessionEvent variant (newer wire event?)");
1025        }
1026    }
1027}
1028
1029/// Fast lane consumer — processes high-frequency events with sync callbacks.
1030/// No transcript buffer — transcripts are accumulated exclusively in the control lane.
1031async fn run_fast_lane(
1032    mut rx: mpsc::Receiver<FastEvent>,
1033    callbacks: Arc<EventCallbacks>,
1034    shared: Arc<SharedState>,
1035    event_tx: broadcast::Sender<LiveEvent>,
1036) {
1037    while let Some(event) = rx.recv().await {
1038        match event {
1039            FastEvent::Barrier(done) => {
1040                let _ = done.send(());
1041            }
1042            FastEvent::Audio(data) => {
1043                // Suppress audio during interruption
1044                if !shared.interrupted.load(Ordering::Acquire) {
1045                    if let Some(cb) = &callbacks.on_audio {
1046                        cb(&data);
1047                    }
1048                    let _ = event_tx.send(LiveEvent::Audio(data));
1049                }
1050            }
1051            FastEvent::Media(media) => {
1052                // Avatar video is speech made visible: stale after barge-in too.
1053                if !shared.interrupted.load(Ordering::Acquire) {
1054                    if let Some(cb) = &callbacks.on_media {
1055                        cb(&media);
1056                    }
1057                    let _ = event_tx.send(LiveEvent::Media(media));
1058                }
1059            }
1060            FastEvent::Text(delta) => {
1061                if let Some(cb) = &callbacks.on_text {
1062                    cb(&delta);
1063                }
1064                let _ = event_tx.send(LiveEvent::TextDelta(delta));
1065            }
1066            FastEvent::TextComplete(text) => {
1067                if let Some(cb) = &callbacks.on_text_complete {
1068                    cb(&text);
1069                }
1070                let _ = event_tx.send(LiveEvent::TextComplete(text));
1071            }
1072            FastEvent::InputTranscript(text) => {
1073                // Callback only — accumulation happens in control lane
1074                if let Some(cb) = &callbacks.on_input_transcript {
1075                    cb(&text, false);
1076                }
1077                let _ = event_tx.send(LiveEvent::InputTranscript {
1078                    text,
1079                    is_final: false,
1080                });
1081            }
1082            FastEvent::OutputTranscript(text) => {
1083                // Callback only — accumulation happens in control lane
1084                if let Some(cb) = &callbacks.on_output_transcript {
1085                    cb(&text, false);
1086                }
1087                let _ = event_tx.send(LiveEvent::OutputTranscript {
1088                    text,
1089                    is_final: false,
1090                });
1091            }
1092            FastEvent::Thought(text) => {
1093                if let Some(cb) = &callbacks.on_thought {
1094                    cb(&text);
1095                }
1096                let _ = event_tx.send(LiveEvent::Thought(text));
1097            }
1098            FastEvent::VadStart => {
1099                if let Some(cb) = &callbacks.on_vad_start {
1100                    cb();
1101                }
1102                let _ = event_tx.send(LiveEvent::VadStart);
1103            }
1104            FastEvent::VadEnd => {
1105                if let Some(cb) = &callbacks.on_vad_end {
1106                    cb();
1107                }
1108                let _ = event_tx.send(LiveEvent::VadEnd);
1109            }
1110            FastEvent::Phase(phase) => {
1111                if let Some(cb) = &callbacks.on_session_phase {
1112                    cb(phase);
1113                }
1114                // Phase is L0-level wire event, not emitted as LiveEvent
1115            }
1116            FastEvent::Interrupted => {
1117                // Audio already suppressed via shared.interrupted flag
1118                // Interrupted LiveEvent is emitted from control lane
1119            }
1120        }
1121    }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127    use std::sync::atomic::AtomicUsize;
1128
1129    use crate::live::events::LiveEvent;
1130    use gemini_genai_rs::prelude::FunctionResponse;
1131
1132    fn dummy_event_tx() -> broadcast::Sender<LiveEvent> {
1133        broadcast::channel::<LiveEvent>(16).0
1134    }
1135
1136    #[test]
1137    fn delivery_config_default_is_all_lossless() {
1138        let cfg = DeliveryConfig::default();
1139        assert_eq!(cfg.audio, Delivery::Lossless);
1140        assert_eq!(cfg.text, Delivery::Lossless);
1141        assert_eq!(cfg.transcript, Delivery::Lossless);
1142        assert_eq!(cfg.thought, Delivery::Lossless);
1143        assert_eq!(cfg.vad, Delivery::Lossless);
1144        assert_eq!(cfg.phase, Delivery::Lossless);
1145        // The standalone Delivery default must also be Lossless.
1146        assert_eq!(Delivery::default(), Delivery::Lossless);
1147    }
1148
1149    #[tokio::test]
1150    async fn lossy_drop_newest_does_not_block_and_counts_drops() {
1151        // Capacity-1 channel that we fill, so the next send would block under
1152        // Lossless. The receiver is held but never drains.
1153        let (tx, _rx) = mpsc::channel::<FastEvent>(1);
1154        tx.send(FastEvent::VadStart).await.unwrap(); // channel now full
1155        let dropped = AtomicU64::new(0);
1156
1157        // Under LossyDropNewest this must return immediately (not block) and
1158        // bump the counter. We bound it with a timeout to prove non-blocking.
1159        let res = tokio::time::timeout(
1160            Duration::from_millis(100),
1161            deliver_fast(&tx, FastEvent::VadEnd, Delivery::LossyDropNewest, &dropped),
1162        )
1163        .await;
1164        assert!(res.is_ok(), "deliver_fast blocked under LossyDropNewest");
1165        assert_eq!(dropped.load(Ordering::Relaxed), 1);
1166    }
1167
1168    #[tokio::test]
1169    async fn lossless_delivers_on_non_full_channel() {
1170        let (tx, mut rx) = mpsc::channel::<FastEvent>(4);
1171        let dropped = AtomicU64::new(0);
1172
1173        deliver_fast(
1174            &tx,
1175            FastEvent::Text("hello".into()),
1176            Delivery::Lossless,
1177            &dropped,
1178        )
1179        .await;
1180
1181        // No drop, and the value arrives on the receiver.
1182        assert_eq!(dropped.load(Ordering::Relaxed), 0);
1183        match rx.recv().await {
1184            Some(FastEvent::Text(s)) => assert_eq!(s, "hello"),
1185            other => panic!("expected Text frame, got {:?}", other.is_some()),
1186        }
1187    }
1188
1189    #[test]
1190    fn dropped_frames_total_sums_classes() {
1191        let d = DroppedFrames::default();
1192        d.audio.fetch_add(2, Ordering::Relaxed);
1193        d.transcript.fetch_add(3, Ordering::Relaxed);
1194        assert_eq!(d.total(), 5);
1195    }
1196
1197    #[tokio::test]
1198    async fn fast_lane_routes_audio() {
1199        let count = Arc::new(AtomicUsize::new(0));
1200        let count_clone = count.clone();
1201
1202        let callbacks = EventCallbacks {
1203            on_audio: Some(Box::new(move |_| {
1204                count_clone.fetch_add(1, Ordering::SeqCst);
1205            })),
1206            ..Default::default()
1207        };
1208        let callbacks = Arc::new(callbacks);
1209
1210        let (event_tx, _) = broadcast::channel(16);
1211        let event_rx = event_tx.subscribe();
1212
1213        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1214
1215        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1216            event_rx,
1217            callbacks,
1218            None,
1219            writer,
1220            vec![],
1221            State::new(),
1222            None,
1223            None,
1224            None,
1225            None,
1226            None,
1227            std::collections::HashMap::new(),
1228            ControlPlaneConfig::default(),
1229            dummy_event_tx(),
1230            None,
1231        );
1232
1233        // Send audio events
1234        let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"audio1")));
1235        let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"audio2")));
1236
1237        // Allow tasks to process
1238        tokio::time::sleep(Duration::from_millis(50)).await;
1239
1240        assert_eq!(count.load(Ordering::SeqCst), 2);
1241
1242        // Cleanup
1243        drop(event_tx);
1244        let _ = fast_handle.await;
1245        let _ = ctrl_handle.await;
1246    }
1247
1248    #[tokio::test]
1249    async fn media_chunks_reach_on_media_not_on_audio() {
1250        let count = Arc::new(AtomicUsize::new(0));
1251        let count_clone = count.clone();
1252
1253        let callbacks = EventCallbacks {
1254            on_media: Some(Box::new(
1255                move |media: &gemini_genai_rs::session::InlineMedia| {
1256                    assert!(media.is_video());
1257                    count_clone.fetch_add(1, Ordering::SeqCst);
1258                },
1259            )),
1260            ..Default::default()
1261        };
1262        let callbacks = Arc::new(callbacks);
1263
1264        let (event_tx, _) = broadcast::channel(16);
1265        let event_rx = event_tx.subscribe();
1266
1267        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1268
1269        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1270            event_rx,
1271            callbacks,
1272            None,
1273            writer,
1274            vec![],
1275            State::new(),
1276            None,
1277            None,
1278            None,
1279            None,
1280            None,
1281            std::collections::HashMap::new(),
1282            ControlPlaneConfig::default(),
1283            dummy_event_tx(),
1284            None,
1285        );
1286
1287        // Live Avatar video chunks
1288        for _ in 0..2 {
1289            let _ = event_tx.send(SessionEvent::Media(gemini_genai_rs::session::InlineMedia {
1290                mime_type: "video/mp4".into(),
1291                data: Bytes::from_static(b"mp4"),
1292            }));
1293        }
1294
1295        // Allow tasks to process
1296        tokio::time::sleep(Duration::from_millis(50)).await;
1297
1298        assert_eq!(count.load(Ordering::SeqCst), 2);
1299
1300        // Cleanup
1301        drop(event_tx);
1302        let _ = fast_handle.await;
1303        let _ = ctrl_handle.await;
1304    }
1305
1306    #[tokio::test]
1307    async fn interrupt_suppresses_audio() {
1308        let count = Arc::new(AtomicUsize::new(0));
1309        let count_clone = count.clone();
1310
1311        let callbacks = EventCallbacks {
1312            on_audio: Some(Box::new(move |_| {
1313                count_clone.fetch_add(1, Ordering::SeqCst);
1314            })),
1315            ..Default::default()
1316        };
1317        let callbacks = Arc::new(callbacks);
1318
1319        let (event_tx, _) = broadcast::channel(16);
1320        let event_rx = event_tx.subscribe();
1321
1322        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1323
1324        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1325            event_rx,
1326            callbacks,
1327            None,
1328            writer,
1329            vec![],
1330            State::new(),
1331            None,
1332            None,
1333            None,
1334            None,
1335            None,
1336            std::collections::HashMap::new(),
1337            ControlPlaneConfig::default(),
1338            dummy_event_tx(),
1339            None,
1340        );
1341
1342        // Send audio, then interrupt, then more audio
1343        let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"before")));
1344        tokio::time::sleep(Duration::from_millis(20)).await;
1345        let _ = event_tx.send(SessionEvent::Interrupted);
1346        tokio::time::sleep(Duration::from_millis(20)).await;
1347        let _ = event_tx.send(SessionEvent::AudioData(Bytes::from_static(b"during")));
1348        tokio::time::sleep(Duration::from_millis(50)).await;
1349
1350        // At least the first audio was received
1351        assert!(count.load(Ordering::SeqCst) >= 1);
1352
1353        drop(event_tx);
1354        let _ = fast_handle.await;
1355        let _ = ctrl_handle.await;
1356    }
1357
1358    #[tokio::test]
1359    async fn control_lane_routes_turn_complete() {
1360        let called = Arc::new(AtomicBool::new(false));
1361        let called_clone = called.clone();
1362
1363        let callbacks = EventCallbacks {
1364            on_turn_complete: Some(Arc::new(move || {
1365                let c = called_clone.clone();
1366                Box::pin(async move {
1367                    c.store(true, Ordering::SeqCst);
1368                })
1369            })),
1370            ..Default::default()
1371        };
1372        let callbacks = Arc::new(callbacks);
1373
1374        let (event_tx, _) = broadcast::channel(16);
1375        let event_rx = event_tx.subscribe();
1376
1377        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1378
1379        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1380            event_rx,
1381            callbacks,
1382            None,
1383            writer,
1384            vec![],
1385            State::new(),
1386            None,
1387            None,
1388            None,
1389            None,
1390            None,
1391            std::collections::HashMap::new(),
1392            ControlPlaneConfig::default(),
1393            dummy_event_tx(),
1394            None,
1395        );
1396
1397        let _ = event_tx.send(SessionEvent::TurnComplete);
1398        tokio::time::sleep(Duration::from_millis(50)).await;
1399
1400        assert!(called.load(Ordering::SeqCst));
1401
1402        drop(event_tx);
1403        let _ = fast_handle.await;
1404        let _ = ctrl_handle.await;
1405    }
1406
1407    #[tokio::test]
1408    async fn transcript_accumulates_in_control_lane() {
1409        let callbacks = Arc::new(EventCallbacks::default());
1410
1411        let (event_tx, _) = broadcast::channel(16);
1412        let event_rx = event_tx.subscribe();
1413
1414        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1415
1416        let state = State::new();
1417        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1418            event_rx,
1419            callbacks,
1420            None,
1421            writer,
1422            vec![],
1423            state.clone(),
1424            None,
1425            None,
1426            None,
1427            None,
1428            None,
1429            std::collections::HashMap::new(),
1430            ControlPlaneConfig::default(),
1431            dummy_event_tx(),
1432            None,
1433        );
1434
1435        // Send transcripts
1436        let _ = event_tx.send(SessionEvent::InputTranscription("Hello ".to_string()));
1437        let _ = event_tx.send(SessionEvent::InputTranscription("world".to_string()));
1438        let _ = event_tx.send(SessionEvent::OutputTranscription("Hi there!".to_string()));
1439        tokio::time::sleep(Duration::from_millis(50)).await;
1440
1441        // End turn
1442        let _ = event_tx.send(SessionEvent::TurnComplete);
1443        tokio::time::sleep(Duration::from_millis(50)).await;
1444
1445        // Turn count should have been incremented
1446        let tc: u32 = state.session().get("turn_count").unwrap_or(0);
1447        assert_eq!(tc, 1);
1448
1449        drop(event_tx);
1450        let _ = fast_handle.await;
1451        let _ = ctrl_handle.await;
1452    }
1453
1454    #[tokio::test]
1455    async fn an_interrupted_turn_keeps_only_what_was_heard() {
1456        let finals = Arc::new(parking_lot::Mutex::new(Vec::<String>::new()));
1457        let sink = finals.clone();
1458        let callbacks = Arc::new(EventCallbacks {
1459            on_output_transcript: Some(Box::new(move |text: &str, is_final: bool| {
1460                if is_final {
1461                    sink.lock().push(text.to_string());
1462                }
1463            })),
1464            ..EventCallbacks::default()
1465        });
1466        let (event_tx, _) = broadcast::channel(64);
1467        let event_rx = event_tx.subscribe();
1468        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1469        let clock = Arc::new(crate::clock::ManualClock::new());
1470        let control_plane = ControlPlaneConfig {
1471            playback: crate::live::playback::PlaybackClock::new(clock.clone()),
1472            ..ControlPlaneConfig::default()
1473        };
1474        let playback = control_plane.playback.clone();
1475        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1476            event_rx,
1477            callbacks,
1478            None,
1479            writer,
1480            vec![],
1481            State::new(),
1482            None,
1483            None,
1484            None,
1485            None,
1486            None,
1487            std::collections::HashMap::new(),
1488            control_plane,
1489            dummy_event_tx(),
1490            None,
1491        );
1492
1493        // The model streams 4 s of audio (24 kHz PCM16) and its whole
1494        // sentence at once; the speaker starts playing it.
1495        let _ = event_tx.send(SessionEvent::AudioData(Bytes::from(vec![0u8; 4 * 48_000])));
1496        let _ = event_tx.send(SessionEvent::OutputTranscription(
1497            "Your table for four is booked for Friday at eight, and I have noted the allergy."
1498                .to_string(),
1499        ));
1500        tokio::time::sleep(Duration::from_millis(50)).await;
1501        playback.queued(Duration::from_secs(4));
1502        // The caller cuts in after 1.5 s: 24 characters at 16 a second.
1503        clock.advance(Duration::from_millis(1_500));
1504        let _ = event_tx.send(SessionEvent::Interrupted);
1505        let _ = event_tx.send(SessionEvent::TurnComplete);
1506        tokio::time::sleep(Duration::from_millis(50)).await;
1507
1508        assert_eq!(*finals.lock(), ["Your table for four is"]);
1509
1510        drop(event_tx);
1511        let _ = fast_handle.await;
1512        let _ = ctrl_handle.await;
1513    }
1514
1515    #[tokio::test]
1516    async fn extractor_runs_on_turn_complete() {
1517        use crate::live::extractor::TurnExtractor;
1518        use crate::live::transcript::TranscriptTurn;
1519        use crate::llm::LlmError;
1520
1521        struct FixedExtractor;
1522
1523        #[async_trait::async_trait]
1524        impl TurnExtractor for FixedExtractor {
1525            fn name(&self) -> &str {
1526                "TestExtractor"
1527            }
1528            fn window_size(&self) -> usize {
1529                3
1530            }
1531            async fn extract(
1532                &self,
1533                _turns: &[TranscriptTurn],
1534            ) -> Result<serde_json::Value, LlmError> {
1535                Ok(serde_json::json!({"score": 0.9, "mood": "happy"}))
1536            }
1537        }
1538
1539        let callbacks = Arc::new(EventCallbacks::default());
1540
1541        let (event_tx, _) = broadcast::channel(16);
1542        let event_rx = event_tx.subscribe();
1543
1544        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1545
1546        let state = State::new();
1547
1548        let extractors: Vec<Arc<dyn TurnExtractor>> = vec![Arc::new(FixedExtractor)];
1549
1550        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1551            event_rx,
1552            callbacks,
1553            None,
1554            writer,
1555            extractors,
1556            state.clone(),
1557            None,
1558            None,
1559            None,
1560            None,
1561            None,
1562            std::collections::HashMap::new(),
1563            ControlPlaneConfig::default(),
1564            dummy_event_tx(),
1565            None,
1566        );
1567
1568        // Produce a turn with content
1569        let _ = event_tx.send(SessionEvent::InputTranscription("hi".to_string()));
1570        tokio::time::sleep(Duration::from_millis(20)).await;
1571        let _ = event_tx.send(SessionEvent::TurnComplete);
1572        tokio::time::sleep(Duration::from_millis(100)).await;
1573
1574        // Check extraction results
1575        let score: Option<f64> = state.get("score");
1576        assert_eq!(score, Some(0.9));
1577        let mood: Option<String> = state.get("mood");
1578        assert_eq!(mood, Some("happy".to_string()));
1579
1580        drop(event_tx);
1581        let _ = fast_handle.await;
1582        let _ = ctrl_handle.await;
1583    }
1584
1585    /// Text pushed through the returned control sender lands on the *user* side
1586    /// of the transcript, so extractors see a typed turn exactly as they see a
1587    /// spoken one.
1588    ///
1589    /// This is the plumbing `LiveHandle::send_text` uses. Before it existed the
1590    /// transcript's user side was written only by `SessionEvent::InputTranscription`
1591    /// (ASR of audio), so a text-driven session handed every extractor an empty
1592    /// user turn.
1593    #[tokio::test]
1594    async fn control_sender_records_text_on_transcript() {
1595        use crate::live::extractor::TurnExtractor;
1596        use crate::live::transcript::TranscriptTurn;
1597        use crate::llm::LlmError;
1598
1599        /// Records the user side of the window it was handed.
1600        struct WindowRecorder(Arc<parking_lot::Mutex<Vec<String>>>);
1601
1602        #[async_trait::async_trait]
1603        impl TurnExtractor for WindowRecorder {
1604            fn name(&self) -> &str {
1605                "WindowRecorder"
1606            }
1607            fn window_size(&self) -> usize {
1608                3
1609            }
1610            async fn extract(
1611                &self,
1612                turns: &[TranscriptTurn],
1613            ) -> Result<serde_json::Value, LlmError> {
1614                self.0.lock().extend(turns.iter().map(|t| t.user.clone()));
1615                Ok(serde_json::json!({}))
1616            }
1617        }
1618
1619        let seen = Arc::new(parking_lot::Mutex::new(Vec::new()));
1620
1621        let (event_tx, _) = broadcast::channel(16);
1622        let event_rx = event_tx.subscribe();
1623        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1624        let extractors: Vec<Arc<dyn TurnExtractor>> = vec![Arc::new(WindowRecorder(seen.clone()))];
1625
1626        let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
1627            event_rx,
1628            Arc::new(EventCallbacks::default()),
1629            None,
1630            writer,
1631            extractors,
1632            State::new(),
1633            None,
1634            None,
1635            None,
1636            None,
1637            None,
1638            std::collections::HashMap::new(),
1639            ControlPlaneConfig::default(),
1640            dummy_event_tx(),
1641            None,
1642        );
1643
1644        // No `InputTranscription` event at all — this is the text path.
1645        let tx = ctrl_tx.upgrade().expect("control lane is alive");
1646        tx.send(ControlEvent::InputTranscript("I am pescatarian".into()))
1647            .await
1648            .expect("control lane accepts the typed turn");
1649        drop(tx);
1650
1651        tokio::time::sleep(Duration::from_millis(20)).await;
1652        let _ = event_tx.send(SessionEvent::TurnComplete);
1653        tokio::time::sleep(Duration::from_millis(100)).await;
1654
1655        assert_eq!(
1656            seen.lock().as_slice(),
1657            ["I am pescatarian"],
1658            "typed turn must reach the extractor's transcript window"
1659        );
1660
1661        drop(event_tx);
1662        let _ = fast_handle.await;
1663        let _ = ctrl_handle.await;
1664    }
1665
1666    /// The control sender handed to `LiveHandle` must be *weak*: a strong one
1667    /// would keep the control channel open forever, so the lane would never
1668    /// drain and shut down when the router exits.
1669    #[tokio::test]
1670    async fn control_sender_does_not_keep_lane_alive() {
1671        let (event_tx, _) = broadcast::channel(16);
1672        let event_rx = event_tx.subscribe();
1673        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1674
1675        let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
1676            event_rx,
1677            Arc::new(EventCallbacks::default()),
1678            None,
1679            writer,
1680            vec![],
1681            State::new(),
1682            None,
1683            None,
1684            None,
1685            None,
1686            None,
1687            std::collections::HashMap::new(),
1688            ControlPlaneConfig::default(),
1689            dummy_event_tx(),
1690            None,
1691        );
1692
1693        // Hold the handle's sender across shutdown, as `LiveHandle` does.
1694        drop(event_tx);
1695
1696        tokio::time::timeout(Duration::from_secs(2), ctrl_handle)
1697            .await
1698            .expect("control lane must shut down while the handle's sender is held")
1699            .expect("control lane joined cleanly");
1700
1701        assert!(
1702            ctrl_tx.upgrade().is_none(),
1703            "control channel must be closed once the router is gone"
1704        );
1705
1706        let _ = fast_handle.await;
1707    }
1708
1709    #[tokio::test]
1710    async fn telemetry_tap_counts_inline_and_forwards_only_state_events() {
1711        let telemetry = Arc::new(SessionTelemetry::new());
1712        let signals = Arc::new(SessionSignals::new(State::new()));
1713        let (mut tap, mut lane) = TelemetryTap::new(telemetry.clone(), signals);
1714
1715        tap.observe(&SessionEvent::AudioData(Bytes::from_static(b"chunk1")));
1716        tap.observe(&SessionEvent::AudioData(Bytes::from_static(b"chunk2")));
1717        tap.observe(&SessionEvent::TextDelta("hi".into()));
1718        tap.observe(&SessionEvent::VoiceActivityEnd);
1719        tokio::time::sleep(Duration::from_millis(5)).await;
1720        tap.observe(&SessionEvent::AudioData(Bytes::from_static(b"response")));
1721
1722        // Counters are recorded synchronously, in event order.
1723        let snap = telemetry.snapshot();
1724        assert_eq!(snap["audio_chunks_out"], 3);
1725        assert_eq!(snap["response_count"], 1);
1726
1727        // Audio and text never reach the lane: only the state-writing VAD
1728        // edge and the measured latency do.
1729        assert!(matches!(
1730            lane.try_recv(),
1731            Ok(TelemetryMsg::Event(SessionEvent::VoiceActivityEnd))
1732        ));
1733        assert!(matches!(
1734            lane.try_recv(),
1735            Ok(TelemetryMsg::ResponseLatency(_))
1736        ));
1737        assert!(lane.try_recv().is_err());
1738    }
1739
1740    #[tokio::test]
1741    async fn telemetry_lane_writes_signals_and_reports_usage() {
1742        let state = State::new();
1743        let telemetry = Arc::new(SessionTelemetry::new());
1744        let signals = Arc::new(SessionSignals::new(state.clone()));
1745        let (mut tap, lane) = TelemetryTap::new(telemetry.clone(), signals.clone());
1746        let usage_seen = Arc::new(AtomicU64::new(0));
1747        let seen = usage_seen.clone();
1748        let on_usage: super::super::callbacks::UsageCallback = Box::new(move |usage| {
1749            seen.store(
1750                usage.total_token_count.unwrap_or(0) as u64,
1751                Ordering::SeqCst,
1752            );
1753        });
1754        let cancel = CancellationToken::new();
1755        let handle = spawn_telemetry_lane(lane, signals, cancel.clone(), Some(on_usage));
1756
1757        let usage: gemini_genai_rs::prelude::UsageMetadata =
1758            serde_json::from_value(serde_json::json!({ "totalTokenCount": 42 })).expect("usage");
1759        tap.observe(&SessionEvent::VoiceActivityStart);
1760        tap.observe(&SessionEvent::Usage(usage));
1761        tap.observe(&SessionEvent::TurnComplete);
1762        tokio::time::sleep(Duration::from_millis(50)).await;
1763
1764        assert_eq!(state.session().get::<bool>("is_user_speaking"), Some(true));
1765        assert_eq!(state.session().get::<u64>("total_token_count"), Some(42));
1766        assert_eq!(usage_seen.load(Ordering::SeqCst), 42);
1767        assert_eq!(telemetry.snapshot()["total_token_count"], 42);
1768
1769        // Dropping the router's tap ends the lane.
1770        drop(tap);
1771        tokio::time::timeout(Duration::from_secs(1), handle)
1772            .await
1773            .expect("lane exits once the router is gone")
1774            .expect("lane task");
1775    }
1776
1777    #[tokio::test]
1778    async fn background_tool_sends_ack_immediately() {
1779        use crate::live::background_tool::{BackgroundToolTracker, ToolExecutionMode};
1780        use crate::tool::{SimpleTool, ToolDispatcher};
1781
1782        // Create a slow tool
1783        let tool = SimpleTool::new(
1784            "slow_search",
1785            "A slow search tool",
1786            Some(serde_json::json!({"type": "object", "properties": {"q": {"type": "string"}}})),
1787            |_args| async move {
1788                tokio::time::sleep(Duration::from_millis(200)).await;
1789                Ok(serde_json::json!({"results": ["found"]}))
1790            },
1791        );
1792
1793        let mut dispatcher = ToolDispatcher::new();
1794        dispatcher.register(tool);
1795
1796        let mut execution_modes = std::collections::HashMap::new();
1797        execution_modes.insert(
1798            "slow_search".to_string(),
1799            ToolExecutionMode::Background {
1800                formatter: None,
1801                scheduling: None,
1802            },
1803        );
1804
1805        let sent = Arc::new(parking_lot::Mutex::new(Vec::<Vec<FunctionResponse>>::new()));
1806        let sent_clone = sent.clone();
1807
1808        // Use a writer that records sent tool responses
1809        struct RecordingWriter {
1810            sent: Arc<parking_lot::Mutex<Vec<Vec<FunctionResponse>>>>,
1811        }
1812
1813        #[async_trait::async_trait]
1814        impl SessionWriter for RecordingWriter {
1815            async fn send_audio(
1816                &self,
1817                _data: bytes::Bytes,
1818            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1819                Ok(())
1820            }
1821            async fn send_text(
1822                &self,
1823                _text: String,
1824            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1825                Ok(())
1826            }
1827            async fn send_video(
1828                &self,
1829                _data: bytes::Bytes,
1830            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1831                Ok(())
1832            }
1833            async fn send_tool_response(
1834                &self,
1835                responses: Vec<FunctionResponse>,
1836            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1837                self.sent.lock().push(responses);
1838                Ok(())
1839            }
1840            async fn update_instruction(
1841                &self,
1842                _instruction: String,
1843            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1844                Ok(())
1845            }
1846            async fn send_client_content(
1847                &self,
1848                _content: Vec<gemini_genai_rs::prelude::Content>,
1849                _turn_complete: bool,
1850            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1851                Ok(())
1852            }
1853            async fn signal_activity_start(
1854                &self,
1855            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1856                Ok(())
1857            }
1858            async fn signal_activity_end(
1859                &self,
1860            ) -> Result<(), gemini_genai_rs::session::SessionError> {
1861                Ok(())
1862            }
1863            async fn disconnect(&self) -> Result<(), gemini_genai_rs::session::SessionError> {
1864                Ok(())
1865            }
1866        }
1867
1868        let writer: Arc<dyn SessionWriter> = Arc::new(RecordingWriter { sent: sent_clone });
1869        let callbacks = Arc::new(EventCallbacks::default());
1870        let tracker = Arc::new(BackgroundToolTracker::new());
1871
1872        let (event_tx, _) = broadcast::channel(16);
1873        let event_rx = event_tx.subscribe();
1874
1875        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1876            event_rx,
1877            callbacks,
1878            Some(Arc::new(dispatcher)),
1879            writer,
1880            vec![],
1881            State::new(),
1882            None,
1883            None,
1884            None,
1885            None,
1886            Some(tracker.clone()),
1887            execution_modes,
1888            ControlPlaneConfig::default(),
1889            dummy_event_tx(),
1890            None,
1891        );
1892
1893        // Send a tool call
1894        let _ = event_tx.send(SessionEvent::ToolCall(vec![
1895            gemini_genai_rs::prelude::FunctionCall {
1896                name: "slow_search".to_string(),
1897                args: serde_json::json!({"q": "test"}),
1898                id: Some("fc_1".to_string()),
1899            },
1900        ]));
1901
1902        // Wait just enough for the ack (but not the full tool)
1903        tokio::time::sleep(Duration::from_millis(50)).await;
1904
1905        // Scope the guard so it is never held across an await point.
1906        {
1907            let responses = sent.lock();
1908            // First batch should be the ack
1909            assert!(!responses.is_empty(), "Should have sent ack immediately");
1910            assert_eq!(responses[0][0].response["status"], "running");
1911        }
1912
1913        // Wait for background tool to complete
1914        tokio::time::sleep(Duration::from_millis(300)).await;
1915
1916        {
1917            let responses = sent.lock();
1918            // Second batch should be the completed result
1919            assert!(
1920                responses.len() >= 2,
1921                "Should have sent result after completion"
1922            );
1923            assert_eq!(responses[1][0].response["status"], "completed");
1924        }
1925
1926        drop(event_tx);
1927        let _ = fast_handle.await;
1928        let _ = ctrl_handle.await;
1929    }
1930
1931    #[tokio::test]
1932    async fn callback_mode_blocking_awaits_inline() {
1933        use crate::live::ExecutionMode;
1934        use std::sync::atomic::AtomicU32;
1935
1936        let order = Arc::new(AtomicU32::new(0));
1937        let order_clone = order.clone();
1938
1939        let callbacks = EventCallbacks {
1940            // Blocking on_turn_complete sets order to 1
1941            on_turn_complete: Some(Arc::new(move || {
1942                let o = order_clone.clone();
1943                Box::pin(async move {
1944                    // Simulate brief work
1945                    tokio::time::sleep(Duration::from_millis(10)).await;
1946                    o.store(1, Ordering::SeqCst);
1947                })
1948            })),
1949            on_turn_complete_mode: ExecutionMode::Blocking,
1950            ..Default::default()
1951        };
1952        let callbacks = Arc::new(callbacks);
1953
1954        let (event_tx, _) = broadcast::channel(16);
1955        let event_rx = event_tx.subscribe();
1956
1957        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
1958
1959        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
1960            event_rx,
1961            callbacks,
1962            None,
1963            writer,
1964            vec![],
1965            State::new(),
1966            None,
1967            None,
1968            None,
1969            None,
1970            None,
1971            std::collections::HashMap::new(),
1972            ControlPlaneConfig::default(),
1973            dummy_event_tx(),
1974            None,
1975        );
1976
1977        let _ = event_tx.send(SessionEvent::TurnComplete);
1978        tokio::time::sleep(Duration::from_millis(100)).await;
1979
1980        // Blocking mode: callback completed before control lane processed next event
1981        assert_eq!(order.load(Ordering::SeqCst), 1);
1982
1983        drop(event_tx);
1984        let _ = fast_handle.await;
1985        let _ = ctrl_handle.await;
1986    }
1987
1988    #[tokio::test]
1989    async fn interruption_beats_slow_inline_tool() {
1990        use crate::tool::{SimpleTool, ToolDispatcher};
1991
1992        // A slow inline tool that blocks the control lane for 5s.
1993        let mut dispatcher = ToolDispatcher::new();
1994        dispatcher.register(SimpleTool::new("slow", "slow", None, |_args| async move {
1995            tokio::time::sleep(Duration::from_secs(5)).await;
1996            Ok(serde_json::json!({"done": true}))
1997        }));
1998
1999        let interrupted_at = Arc::new(parking_lot::Mutex::new(None::<std::time::Instant>));
2000        let flag = interrupted_at.clone();
2001        let callbacks = EventCallbacks {
2002            on_interrupted: Some(Arc::new(move || {
2003                let flag = flag.clone();
2004                Box::pin(async move {
2005                    *flag.lock() = Some(std::time::Instant::now());
2006                })
2007            })),
2008            ..Default::default()
2009        };
2010
2011        let (event_tx, _) = broadcast::channel(16);
2012        let event_rx = event_tx.subscribe();
2013        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
2014
2015        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
2016            event_rx,
2017            Arc::new(callbacks),
2018            Some(Arc::new(dispatcher)),
2019            writer,
2020            vec![],
2021            State::new(),
2022            None,
2023            None,
2024            None,
2025            None,
2026            None,
2027            std::collections::HashMap::new(),
2028            ControlPlaneConfig::default(),
2029            dummy_event_tx(),
2030            None,
2031        );
2032
2033        // Tool call starts the 5s dispatch, then the user barges in.
2034        let start = std::time::Instant::now();
2035        let _ = event_tx.send(SessionEvent::ToolCall(vec![
2036            gemini_genai_rs::prelude::FunctionCall {
2037                name: "slow".to_string(),
2038                args: serde_json::json!({}),
2039                id: Some("fc_slow".to_string()),
2040            },
2041        ]));
2042        tokio::time::sleep(Duration::from_millis(50)).await;
2043        let _ = event_tx.send(SessionEvent::Interrupted);
2044
2045        // The interruption must be processed long before the tool's 5s —
2046        // before the fix it queued behind the blocking dispatch.
2047        let mut waited = Duration::ZERO;
2048        while interrupted_at.lock().is_none() && waited < Duration::from_secs(2) {
2049            tokio::time::sleep(Duration::from_millis(25)).await;
2050            waited += Duration::from_millis(25);
2051        }
2052        let fired = (*interrupted_at.lock()).expect("on_interrupted must fire");
2053        assert!(
2054            fired.duration_since(start) < Duration::from_secs(2),
2055            "interruption must not wait for the slow tool"
2056        );
2057
2058        drop(event_tx);
2059        let _ = fast_handle.await;
2060        let _ = ctrl_handle.await;
2061    }
2062
2063    #[tokio::test]
2064    async fn control_lane_exit_persists_final_snapshot_synchronously() {
2065        use crate::live::persistence::{MemoryPersistence, SessionPersistence};
2066
2067        let persistence = Arc::new(MemoryPersistence::new());
2068        let control_plane = ControlPlaneConfig {
2069            persistence: Some(persistence.clone()),
2070            session_id: Some("final-drain".to_string()),
2071            ..Default::default()
2072        };
2073
2074        let (event_tx, _) = broadcast::channel(16);
2075        let event_rx = event_tx.subscribe();
2076        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
2077
2078        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
2079            event_rx,
2080            Arc::new(EventCallbacks::default()),
2081            None,
2082            writer,
2083            vec![],
2084            State::new(),
2085            None,
2086            None,
2087            None,
2088            None,
2089            None,
2090            std::collections::HashMap::new(),
2091            control_plane,
2092            dummy_event_tx(),
2093            None,
2094        );
2095
2096        // Accumulate state mid-turn — but never reach a TurnComplete, so the
2097        // per-turn (spawn-and-forget) save never fires.
2098        let _ = event_tx.send(SessionEvent::InputTranscription("last words".to_string()));
2099        tokio::time::sleep(Duration::from_millis(50)).await;
2100
2101        // Session ends. The control lane must run a final synchronous save on
2102        // exit; before the fix nothing was ever persisted in this scenario.
2103        drop(event_tx);
2104        let _ = fast_handle.await;
2105        let _ = ctrl_handle.await;
2106
2107        let snap = persistence
2108            .load("final-drain")
2109            .await
2110            .unwrap()
2111            .expect("control-lane exit must persist a final snapshot");
2112        assert_eq!(snap.turn_count, 0);
2113    }
2114
2115    #[tokio::test]
2116    async fn lanes_exit_after_terminal_disconnected_event() {
2117        // The Disconnected event is terminal in L0; the router must exit after
2118        // routing it (dropping its lane senders) so the lanes can drain and
2119        // shut down gracefully — even though the broadcast sender stays alive
2120        // for the LiveHandle's lifetime.
2121        let callbacks = Arc::new(EventCallbacks::default());
2122        let (event_tx, _) = broadcast::channel(16);
2123        let event_rx = event_tx.subscribe();
2124        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
2125
2126        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
2127            event_rx,
2128            callbacks,
2129            None,
2130            writer,
2131            vec![],
2132            State::new(),
2133            None,
2134            None,
2135            None,
2136            None,
2137            None,
2138            std::collections::HashMap::new(),
2139            ControlPlaneConfig::default(),
2140            dummy_event_tx(),
2141            None,
2142        );
2143
2144        let _ = event_tx.send(SessionEvent::Disconnected(None));
2145
2146        // NOTE: event_tx is intentionally kept alive — before the fix the
2147        // router only exited on channel close, and both awaits below hung.
2148        let joined = tokio::time::timeout(Duration::from_secs(2), async {
2149            let _ = fast_handle.await;
2150            let _ = ctrl_handle.await;
2151        })
2152        .await;
2153        assert!(
2154            joined.is_ok(),
2155            "lanes must exit after the terminal Disconnected event"
2156        );
2157        drop(event_tx);
2158    }
2159
2160    #[tokio::test]
2161    async fn callback_mode_concurrent_spawns_task() {
2162        use crate::live::ExecutionMode;
2163
2164        let called = Arc::new(AtomicBool::new(false));
2165        let called_clone = called.clone();
2166
2167        let callbacks = EventCallbacks {
2168            on_turn_complete: Some(Arc::new(move || {
2169                let c = called_clone.clone();
2170                Box::pin(async move {
2171                    tokio::time::sleep(Duration::from_millis(10)).await;
2172                    c.store(true, Ordering::SeqCst);
2173                })
2174            })),
2175            on_turn_complete_mode: ExecutionMode::Concurrent,
2176            ..Default::default()
2177        };
2178        let callbacks = Arc::new(callbacks);
2179
2180        let (event_tx, _) = broadcast::channel(16);
2181        let event_rx = event_tx.subscribe();
2182
2183        let writer: Arc<dyn SessionWriter> = Arc::new(crate::agent_session::NoOpSessionWriter);
2184
2185        let (fast_handle, ctrl_handle, _ctrl_tx) = spawn_event_processor(
2186            event_rx,
2187            callbacks,
2188            None,
2189            writer,
2190            vec![],
2191            State::new(),
2192            None,
2193            None,
2194            None,
2195            None,
2196            None,
2197            std::collections::HashMap::new(),
2198            ControlPlaneConfig::default(),
2199            dummy_event_tx(),
2200            None,
2201        );
2202
2203        let _ = event_tx.send(SessionEvent::TurnComplete);
2204        // Give spawned task time to complete
2205        tokio::time::sleep(Duration::from_millis(100)).await;
2206
2207        // Concurrent mode: callback was spawned and eventually completed
2208        assert!(called.load(Ordering::SeqCst));
2209
2210        drop(event_tx);
2211        let _ = fast_handle.await;
2212        let _ = ctrl_handle.await;
2213    }
2214}