gemini_adk_rs/live/
builder.rs

1//! LiveSessionBuilder — combines SessionConfig + callbacks + tools into one setup.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use tokio_util::sync::CancellationToken;
7
8use gemini_genai_rs::prelude::{ConnectBuilder, SessionConfig, SessionEvent, SessionPhase};
9use gemini_genai_rs::session::{SessionError, SessionHandle, SessionWriter};
10
11use crate::error::AgentError;
12use crate::state::State;
13use crate::tool::ToolDispatcher;
14
15use super::background_tool::{BackgroundToolTracker, ToolExecutionMode};
16use super::callbacks::EventCallbacks;
17use super::computed::ComputedRegistry;
18use super::context_writer::{DeferredWriter, PendingContext};
19use super::extractor::TurnExtractor;
20use super::handle::LiveHandle;
21use super::needs::{NeedsFulfillment, RepairConfig};
22use super::persistence::SessionPersistence;
23use super::phase::PhaseMachine;
24use super::processor::{ControlPlaneConfig, spawn_event_processor, spawn_telemetry_lane};
25use super::session_signals::SessionSignals;
26use super::soft_turn::SoftTurnDetector;
27use super::steering::{ContextDelivery, SteeringMode};
28use super::telemetry::SessionTelemetry;
29use super::temporal::TemporalRegistry;
30use super::watcher::WatcherRegistry;
31
32/// Builder for a callback-driven Live session.
33///
34/// Combines [`SessionConfig`], [`EventCallbacks`], tool dispatching, extractors,
35/// computed state, phase machines, watchers, and temporal patterns into a
36/// single connection setup. Call [`connect()`](Self::connect) to establish
37/// the WebSocket connection and start the three-lane event processor.
38///
39/// For ergonomic usage, prefer the L2 `Live` builder from `gemini-adk-fluent-rs`
40/// which wraps this with a fluent API.
41pub struct LiveSessionBuilder {
42    config: SessionConfig,
43    callbacks: EventCallbacks,
44    dispatcher: Option<Arc<ToolDispatcher>>,
45    extractors: Vec<Arc<dyn TurnExtractor>>,
46    computed: Option<ComputedRegistry>,
47    phase_machine: Option<PhaseMachine>,
48    watchers: Option<WatcherRegistry>,
49    temporal: Option<TemporalRegistry>,
50    greeting: Option<String>,
51    state: Option<State>,
52    execution_modes: HashMap<String, ToolExecutionMode>,
53    // Control plane configuration
54    soft_turn_timeout: Option<std::time::Duration>,
55    steering_mode: SteeringMode,
56    context_delivery: ContextDelivery,
57    delivery: super::processor::DeliveryConfig,
58    repair_config: Option<RepairConfig>,
59    persistence: Option<Arc<dyn SessionPersistence>>,
60    session_id: Option<String>,
61    tool_advisory: bool,
62    telemetry_interval: Option<std::time::Duration>,
63    middleware: Vec<Arc<dyn crate::middleware::Middleware>>,
64    flow: Option<crate::flow::FlowMonitor>,
65    redactor: Option<Arc<super::redaction::TranscriptRedactor>>,
66}
67
68impl LiveSessionBuilder {
69    /// Create a new builder with the given session config.
70    pub fn new(config: SessionConfig) -> Self {
71        Self {
72            config,
73            callbacks: EventCallbacks::default(),
74            dispatcher: None,
75            extractors: Vec::new(),
76            computed: None,
77            phase_machine: None,
78            watchers: None,
79            temporal: None,
80            greeting: None,
81            state: None,
82            execution_modes: HashMap::new(),
83            soft_turn_timeout: None,
84            steering_mode: SteeringMode::default(),
85            context_delivery: ContextDelivery::default(),
86            delivery: super::processor::DeliveryConfig::default(),
87            repair_config: None,
88            persistence: None,
89            session_id: None,
90            tool_advisory: true,
91            telemetry_interval: None,
92            middleware: Vec::new(),
93            flow: None,
94            redactor: None,
95        }
96    }
97
98    /// Install transcript redaction — see
99    /// [`redaction::TranscriptRedactor`](super::redaction::TranscriptRedactor).
100    ///
101    /// Applied at the event router, before callbacks, the transcript buffer,
102    /// extraction, or persistence see the text. An inactive redactor (no
103    /// rules enabled) is dropped rather than installed.
104    pub fn redaction(mut self, redactor: super::redaction::TranscriptRedactor) -> Self {
105        self.redactor = redactor.is_active().then(|| Arc::new(redactor));
106        self
107    }
108
109    /// Add a middleware layer.
110    ///
111    /// Layers run around tool dispatch in the control lane: `before_tool`
112    /// (a returned error vetoes the call), `after_tool`, and `on_tool_error`.
113    /// Multiple calls accumulate in order.
114    pub fn middleware(mut self, layer: Arc<dyn crate::middleware::Middleware>) -> Self {
115        self.middleware.push(layer);
116        self
117    }
118
119    /// Attach a governed-flow monitor (built from a `Flow` + `Mode`).
120    pub fn flow_monitor(mut self, monitor: crate::flow::FlowMonitor) -> Self {
121        self.flow = Some(monitor);
122        self
123    }
124
125    /// Provide a pre-created State to use for this session.
126    ///
127    /// If not set, a new State is created at connect time. Use this when
128    /// the State needs to be shared with tools or other components before
129    /// the session connects.
130    pub fn state(mut self, state: State) -> Self {
131        self.state = Some(state);
132        self
133    }
134
135    /// Set a greeting prompt sent on connect to trigger the model to speak first.
136    pub fn greeting(mut self, prompt: impl Into<String>) -> Self {
137        self.greeting = Some(prompt.into());
138        self
139    }
140
141    /// Set the tool dispatcher for auto-dispatch of tool calls.
142    pub fn dispatcher(mut self, dispatcher: ToolDispatcher) -> Self {
143        // Add tool declarations to session config
144        for tool in dispatcher.to_tool_declarations() {
145            self.config = self.config.add_tool(tool);
146        }
147        self.dispatcher = Some(Arc::new(dispatcher));
148        self
149    }
150
151    /// Set the event callbacks.
152    pub fn callbacks(mut self, callbacks: EventCallbacks) -> Self {
153        self.callbacks = callbacks;
154        self
155    }
156
157    /// Add a turn extractor that runs between turns.
158    pub fn extractor(mut self, extractor: Arc<dyn TurnExtractor>) -> Self {
159        self.extractors.push(extractor);
160        self
161    }
162
163    /// Set the computed variable registry for derived state.
164    pub fn computed(mut self, registry: ComputedRegistry) -> Self {
165        self.computed = Some(registry);
166        self
167    }
168
169    /// Set the phase machine for declarative conversation phase management.
170    pub fn phase_machine(mut self, machine: PhaseMachine) -> Self {
171        self.phase_machine = Some(machine);
172        self
173    }
174
175    /// Set the watcher registry for state change watchers.
176    pub fn watchers(mut self, registry: WatcherRegistry) -> Self {
177        self.watchers = Some(registry);
178        self
179    }
180
181    /// Set the temporal pattern registry.
182    pub fn temporal(mut self, registry: TemporalRegistry) -> Self {
183        self.temporal = Some(registry);
184        self
185    }
186
187    /// Set the execution mode for a named tool.
188    ///
189    /// Tools default to [`ToolExecutionMode::Standard`]. Set to
190    /// [`ToolExecutionMode::Background`] for zero-dead-air execution.
191    pub fn tool_execution_mode(
192        mut self,
193        tool_name: impl Into<String>,
194        mode: ToolExecutionMode,
195    ) -> Self {
196        self.execution_modes.insert(tool_name.into(), mode);
197        self
198    }
199
200    /// Enable soft turn detection for proactive silence awareness.
201    ///
202    /// When `proactiveAudio` is enabled, the model may choose not to respond.
203    /// This sets a timeout after VAD end — if the model stays silent, a
204    /// lightweight "soft turn" fires to keep state updated without forcing
205    /// the model to speak.
206    pub fn soft_turn_timeout(mut self, timeout: std::time::Duration) -> Self {
207        self.soft_turn_timeout = Some(timeout);
208        self
209    }
210
211    /// Set the steering mode for how the phase machine delivers instructions.
212    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
213        self.steering_mode = mode;
214        self
215    }
216
217    /// Set the context delivery timing.
218    ///
219    /// - `Immediate` (default): send batched context during TurnComplete.
220    /// - `Deferred`: queue context and flush with next user send.
221    pub fn context_delivery(mut self, mode: ContextDelivery) -> Self {
222        self.context_delivery = mode;
223        self
224    }
225
226    /// Set the fast-lane delivery (backpressure) policy per event class.
227    ///
228    /// Defaults to all-[`Lossless`](super::processor::Delivery::Lossless), which
229    /// preserves the historical `send().await` routing behavior. Opt classes
230    /// into [`LossyDropNewest`](super::processor::Delivery::LossyDropNewest) to
231    /// keep the router from stalling when a fast-lane consumer falls behind.
232    pub fn delivery(mut self, delivery: super::processor::DeliveryConfig) -> Self {
233        self.delivery = delivery;
234        self
235    }
236
237    /// Enable the conversation repair protocol.
238    ///
239    /// Tracks need fulfillment per phase and nudges the model when the
240    /// conversation stalls on gathering required information.
241    pub fn repair(mut self, config: RepairConfig) -> Self {
242        self.repair_config = Some(config);
243        self
244    }
245
246    /// Set a session persistence backend for surviving process restarts.
247    pub fn persistence(mut self, backend: Arc<dyn SessionPersistence>) -> Self {
248        self.persistence = Some(backend);
249        self
250    }
251
252    /// Set the session ID for persistence.
253    pub fn session_id(mut self, id: impl Into<String>) -> Self {
254        self.session_id = Some(id.into());
255        self
256    }
257
258    /// Enable or disable tool availability advisory on phase transitions.
259    pub fn tool_advisory(mut self, enabled: bool) -> Self {
260        self.tool_advisory = enabled;
261        self
262    }
263
264    /// Set the periodic telemetry emission interval.
265    ///
266    /// When set, the processor periodically emits `LiveEvent::Telemetry`
267    /// and `LiveEvent::TurnMetrics` to the event stream.
268    pub fn telemetry_interval(mut self, interval: std::time::Duration) -> Self {
269        self.telemetry_interval = Some(interval);
270        self
271    }
272
273    /// Connect to Gemini and start the three-lane event processor.
274    ///
275    /// This is a thin orchestrator over three explicit, behavior-preserving
276    /// stages:
277    ///
278    /// 1. `into_plan` — pure derivation/validation of the resolved startup
279    ///    configuration (`SessionPlan`); no I/O, no spawning.
280    /// 2. Connect the L0 transport using the plan's resolved [`SessionConfig`].
281    /// 3. `build_runtime` — assemble the runtime wiring (channels, shared
282    ///    state, dispatcher, control plane) from the plan + connected session.
283    /// 4. `spawn_lanes` — spawn the telemetry/event/tool lanes and return the
284    ///    assembled [`LiveHandle`].
285    pub async fn connect(self) -> Result<LiveHandle, AgentError> {
286        let mut plan = self.into_plan()?;
287
288        // Connect via L0 using the resolved config (taken out of the plan so
289        // the rest of the plan can be moved into the runtime stage).
290        let config = plan.config.take().expect("plan always carries a config");
291        let session = ConnectBuilder::new(config)
292            .connect()
293            .await
294            .map_err(AgentError::Session)?;
295
296        // Wait for Active phase — or for the session to give up trying.
297        //
298        // `wait_for_phase` alone waits forever: the L0 session loop retries the
299        // setup handshake `max_reconnect_attempts` times, then emits
300        // `Disconnected` and returns, but the phase watch stays alive because
301        // the handle holds it. Active never arrives and nothing ever wakes the
302        // waiter — so a permanently unacceptable setup (a retired model name is
303        // the common one) wedges `connect()` with no error, forever. Racing the
304        // terminal event turns that into a returned failure.
305        let mut events = session.subscribe();
306        tokio::select! {
307            () = session.wait_for_phase(SessionPhase::Active) => {}
308            failure = wait_for_connect_failure(&mut events) => {
309                return Err(AgentError::Session(SessionError::SetupFailed(
310                    gemini_genai_rs::session::SetupError::ServerRejected {
311                        code: None,
312                        message: failure,
313                    },
314                )));
315            }
316        }
317
318        let runtime = build_runtime(plan, session);
319        spawn_lanes(runtime).await
320    }
321
322    /// Derive the resolved [`SessionPlan`] from this builder.
323    ///
324    /// This is a pure transformation: it runs build-time validations and
325    /// resolves the startup configuration (notably applying `NonBlocking`
326    /// behavior to background-tool declarations) without performing any I/O or
327    /// spawning any tasks. It is unit-testable without a live connection.
328    pub(crate) fn into_plan(self) -> Result<SessionPlan, AgentError> {
329        // Build-time validations
330        if let Some(ref pm) = self.phase_machine {
331            pm.validate()?;
332        }
333        if let Some(ref computed) = self.computed {
334            computed.validate()?;
335        }
336
337        // Apply NON_BLOCKING behavior to tool declarations for background tools
338        let mut config = self.config;
339        for (tool_name, mode) in &self.execution_modes {
340            if matches!(
341                mode,
342                super::background_tool::ToolExecutionMode::Background { .. }
343            ) {
344                for tool in &mut config.tools {
345                    if let Some(ref mut decls) = tool.function_declarations {
346                        for decl in decls {
347                            if decl.name == *tool_name {
348                                decl.behavior = Some(
349                                    gemini_genai_rs::prelude::FunctionCallingBehavior::NonBlocking,
350                                );
351                            }
352                        }
353                    }
354                }
355            }
356        }
357
358        // The instruction the caller set at connect, kept as text so an
359        // `instruction_amendment` has something to compose onto in a session
360        // with no phase machine. Without it such an amendment was computed and
361        // then dropped — see the note in `control_plane::lifecycle` step 10.
362        let base_instruction = config.system_instruction.as_ref().map(|content| {
363            content
364                .parts
365                .iter()
366                .filter_map(|part| match part {
367                    gemini_genai_rs::prelude::Part::Text { text } => Some(text.as_str()),
368                    _ => None,
369                })
370                .collect::<Vec<_>>()
371                .join("\n")
372        });
373
374        Ok(SessionPlan {
375            config: Some(config),
376            base_instruction,
377            callbacks: self.callbacks,
378            dispatcher: self.dispatcher,
379            extractors: self.extractors,
380            computed: self.computed,
381            phase_machine: self.phase_machine,
382            watchers: self.watchers,
383            temporal: self.temporal,
384            greeting: self.greeting,
385            state: self.state,
386            execution_modes: self.execution_modes,
387            soft_turn_timeout: self.soft_turn_timeout,
388            steering_mode: self.steering_mode,
389            context_delivery: self.context_delivery,
390            delivery: self.delivery,
391            repair_config: self.repair_config,
392            persistence: self.persistence,
393            session_id: self.session_id,
394            tool_advisory: self.tool_advisory,
395            telemetry_interval: self.telemetry_interval,
396            middleware: self.middleware,
397            flow: self.flow,
398            redactor: self.redactor,
399        })
400    }
401}
402
403/// The resolved startup configuration for a Live session.
404///
405/// Produced purely from a [`LiveSessionBuilder`] via
406/// [`into_plan`](LiveSessionBuilder::into_plan) — no I/O, no task spawning. The
407/// `config` is held in an `Option` so [`connect`](LiveSessionBuilder::connect)
408/// can take it out to open the transport while moving the remaining plan into
409/// [`build_runtime`].
410pub(crate) struct SessionPlan {
411    /// Resolved session config (background-tool `NonBlocking` already applied).
412    /// `Some` until the transport is opened; `connect` takes it out.
413    config: Option<SessionConfig>,
414    /// The connect-time system instruction as text, for amendments to compose
415    /// onto when there is no phase to supply a base.
416    base_instruction: Option<String>,
417    callbacks: EventCallbacks,
418    dispatcher: Option<Arc<ToolDispatcher>>,
419    extractors: Vec<Arc<dyn TurnExtractor>>,
420    computed: Option<ComputedRegistry>,
421    phase_machine: Option<PhaseMachine>,
422    watchers: Option<WatcherRegistry>,
423    temporal: Option<TemporalRegistry>,
424    greeting: Option<String>,
425    state: Option<State>,
426    execution_modes: HashMap<String, ToolExecutionMode>,
427    soft_turn_timeout: Option<std::time::Duration>,
428    steering_mode: SteeringMode,
429    context_delivery: ContextDelivery,
430    delivery: super::processor::DeliveryConfig,
431    repair_config: Option<RepairConfig>,
432    persistence: Option<Arc<dyn SessionPersistence>>,
433    session_id: Option<String>,
434    tool_advisory: bool,
435    telemetry_interval: Option<std::time::Duration>,
436    middleware: Vec<Arc<dyn crate::middleware::Middleware>>,
437    flow: Option<crate::flow::FlowMonitor>,
438    redactor: Option<Arc<super::redaction::TranscriptRedactor>>,
439}
440
441/// Fully wired runtime for a connected Live session, ready for lane spawning.
442///
443/// Produced by [`build_runtime`] from a [`SessionPlan`] plus the connected
444/// [`SessionHandle`]. Holds the channels, shared atomics/state, dispatcher,
445/// control-plane config, and the resolved writers — but spawns nothing. The
446/// final stage [`spawn_lanes`] consumes this to start the lanes and assemble
447/// the [`LiveHandle`].
448pub(crate) struct SessionRuntime {
449    session: SessionHandle,
450    callbacks: Arc<EventCallbacks>,
451    dispatcher: Option<Arc<ToolDispatcher>>,
452    extractors: Vec<Arc<dyn TurnExtractor>>,
453    computed: Option<ComputedRegistry>,
454    phase_machine: Option<tokio::sync::Mutex<PhaseMachine>>,
455    watchers: Option<WatcherRegistry>,
456    temporal: Option<Arc<TemporalRegistry>>,
457    greeting: Option<String>,
458    state: State,
459    execution_modes: HashMap<String, ToolExecutionMode>,
460    background_tracker: Arc<BackgroundToolTracker>,
461    telemetry: Arc<SessionTelemetry>,
462    telemetry_interval: Option<std::time::Duration>,
463    control_plane: ControlPlaneConfig,
464    pending_context: Option<Arc<PendingContext>>,
465    /// Writer used by the processor for internal sends.
466    writer: Arc<dyn SessionWriter>,
467    /// User-facing writer handed to the `LiveHandle` (and used for greeting).
468    user_writer: Arc<dyn SessionWriter>,
469    event_rx: tokio::sync::broadcast::Receiver<gemini_genai_rs::prelude::SessionEvent>,
470    telem_rx: tokio::sync::broadcast::Receiver<gemini_genai_rs::prelude::SessionEvent>,
471    on_usage_cb: Option<super::callbacks::UsageCallback>,
472    live_event_tx: tokio::sync::broadcast::Sender<super::events::LiveEvent>,
473    telem_cancel: CancellationToken,
474    flow_monitor: Option<crate::flow::SharedFlowMonitor>,
475}
476
477/// Stage 3 input: construct the runtime wiring from a resolved plan and the
478/// connected session. Builds channels, shared state, the dispatcher set, the
479/// control-plane config (including deferred-context writer wrapping), and the
480/// telemetry handle — but does not spawn any lanes.
481pub(crate) fn build_runtime(plan: SessionPlan, session: SessionHandle) -> SessionRuntime {
482    // Share the governed-flow monitor between the control lane (which
483    // advances it) and the LiveHandle (which snapshots explain).
484    let flow_monitor = plan.flow.map(crate::flow::FlowMonitor::into_shared);
485    let mut callbacks = plan.callbacks;
486    let on_usage_cb = callbacks.on_usage.take();
487    let callbacks = Arc::new(callbacks);
488    let raw_writer: Arc<dyn SessionWriter> = Arc::new(session.clone());
489    let state = plan.state.unwrap_or_default();
490
491    // Subscribe twice: one for router → fast/ctrl, one for telemetry lane
492    let event_rx = session.subscribe();
493    let telem_rx = session.subscribe();
494
495    // Store initial phase's `needs` metadata for ContextBuilder.
496    if let Some(ref pm) = plan.phase_machine {
497        let _ = state.session().set("phase", pm.current());
498        if let Some(phase) = pm.current_phase()
499            && !phase.needs.is_empty()
500        {
501            let _ = state.set("session:phase_needs", phase.needs.clone());
502        }
503    }
504
505    let phase_machine_mutex = plan.phase_machine.map(tokio::sync::Mutex::new);
506    let temporal_arc = plan.temporal.map(Arc::new);
507    let background_tracker = Arc::new(BackgroundToolTracker::new());
508
509    // Create telemetry (auto-collected by the telemetry lane)
510    let telemetry = Arc::new(SessionTelemetry::new());
511    let telem_cancel = CancellationToken::new();
512
513    // Build control plane config
514    let mut control_plane = ControlPlaneConfig {
515        soft_turn: plan.soft_turn_timeout.map(SoftTurnDetector::new),
516        steering_mode: plan.steering_mode,
517        context_delivery: plan.context_delivery,
518        delivery: plan.delivery,
519        needs_fulfillment: plan.repair_config.map(NeedsFulfillment::new),
520        persistence: plan.persistence,
521        session_id: plan.session_id,
522        tool_advisory: plan.tool_advisory,
523        base_instruction: plan.base_instruction,
524        pending_context: None, // set after PendingContext is created below
525        middleware: {
526            let mut chain = crate::middleware::MiddlewareChain::new();
527            for layer in plan.middleware {
528                chain.add(layer);
529            }
530            Arc::new(chain)
531        },
532        flow: flow_monitor.clone(),
533        redactor: plan.redactor,
534    };
535
536    // Create shared PendingContext for deferred delivery.
537    // The SAME Arc is given to both the DeferredWriter (which drains it before
538    // user sends) and the ControlPlaneConfig (which the processor uses to push
539    // context turns from the control lane).
540    let pending_context = if plan.context_delivery == ContextDelivery::Deferred {
541        Some(Arc::new(PendingContext::new()))
542    } else {
543        None
544    };
545
546    // Wrap writer in DeferredWriter if deferred context delivery is enabled.
547    let (writer, user_writer) = if let Some(ref pending) = pending_context {
548        let deferred: Arc<dyn SessionWriter> =
549            Arc::new(DeferredWriter::new(raw_writer.clone(), pending.clone()));
550        // Processor uses raw_writer for internal sends (lifecycle context
551        // goes through PendingContext, not through the writer directly).
552        // User-facing LiveHandle uses the DeferredWriter.
553        (raw_writer, deferred)
554    } else {
555        (raw_writer.clone(), raw_writer)
556    };
557
558    // Pass shared pending context to control plane config
559    control_plane.pending_context = pending_context.clone();
560
561    // Create LiveEvent broadcast channel
562    use super::events::LiveEvent;
563    use tokio::sync::broadcast;
564    let (live_event_tx, _) = broadcast::channel::<LiveEvent>(4096);
565
566    SessionRuntime {
567        session,
568        callbacks,
569        dispatcher: plan.dispatcher,
570        extractors: plan.extractors,
571        computed: plan.computed,
572        phase_machine: phase_machine_mutex,
573        watchers: plan.watchers,
574        temporal: temporal_arc,
575        greeting: plan.greeting,
576        state,
577        execution_modes: plan.execution_modes,
578        background_tracker,
579        telemetry,
580        telemetry_interval: plan.telemetry_interval,
581        control_plane,
582        pending_context,
583        writer,
584        user_writer,
585        event_rx,
586        telem_rx,
587        on_usage_cb,
588        live_event_tx,
589        telem_cancel,
590        flow_monitor,
591    }
592}
593
594/// Stage 4: spawn the telemetry lane, event processor, and periodic telemetry
595/// emitter, send any greeting, and assemble the [`LiveHandle`].
596/// Resolve once the session has definitively failed to come up, returning a
597/// message that names the cause.
598///
599/// The L0 loop reports each failed setup attempt as `Error` and only says
600/// `Disconnected` when it stops retrying, so the last `Error` carries the real
601/// reason ("Setup failed: …") while `Disconnected` carries only "max attempts
602/// exceeded". Both are reported: the terminal event proves it is over, the
603/// retained error says why.
604///
605/// Pends forever if the session comes up normally — the caller races it against
606/// the phase wait.
607async fn wait_for_connect_failure(
608    events: &mut tokio::sync::broadcast::Receiver<SessionEvent>,
609) -> String {
610    let mut last_error: Option<String> = None;
611    loop {
612        match events.recv().await {
613            Ok(SessionEvent::Error(err)) => last_error = Some(err.to_string()),
614            Ok(SessionEvent::Disconnected(reason)) => {
615                let reason = reason.unwrap_or_else(|| "connection closed".to_string());
616                return match last_error {
617                    Some(err) => format!("{reason} ({err})"),
618                    None => reason,
619                };
620            }
621            Ok(_) => {}
622            // The sender is gone, which is itself terminal.
623            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
624                return last_error.unwrap_or_else(|| "session ended during setup".to_string());
625            }
626            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
627        }
628    }
629}
630
631pub(crate) async fn spawn_lanes(rt: SessionRuntime) -> Result<LiveHandle, AgentError> {
632    use super::events::LiveEvent;
633
634    // Spawn telemetry lane (SessionSignals + SessionTelemetry on own broadcast rx)
635    let session_signals = SessionSignals::new(rt.state.clone());
636    let _telem_handle = spawn_telemetry_lane(
637        rt.telem_rx,
638        session_signals,
639        rt.telemetry.clone(),
640        rt.telem_cancel.clone(),
641        rt.on_usage_cb,
642    );
643
644    // Spawn fast + control lanes (no session_signals, no transcript mutex)
645    let greeting_writer = rt.user_writer.clone();
646    let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
647        rt.event_rx,
648        rt.callbacks,
649        rt.dispatcher,
650        rt.writer,
651        rt.extractors,
652        rt.state.clone(),
653        rt.computed,
654        rt.phase_machine,
655        rt.watchers,
656        rt.temporal,
657        Some(rt.background_tracker.clone()),
658        rt.execution_modes,
659        rt.control_plane,
660        rt.live_event_tx.clone(),
661    );
662
663    // Spawn periodic telemetry emitter if interval is set
664    if let Some(interval) = rt.telemetry_interval {
665        let telem_tx = rt.live_event_tx.clone();
666        let telem_ref = rt.telemetry.clone();
667        tokio::spawn(async move {
668            let mut tick = tokio::time::interval(interval);
669            let mut prev_turns = 0u64;
670            loop {
671                tick.tick().await;
672                let snap = telem_ref.snapshot();
673                if let Some(obj) = snap.as_object() {
674                    let tc = obj
675                        .get("turn_count")
676                        .or_else(|| obj.get("response_count"))
677                        .and_then(serde_json::Value::as_u64)
678                        .unwrap_or(0);
679                    if tc > prev_turns {
680                        let latency = obj
681                            .get("last_response_latency_ms")
682                            .and_then(serde_json::Value::as_u64)
683                            .unwrap_or(0) as u32;
684                        let prompt = obj
685                            .get("prompt_token_count")
686                            .and_then(serde_json::Value::as_u64)
687                            .unwrap_or(0) as u32;
688                        let response = obj
689                            .get("response_token_count")
690                            .and_then(serde_json::Value::as_u64)
691                            .unwrap_or(0) as u32;
692                        let _ = telem_tx.send(LiveEvent::TurnMetrics {
693                            turn: tc as u32,
694                            latency_ms: latency,
695                            prompt_tokens: prompt,
696                            response_tokens: response,
697                        });
698                        prev_turns = tc;
699                    }
700                }
701                if telem_tx.send(LiveEvent::Telemetry(snap)).is_err() {
702                    break;
703                }
704            }
705        });
706    }
707
708    // Send greeting prompt to trigger model-initiated conversation
709    if let Some(greeting) = rt.greeting {
710        greeting_writer
711            .send_text(greeting)
712            .await
713            .map_err(AgentError::Session)?;
714    }
715
716    Ok(LiveHandle::new(
717        rt.session,
718        rt.user_writer,
719        fast_handle,
720        ctrl_handle,
721        rt.state,
722        rt.telemetry,
723        rt.live_event_tx,
724        rt.pending_context,
725        rt.flow_monitor,
726        rt.background_tracker,
727        rt.telem_cancel,
728    )
729    .with_control_sender(ctrl_tx))
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn builder_creates_with_defaults() {
738        let config = SessionConfig::new("test-key");
739        let builder = LiveSessionBuilder::new(config);
740        assert!(builder.dispatcher.is_none());
741        assert!(builder.computed.is_none());
742        assert!(builder.phase_machine.is_none());
743        assert!(builder.watchers.is_none());
744        assert!(builder.temporal.is_none());
745    }
746
747    #[test]
748    fn into_plan_derives_defaults() {
749        let config = SessionConfig::new("test-key");
750        let plan = LiveSessionBuilder::new(config)
751            .into_plan()
752            .expect("default builder should produce a plan");
753
754        // Config is carried (taken out only at connect time).
755        assert!(plan.config.is_some());
756        // Defaults preserved.
757        assert!(plan.dispatcher.is_none());
758        assert!(plan.phase_machine.is_none());
759        assert!(plan.persistence.is_none());
760        assert!(plan.session_id.is_none());
761        assert!(plan.greeting.is_none());
762        assert!(plan.soft_turn_timeout.is_none());
763        assert!(plan.telemetry_interval.is_none());
764        assert!(plan.repair_config.is_none());
765        assert!(plan.flow.is_none());
766        assert!(plan.execution_modes.is_empty());
767        assert!(plan.middleware.is_empty());
768        assert_eq!(plan.steering_mode, SteeringMode::default());
769        assert_eq!(plan.context_delivery, ContextDelivery::default());
770        // Default builder enables tool advisory.
771        assert!(plan.tool_advisory);
772    }
773
774    #[test]
775    fn into_plan_carries_persistence_and_session_id() {
776        let config = SessionConfig::new("test-key");
777        let plan = LiveSessionBuilder::new(config)
778            .session_id("user-123-session-456")
779            .into_plan()
780            .expect("plan derivation should succeed");
781
782        assert_eq!(plan.session_id.as_deref(), Some("user-123-session-456"));
783    }
784
785    #[test]
786    fn into_plan_carries_steering_and_context_delivery() {
787        let config = SessionConfig::new("test-key");
788        let plan = LiveSessionBuilder::new(config)
789            .steering_mode(SteeringMode::ContextInjection)
790            .context_delivery(ContextDelivery::Deferred)
791            .tool_advisory(false)
792            .into_plan()
793            .expect("plan derivation should succeed");
794
795        assert_eq!(plan.steering_mode, SteeringMode::ContextInjection);
796        assert_eq!(plan.context_delivery, ContextDelivery::Deferred);
797        assert!(!plan.tool_advisory);
798    }
799
800    #[test]
801    fn into_plan_carries_greeting_and_telemetry_interval() {
802        let config = SessionConfig::new("test-key");
803        let plan = LiveSessionBuilder::new(config)
804            .greeting("Hello there")
805            .telemetry_interval(std::time::Duration::from_secs(5))
806            .soft_turn_timeout(std::time::Duration::from_secs(2))
807            .into_plan()
808            .expect("plan derivation should succeed");
809
810        assert_eq!(plan.greeting.as_deref(), Some("Hello there"));
811        assert_eq!(
812            plan.telemetry_interval,
813            Some(std::time::Duration::from_secs(5))
814        );
815        assert_eq!(
816            plan.soft_turn_timeout,
817            Some(std::time::Duration::from_secs(2))
818        );
819    }
820
821    #[test]
822    fn into_plan_validates_phase_machine() {
823        // A PhaseMachine whose initial phase doesn't exist must fail validation
824        // during plan derivation (no connection required).
825        let config = SessionConfig::new("test-key");
826        let pm = PhaseMachine::new("nonexistent");
827        let result = LiveSessionBuilder::new(config)
828            .phase_machine(pm)
829            .into_plan();
830        assert!(result.is_err(), "invalid phase machine should fail to plan");
831    }
832
833    #[test]
834    fn into_plan_carries_valid_phase_machine_and_seeds_nothing() {
835        // A valid phase machine is carried into the plan; into_plan does NOT
836        // seed state (that happens in build_runtime), so this stays I/O-free.
837        let config = SessionConfig::new("test-key");
838        let mut pm = PhaseMachine::new("start");
839        pm.add_phase(crate::live::phase::Phase::new("start", "Start phase"));
840        let plan = LiveSessionBuilder::new(config)
841            .phase_machine(pm)
842            .into_plan()
843            .expect("valid phase machine should plan");
844
845        assert!(plan.phase_machine.is_some());
846    }
847
848    #[test]
849    fn into_plan_applies_non_blocking_to_background_tools() {
850        use gemini_genai_rs::prelude::{FunctionCallingBehavior, FunctionDeclaration, Tool};
851
852        let decl = FunctionDeclaration {
853            name: "search_kb".into(),
854            description: "Search".into(),
855            parameters: None,
856            behavior: None,
857        };
858        let config = SessionConfig::new("test-key").add_tool(Tool::functions(vec![decl]));
859
860        let plan = LiveSessionBuilder::new(config)
861            .tool_execution_mode(
862                "search_kb",
863                ToolExecutionMode::Background {
864                    formatter: None,
865                    scheduling: None,
866                },
867            )
868            .into_plan()
869            .expect("plan derivation should succeed");
870
871        let cfg = plan.config.expect("config carried");
872        let decl = cfg.tools[0]
873            .function_declarations
874            .as_ref()
875            .unwrap()
876            .iter()
877            .find(|d| d.name == "search_kb")
878            .unwrap();
879        assert_eq!(decl.behavior, Some(FunctionCallingBehavior::NonBlocking));
880    }
881}