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