Skip to main content

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};
10use gemini_genai_rs::transport::{Transport, TransportConfig};
11
12use crate::error::AgentError;
13use crate::state::State;
14use crate::tool::ToolDispatcher;
15
16use super::background_tool::{BackgroundToolTracker, ToolExecutionMode};
17use super::callbacks::EventCallbacks;
18use super::computed::ComputedRegistry;
19use super::context_writer::{DeferredWriter, PendingContext};
20use super::extractor::TurnExtractor;
21use super::handle::LiveHandle;
22use super::needs::{NeedsFulfillment, RepairConfig};
23use super::persistence::SessionPersistence;
24use super::phase::PhaseMachine;
25use super::processor::{
26    ControlPlaneConfig, TelemetryTap, spawn_event_processor, spawn_telemetry_lane,
27};
28use super::session_signals::SessionSignals;
29use super::soft_turn::SoftTurnDetector;
30use super::steering::{ContextDelivery, SteeringMode};
31use super::telemetry::SessionTelemetry;
32use super::temporal::TemporalRegistry;
33use super::tool_scope::{self, ToolScope};
34use super::watcher::WatcherRegistry;
35
36/// Builder for a callback-driven Live session.
37///
38/// Combines [`SessionConfig`], [`EventCallbacks`], tool dispatching, extractors,
39/// computed state, phase machines, watchers, and temporal patterns into a
40/// single connection setup. Call [`connect()`](Self::connect) to establish
41/// the WebSocket connection and start the three-lane event processor.
42///
43/// For ergonomic usage, prefer the L2 `Live` builder from `gemini-adk-fluent-rs`
44/// which wraps this with a fluent API.
45pub struct LiveSessionBuilder {
46    config: SessionConfig,
47    callbacks: EventCallbacks,
48    dispatcher: Option<Arc<ToolDispatcher>>,
49    extractors: Vec<Arc<dyn TurnExtractor>>,
50    computed: Option<ComputedRegistry>,
51    phase_machine: Option<PhaseMachine>,
52    watchers: Option<WatcherRegistry>,
53    temporal: Option<TemporalRegistry>,
54    greeting: Option<String>,
55    state: Option<State>,
56    execution_modes: HashMap<String, ToolExecutionMode>,
57    // Control plane configuration
58    soft_turn_timeout: Option<std::time::Duration>,
59    steering_mode: SteeringMode,
60    /// Whether `steering_mode` was set, rather than left at its default.
61    steering_set: bool,
62    context_delivery: ContextDelivery,
63    delivery: super::processor::DeliveryConfig,
64    repair_config: Option<RepairConfig>,
65    persistence: Option<Arc<dyn SessionPersistence>>,
66    session_id: Option<String>,
67    tool_advisory: bool,
68    telemetry_interval: Option<std::time::Duration>,
69    middleware: Vec<Arc<dyn crate::middleware::Middleware>>,
70    flow: Option<crate::flow::FlowStack>,
71    decisions: Option<Arc<crate::decision::Decisions>>,
72    code_mode: Option<Arc<crate::code_mode::CodeMode>>,
73    tasks: Option<crate::tasks::TaskRuntime>,
74    redactor: Option<Arc<super::redaction::TranscriptRedactor>>,
75    clock: Option<crate::clock::SharedClock>,
76    lockstep: Option<Arc<super::processor::Lockstep>>,
77    event_capacity: usize,
78}
79
80impl LiveSessionBuilder {
81    /// Create a new builder with the given session config.
82    pub fn new(config: SessionConfig) -> Self {
83        Self {
84            config,
85            callbacks: EventCallbacks::default(),
86            dispatcher: None,
87            extractors: Vec::new(),
88            computed: None,
89            phase_machine: None,
90            watchers: None,
91            temporal: None,
92            greeting: None,
93            state: None,
94            execution_modes: HashMap::new(),
95            soft_turn_timeout: None,
96            steering_mode: SteeringMode::default(),
97            steering_set: false,
98            context_delivery: ContextDelivery::default(),
99            delivery: super::processor::DeliveryConfig::default(),
100            repair_config: None,
101            persistence: None,
102            session_id: None,
103            tool_advisory: true,
104            telemetry_interval: None,
105            middleware: Vec::new(),
106            flow: None,
107            decisions: None,
108            code_mode: None,
109            tasks: None,
110            redactor: None,
111            clock: None,
112            lockstep: None,
113            event_capacity: super::events::DEFAULT_EVENT_CAPACITY,
114        }
115    }
116
117    /// Install a catalog of independently owned tasks in this voice session.
118    ///
119    /// Task commands and completions run on the control lane. A task session
120    /// cannot also install session-wide governance or state-changing pipelines.
121    pub fn tasks(mut self, tasks: crate::tasks::TaskRuntime) -> Self {
122        self.tasks = Some(tasks);
123        self
124    }
125
126    /// Replay lockstep: the router waits for both lanes to handle each event
127    /// and counts it in `lockstep`. Offline replay only.
128    pub(crate) fn lockstep(mut self, lockstep: Arc<super::processor::Lockstep>) -> Self {
129        self.lockstep = Some(lockstep);
130        self
131    }
132
133    /// Install transcript redaction — see
134    /// [`redaction::TranscriptRedactor`](super::redaction::TranscriptRedactor).
135    ///
136    /// Applied at the event router, before callbacks, the transcript buffer,
137    /// extraction, or persistence see the text. An inactive redactor (no
138    /// rules enabled) is dropped rather than installed.
139    pub fn redaction(mut self, redactor: super::redaction::TranscriptRedactor) -> Self {
140        self.redactor = redactor.is_active().then(|| Arc::new(redactor));
141        self
142    }
143
144    /// Read the time from `clock` instead of the system clock.
145    ///
146    /// Installed on the session [`State`] (see
147    /// [`State::set_clock`](crate::state::State::set_clock)), so temporal
148    /// patterns, phase durations, resolver cache expiry, the `session:`
149    /// timing signals and journal timestamps all follow it. Pass a
150    /// [`ManualClock`](crate::clock::ManualClock) to make timing decisions
151    /// reproducible in tests; replay installs one driven by the recording.
152    pub fn clock(mut self, clock: crate::clock::SharedClock) -> Self {
153        self.clock = Some(clock);
154        self
155    }
156
157    /// Add a middleware layer.
158    ///
159    /// Layers run around tool dispatch in the control lane: `before_tool`
160    /// (a returned error vetoes the call), `after_tool`, and `on_tool_error`.
161    /// Multiple calls accumulate in order.
162    pub fn middleware(mut self, layer: Arc<dyn crate::middleware::Middleware>) -> Self {
163        self.middleware.push(layer);
164        self
165    }
166
167    /// Attach a governed-flow monitor (built from a `Flow` + `Mode`). The
168    /// monitor becomes the main layer of a [`FlowStack`](crate::flow::FlowStack)
169    /// with no digressions.
170    pub fn flow_monitor(self, monitor: crate::flow::FlowMonitor) -> Self {
171        self.flow_stack(monitor.into_stack())
172    }
173
174    /// Attach a governed-flow stack: the main flow plus its digressions and
175    /// repair policies. This is the one governance object the control plane
176    /// drives; `flow_monitor` is the no-digression special case.
177    pub fn flow_stack(mut self, stack: crate::flow::FlowStack) -> Self {
178        self.flow = Some(stack);
179        self
180    }
181
182    /// The questions a decision model answers about the conversation, for
183    /// guards that use the `decided` atom (see [`crate::flow::decided`]).
184    ///
185    /// At each decision point (the caller's turn ends, or the model calls a
186    /// tool) the runtime asks, in one request, the questions the flow can act
187    /// on then, plus the standing ones, against the rolling conversation and
188    /// the active stages' grounding lines. A tool call is admitted after its
189    /// questions are answered for the caller's latest words, and a digression
190    /// whose trigger they satisfy opens before the call is admitted.
191    pub fn decisions(mut self, decisions: Arc<crate::decision::Decisions>) -> Self {
192        self.decisions = Some(decisions);
193        self
194    }
195
196    /// Provide a pre-created State to use for this session.
197    ///
198    /// If not set, a new State is created at connect time. Use this when
199    /// the State needs to be shared with tools or other components before
200    /// the session connects.
201    pub fn state(mut self, state: State) -> Self {
202        self.state = Some(state);
203        self
204    }
205
206    /// Set a greeting prompt sent on connect to trigger the model to speak first.
207    pub fn greeting(mut self, prompt: impl Into<String>) -> Self {
208        self.greeting = Some(prompt.into());
209        self
210    }
211
212    /// Give the model a code tool: it runs a short Python program whose
213    /// functions are the session's tools, each call down the same governed
214    /// path as a direct call. See [`code_mode`](crate::code_mode).
215    pub fn code_mode(mut self, code: crate::code_mode::CodeMode) -> Self {
216        self.code_mode = Some(Arc::new(code));
217        self
218    }
219
220    /// Set the tool dispatcher for auto-dispatch of tool calls.
221    pub fn dispatcher(mut self, dispatcher: ToolDispatcher) -> Self {
222        // Add tool declarations to session config
223        for tool in dispatcher.to_tool_declarations() {
224            self.config = self.config.add_tool(tool);
225        }
226        self.dispatcher = Some(Arc::new(dispatcher));
227        self
228    }
229
230    /// Set the event callbacks.
231    pub fn callbacks(mut self, callbacks: EventCallbacks) -> Self {
232        self.callbacks = callbacks;
233        self
234    }
235
236    /// Add a turn extractor that runs between turns.
237    pub fn extractor(mut self, extractor: Arc<dyn TurnExtractor>) -> Self {
238        self.extractors.push(extractor);
239        self
240    }
241
242    /// Set the computed variable registry for derived state.
243    pub fn computed(mut self, registry: ComputedRegistry) -> Self {
244        self.computed = Some(registry);
245        self
246    }
247
248    /// Set the phase machine for declarative conversation phase management.
249    pub fn phase_machine(mut self, machine: PhaseMachine) -> Self {
250        self.phase_machine = Some(machine);
251        self
252    }
253
254    /// Set the watcher registry for state change watchers.
255    pub fn watchers(mut self, registry: WatcherRegistry) -> Self {
256        self.watchers = Some(registry);
257        self
258    }
259
260    /// Set the temporal pattern registry.
261    pub fn temporal(mut self, registry: TemporalRegistry) -> Self {
262        self.temporal = Some(registry);
263        self
264    }
265
266    /// Set the execution mode for a named tool.
267    ///
268    /// Tools default to [`ToolExecutionMode::Standard`]. Set to
269    /// [`ToolExecutionMode::Background`] for zero-dead-air execution.
270    pub fn tool_execution_mode(
271        mut self,
272        tool_name: impl Into<String>,
273        mode: ToolExecutionMode,
274    ) -> Self {
275        self.execution_modes.insert(tool_name.into(), mode);
276        self
277    }
278
279    /// Enable soft turn detection for proactive silence awareness.
280    ///
281    /// When `proactiveAudio` is enabled, the model may choose not to respond.
282    /// This sets a timeout after VAD end — if the model stays silent, a
283    /// lightweight "soft turn" fires to keep state updated without forcing
284    /// the model to speak.
285    pub fn soft_turn_timeout(mut self, timeout: std::time::Duration) -> Self {
286        self.soft_turn_timeout = Some(timeout);
287        self
288    }
289
290    /// Set the steering mode for how the phase machine delivers instructions.
291    ///
292    /// A task session (see [`tasks`](Self::tasks)) on a model that accepts
293    /// `contextUpdate` declares only the foreground skill's tools unless this
294    /// sets a mode other than [`SteeringMode::ContextUpdate`].
295    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
296        self.steering_mode = mode;
297        self.steering_set = true;
298        self
299    }
300
301    /// Set the context delivery timing.
302    ///
303    /// - `Immediate` (default): send batched context during TurnComplete.
304    /// - `Deferred`: queue context and flush with next user send.
305    pub fn context_delivery(mut self, mode: ContextDelivery) -> Self {
306        self.context_delivery = mode;
307        self
308    }
309
310    /// Set the fast-lane delivery (backpressure) policy per event class.
311    ///
312    /// Defaults to all-[`Lossless`](super::processor::Delivery::Lossless), which
313    /// preserves the historical `send().await` routing behavior. Opt classes
314    /// into [`LossyDropNewest`](super::processor::Delivery::LossyDropNewest) to
315    /// keep the router from stalling when a fast-lane consumer falls behind.
316    pub fn delivery(mut self, delivery: super::processor::DeliveryConfig) -> Self {
317        self.delivery = delivery;
318        self
319    }
320
321    /// Enable the conversation repair protocol.
322    ///
323    /// Tracks need fulfillment per phase and nudges the model when the
324    /// conversation stalls on gathering required information.
325    pub fn repair(mut self, config: RepairConfig) -> Self {
326        self.repair_config = Some(config);
327        self
328    }
329
330    /// Set a session persistence backend for surviving process restarts.
331    pub fn persistence(mut self, backend: Arc<dyn SessionPersistence>) -> Self {
332        self.persistence = Some(backend);
333        self
334    }
335
336    /// Set the session ID for persistence.
337    pub fn session_id(mut self, id: impl Into<String>) -> Self {
338        self.session_id = Some(id.into());
339        self
340    }
341
342    /// Enable or disable tool availability advisory on phase transitions.
343    pub fn tool_advisory(mut self, enabled: bool) -> Self {
344        self.tool_advisory = enabled;
345        self
346    }
347
348    /// Set the periodic telemetry emission interval.
349    ///
350    /// When set, the processor periodically emits `LiveEvent::Telemetry`
351    /// and `LiveEvent::TurnMetrics` to the event stream.
352    pub fn telemetry_interval(mut self, interval: std::time::Duration) -> Self {
353        self.telemetry_interval = Some(interval);
354        self
355    }
356
357    /// Set how many [`LiveEvent`](super::events::LiveEvent)s a subscriber
358    /// can fall behind before it starts skipping the oldest ones.
359    ///
360    /// The buffer behind [`LiveHandle::events`](super::handle::LiveHandle::events)
361    /// is allocated in full when the session starts, at roughly 170 bytes
362    /// per slot, whether or not anyone subscribes. The default,
363    /// [`DEFAULT_EVENT_CAPACITY`](super::events::DEFAULT_EVENT_CAPACITY), is
364    /// enough for a subscriber that hands each event on without waiting.
365    /// Raise it when a subscriber does slow work inline, such as pacing
366    /// audio to a device from its receive loop; a lagging subscriber gets
367    /// `RecvError::Lagged` and skips events. Values below 1 are raised to 1.
368    pub fn event_capacity(mut self, capacity: usize) -> Self {
369        self.event_capacity = capacity.max(1);
370        self
371    }
372
373    /// Connect to Gemini and start the three-lane event processor.
374    ///
375    /// This is a thin orchestrator over three explicit, behavior-preserving
376    /// stages:
377    ///
378    /// 1. `into_plan` — pure derivation/validation of the resolved startup
379    ///    configuration (`SessionPlan`); no I/O, no spawning.
380    /// 2. Connect the L0 transport using the plan's resolved [`SessionConfig`].
381    /// 3. `build_runtime` — assemble the runtime wiring (channels, shared
382    ///    state, dispatcher, control plane) from the plan + connected session.
383    /// 4. `spawn_lanes` — spawn the telemetry/event/tool lanes and return the
384    ///    assembled [`LiveHandle`].
385    pub async fn connect(self) -> Result<LiveHandle, AgentError> {
386        let mut plan = self.into_plan()?;
387
388        // Connect via L0 using the resolved config (taken out of the plan so
389        // the rest of the plan can be moved into the runtime stage).
390        let config = plan.config.take().expect("plan always carries a config");
391        let session = ConnectBuilder::new(config)
392            .connect()
393            .await
394            .map_err(AgentError::Session)?;
395        finish_connect(plan, session).await
396    }
397
398    /// [`connect`](Self::connect) over `transport` instead of a WebSocket to
399    /// Gemini: the whole runtime (phases, tools, extractors, watchers, flow
400    /// governance) runs as it would live, against whatever the transport
401    /// answers.
402    ///
403    /// Use it to test a session without a network or a credential, over a
404    /// [`ReplayTransport`](gemini_genai_rs::transport::ReplayTransport)
405    /// scripted with server frames. The transport is not reconnected if it
406    /// closes.
407    pub async fn connect_with_transport<T: Transport>(
408        self,
409        transport: T,
410    ) -> Result<LiveHandle, AgentError> {
411        let mut plan = self.into_plan()?;
412        let config = plan.config.take().expect("plan always carries a config");
413        let session = ConnectBuilder::new(config)
414            .transport_config(TransportConfig {
415                max_reconnect_attempts: 0,
416                ..TransportConfig::default()
417            })
418            .transport(transport)
419            .connect()
420            .await
421            .map_err(AgentError::Session)?;
422        finish_connect(plan, session).await
423    }
424}
425
426/// Wait for a freshly connected session to become active, then build and
427/// start its runtime.
428async fn finish_connect(
429    plan: SessionPlan,
430    session: SessionHandle,
431) -> Result<LiveHandle, AgentError> {
432    // Wait for Active phase — or for the session to give up trying.
433    //
434    // `wait_for_phase` alone waits forever: the L0 session loop retries the
435    // setup handshake `max_reconnect_attempts` times, then emits
436    // `Disconnected` and returns, but the phase watch stays alive because
437    // the handle holds it. Active never arrives and nothing ever wakes the
438    // waiter — so a permanently unacceptable setup (a retired model name is
439    // the common one) wedges `connect()` with no error, forever. Racing the
440    // terminal event turns that into a returned failure.
441    let mut events = session.subscribe();
442    tokio::select! {
443        () = session.wait_for_phase(SessionPhase::Active) => {}
444        failure = wait_for_connect_failure(&mut events) => {
445            return Err(AgentError::Session(SessionError::SetupFailed(
446                gemini_genai_rs::session::SetupError::ServerRejected {
447                    code: None,
448                    message: failure,
449                },
450            )));
451        }
452    }
453    let runtime = build_runtime(plan, session);
454    spawn_lanes(runtime).await
455}
456
457impl LiveSessionBuilder {
458    /// Derive the resolved [`SessionPlan`] from this builder.
459    ///
460    /// This is a pure transformation: it runs build-time validations and
461    /// resolves the startup configuration (notably applying `NonBlocking`
462    /// behavior to background-tool declarations) without performing any I/O or
463    /// spawning any tasks. It is unit-testable without a live connection.
464    pub(crate) fn into_plan(self) -> Result<SessionPlan, AgentError> {
465        // Build-time validations
466        if self.tasks.is_some()
467            && (self.flow.is_some()
468                || self.phase_machine.is_some()
469                || self.dispatcher.is_some()
470                || !self.config.tools.is_empty()
471                || !self.extractors.is_empty()
472                || self.computed.is_some()
473                || self.watchers.is_some()
474                || self
475                    .temporal
476                    .as_ref()
477                    .is_some_and(|temporal| !temporal.is_empty())
478                || self.callbacks.on_tool_call.is_some()
479                || !self.middleware.is_empty())
480        {
481            return Err(AgentError::Config(
482                "task sessions own their tools, state and governance; configure these inside skills".into(),
483            ));
484        }
485        if let Some(ref pm) = self.phase_machine {
486            pm.validate()?;
487        }
488        if let Some(code) = &self.code_mode {
489            if self.tasks.is_some() {
490                return Err(AgentError::Config(
491                    "a task session cannot have a session-wide code tool".into(),
492                ));
493            }
494            let known: Vec<&str> = self
495                .dispatcher
496                .as_ref()
497                .map(|d| d.names().collect())
498                .unwrap_or_default();
499            let missing: Vec<&str> = code
500                .function_list()
501                .iter()
502                .map(|f| f.name.as_str())
503                .filter(|name| !known.contains(name))
504                .collect();
505            if !missing.is_empty() {
506                return Err(AgentError::Config(format!(
507                    "the code tool exposes {} but the session has no tool of that name",
508                    missing.join(", ")
509                )));
510            }
511            if known.contains(&code.tool()) {
512                return Err(AgentError::Config(format!(
513                    "the code tool's name '{}' is also a tool of the session",
514                    code.tool()
515                )));
516            }
517        }
518        if let Some(ref computed) = self.computed {
519            computed.validate()?;
520        }
521
522        // Apply NON_BLOCKING behavior to tool declarations for background tools
523        let mut config = self.config;
524        if let Some(tasks) = &self.tasks {
525            config.tools.extend(super::task_tools::declarations(tasks));
526            let catalog = super::task_tools::catalog_instruction(tasks);
527            let instruction = config.system_instruction.get_or_insert_with(|| {
528                gemini_genai_rs::prelude::Content {
529                    role: None,
530                    parts: Vec::new(),
531                }
532            });
533            instruction
534                .parts
535                .push(gemini_genai_rs::prelude::Part::Text { text: catalog });
536        }
537        for (tool_name, mode) in &self.execution_modes {
538            if matches!(
539                mode,
540                super::background_tool::ToolExecutionMode::Background { .. }
541            ) {
542                for tool in &mut config.tools {
543                    if let Some(ref mut decls) = tool.function_declarations {
544                        for decl in decls {
545                            if decl.name == *tool_name {
546                                decl.behavior = Some(
547                                    gemini_genai_rs::prelude::FunctionCallingBehavior::NonBlocking,
548                                );
549                            }
550                        }
551                    }
552                }
553            }
554        }
555
556        // The instruction the caller set at connect, kept as text so an
557        // `instruction_amendment` has something to compose onto in a session
558        // with no phase machine. Without it such an amendment was computed and
559        // then dropped — see the note in `control_plane::lifecycle` step 10.
560        let base_instruction = config.system_instruction.as_ref().map(|content| {
561            content
562                .parts
563                .iter()
564                .filter_map(|part| match part {
565                    gemini_genai_rs::prelude::Part::Text { text } => Some(text.as_str()),
566                    _ => None,
567                })
568                .collect::<Vec<_>>()
569                .join("\n")
570        });
571
572        // `ContextUpdate` steering needs a model that accepts the message;
573        // elsewhere it would close the session, so steer as the nearest mode.
574        let steering_mode = match self.steering_mode {
575            SteeringMode::ContextUpdate if !config.supports_context_update() => {
576                tracing::warn!(
577                    model = %config.resolved_model(),
578                    "SteeringMode::ContextUpdate needs a model that accepts contextUpdate; steering as Hybrid"
579                );
580                SteeringMode::Hybrid
581            }
582            mode => mode,
583        };
584
585        // Under `ContextUpdate` steering the setup already declares only what
586        // the initial phase and flow step admit, with the base instruction
587        // followed by the initial phase's own. Later changes replace both
588        // through `contextUpdate`, so the full declarations are kept here.
589        let tool_scope = if steering_mode == SteeringMode::ContextUpdate {
590            let state = self.state.clone().unwrap_or_default();
591            let phase = self
592                .phase_machine
593                .as_ref()
594                .and_then(PhaseMachine::current_phase);
595            let admitted = tool_scope::offered_names(
596                &config.tools,
597                phase.and_then(|p| p.tools_enabled.as_deref()),
598                self.flow.as_ref(),
599                &state,
600            );
601            let scope = ToolScope::new(
602                std::mem::take(&mut config.tools),
603                base_instruction.clone(),
604                &admitted,
605            )
606            .with_code(self.code_mode.clone());
607            config.tools = scope.tools_for(&admitted);
608            if let Some(phase) = phase {
609                let instruction = scope.instruction_for(
610                    &phase
611                        .instruction
612                        .resolve_with_modifiers(&state, &phase.modifiers),
613                );
614                if !instruction.is_empty() {
615                    config = config.system_instruction(instruction);
616                }
617            }
618            Some(Arc::new(scope))
619        } else {
620            // Declared once, at connect: the code tool lists every function
621            // it exposes.
622            if let Some(code) = &self.code_mode {
623                config.tools = tool_scope::with_code(std::mem::take(&mut config.tools), code);
624            }
625            None
626        };
627
628        // A task session steers by the foreground task, not by phases: on a
629        // model that accepts `contextUpdate` it declares `task_control` and
630        // each skill's typed `start_{skill}` at connect, and the task lane
631        // re-declares the foreground skill's tools as tasks start, finish and
632        // advance.
633        let task_scope = match &self.tasks {
634            Some(tasks)
635                if config.supports_context_update()
636                    && (!self.steering_set
637                        || self.steering_mode == SteeringMode::ContextUpdate) =>
638            {
639                let declared = super::task_tools::entry_names(tasks);
640                let scope = ToolScope::new(
641                    std::mem::take(&mut config.tools),
642                    base_instruction.clone(),
643                    &declared,
644                );
645                config.tools = scope.tools_for(&declared);
646                Some(Arc::new(scope))
647            }
648            _ => None,
649        };
650
651        Ok(SessionPlan {
652            config: Some(config),
653            base_instruction,
654            tool_scope,
655            task_scope,
656            code_mode: self.code_mode,
657            callbacks: self.callbacks,
658            dispatcher: self.dispatcher,
659            extractors: self.extractors,
660            computed: self.computed,
661            phase_machine: self.phase_machine,
662            watchers: self.watchers,
663            temporal: self.temporal,
664            greeting: self.greeting,
665            state: self.state,
666            execution_modes: self.execution_modes,
667            soft_turn_timeout: self.soft_turn_timeout,
668            steering_mode,
669            context_delivery: self.context_delivery,
670            delivery: self.delivery,
671            repair_config: self.repair_config,
672            persistence: self.persistence,
673            session_id: self.session_id,
674            tool_advisory: self.tool_advisory,
675            telemetry_interval: self.telemetry_interval,
676            middleware: self.middleware,
677            decisions: {
678                warn_unanswerable(self.flow.as_ref(), self.decisions.as_deref());
679                self.decisions
680            },
681            flow: self.flow,
682            tasks: self.tasks,
683            redactor: self.redactor,
684            clock: self.clock,
685            lockstep: self.lockstep,
686            event_capacity: self.event_capacity,
687        })
688    }
689}
690
691/// Warn about `decided` guards no decision service can answer: their guards
692/// never hold.
693fn warn_unanswerable(
694    flow: Option<&crate::flow::FlowStack>,
695    decisions: Option<&crate::decision::Decisions>,
696) {
697    let Some(flow) = flow else {
698        return;
699    };
700    let mut named = flow.main().flow().decisions();
701    for ov in flow.overlays() {
702        named.extend(ov.trigger().decisions());
703        named.extend(ov.flow().decisions());
704    }
705    let missing: Vec<&String> = named
706        .iter()
707        .filter(|q| decisions.is_none_or(|d| d.get(q).is_none()))
708        .collect();
709    if !missing.is_empty() {
710        tracing::warn!(
711            ?missing,
712            "the flow's guards name decisions no decision service declares; those guards never hold"
713        );
714    }
715}
716
717/// The resolved startup configuration for a Live session.
718///
719/// Produced purely from a [`LiveSessionBuilder`] via
720/// [`into_plan`](LiveSessionBuilder::into_plan) — no I/O, no task spawning. The
721/// `config` is held in an `Option` so [`connect`](LiveSessionBuilder::connect)
722/// can take it out to open the transport while moving the remaining plan into
723/// [`build_runtime`].
724pub(crate) struct SessionPlan {
725    /// Resolved session config (background-tool `NonBlocking` already applied).
726    /// `Some` until the transport is opened; `connect` takes it out.
727    config: Option<SessionConfig>,
728    /// The connect-time system instruction as text, for amendments to compose
729    /// onto when there is no phase to supply a base.
730    base_instruction: Option<String>,
731    /// Every tool declaration, under `ContextUpdate` steering.
732    tool_scope: Option<Arc<ToolScope>>,
733    /// Every task tool declaration, in a task session on a model that
734    /// accepts `contextUpdate`.
735    task_scope: Option<Arc<ToolScope>>,
736    /// The session's code tool.
737    code_mode: Option<Arc<crate::code_mode::CodeMode>>,
738    callbacks: EventCallbacks,
739    dispatcher: Option<Arc<ToolDispatcher>>,
740    extractors: Vec<Arc<dyn TurnExtractor>>,
741    computed: Option<ComputedRegistry>,
742    phase_machine: Option<PhaseMachine>,
743    watchers: Option<WatcherRegistry>,
744    temporal: Option<TemporalRegistry>,
745    greeting: Option<String>,
746    state: Option<State>,
747    execution_modes: HashMap<String, ToolExecutionMode>,
748    soft_turn_timeout: Option<std::time::Duration>,
749    steering_mode: SteeringMode,
750    context_delivery: ContextDelivery,
751    delivery: super::processor::DeliveryConfig,
752    repair_config: Option<RepairConfig>,
753    persistence: Option<Arc<dyn SessionPersistence>>,
754    session_id: Option<String>,
755    tool_advisory: bool,
756    telemetry_interval: Option<std::time::Duration>,
757    middleware: Vec<Arc<dyn crate::middleware::Middleware>>,
758    flow: Option<crate::flow::FlowStack>,
759    decisions: Option<Arc<crate::decision::Decisions>>,
760    tasks: Option<crate::tasks::TaskRuntime>,
761    redactor: Option<Arc<super::redaction::TranscriptRedactor>>,
762    clock: Option<crate::clock::SharedClock>,
763    lockstep: Option<Arc<super::processor::Lockstep>>,
764    event_capacity: usize,
765}
766
767/// Fully wired runtime for a connected Live session, ready for lane spawning.
768///
769/// Produced by [`build_runtime`] from a [`SessionPlan`] plus the connected
770/// [`SessionHandle`]. Holds the channels, shared atomics/state, dispatcher,
771/// control-plane config, and the resolved writers — but spawns nothing. The
772/// final stage [`spawn_lanes`] consumes this to start the lanes and assemble
773/// the [`LiveHandle`].
774pub(crate) struct SessionRuntime {
775    session: SessionHandle,
776    callbacks: Arc<EventCallbacks>,
777    dispatcher: Option<Arc<ToolDispatcher>>,
778    extractors: Vec<Arc<dyn TurnExtractor>>,
779    computed: Option<ComputedRegistry>,
780    phase_machine: Option<tokio::sync::Mutex<PhaseMachine>>,
781    watchers: Option<WatcherRegistry>,
782    temporal: Option<Arc<TemporalRegistry>>,
783    greeting: Option<String>,
784    state: State,
785    execution_modes: HashMap<String, ToolExecutionMode>,
786    background_tracker: Arc<BackgroundToolTracker>,
787    telemetry: Arc<SessionTelemetry>,
788    telemetry_interval: Option<std::time::Duration>,
789    control_plane: ControlPlaneConfig,
790    pending_context: Option<Arc<PendingContext>>,
791    /// Writer used by the processor for internal sends.
792    writer: Arc<dyn SessionWriter>,
793    /// User-facing writer handed to the `LiveHandle` (and used for greeting).
794    user_writer: Arc<dyn SessionWriter>,
795    event_rx: tokio::sync::broadcast::Receiver<gemini_genai_rs::prelude::SessionEvent>,
796    on_usage_cb: Option<super::callbacks::UsageCallback>,
797    live_event_tx: tokio::sync::broadcast::Sender<super::events::LiveEvent>,
798    telem_cancel: CancellationToken,
799    flow_monitor: Option<crate::flow::SharedFlowStack>,
800}
801
802/// Stage 3 input: construct the runtime wiring from a resolved plan and the
803/// connected session. Builds channels, shared state, the dispatcher set, the
804/// control-plane config (including deferred-context writer wrapping), and the
805/// telemetry handle — but does not spawn any lanes.
806pub(crate) fn build_runtime(plan: SessionPlan, session: SessionHandle) -> SessionRuntime {
807    // Share the governed-flow stack between the control lane (which
808    // advances it) and the LiveHandle (which snapshots explain).
809    let flow_monitor = plan.flow.map(crate::flow::FlowStack::into_shared);
810    let task_status = plan
811        .tasks
812        .as_ref()
813        .map(|tasks| Arc::new(parking_lot::Mutex::new(tasks.snapshot())));
814    let mut callbacks = plan.callbacks;
815    let on_usage_cb = callbacks.on_usage.take();
816    let callbacks = Arc::new(callbacks);
817    let raw_writer: Arc<dyn SessionWriter> = Arc::new(session.clone());
818    let state = plan.state.unwrap_or_default();
819    if let Some(clock) = plan.clock {
820        state.set_clock(clock);
821    }
822    if let Some(stack) = &flow_monitor {
823        stack.lock().publish_timing(&state);
824    }
825
826    // The router is the runtime's only subscriber; it feeds the telemetry lane.
827    let event_rx = session.subscribe();
828
829    // Store initial phase's `needs` metadata for ContextBuilder.
830    if let Some(ref pm) = plan.phase_machine {
831        let _ = state.session().set("phase", pm.current());
832        if let Some(phase) = pm.current_phase()
833            && !phase.needs.is_empty()
834        {
835            let _ = state.set("session:phase_needs", phase.needs.clone());
836        }
837    }
838
839    // What the setup message declares, for the application and Studio to read.
840    if let Some(scope) = plan.tool_scope.as_ref().or(plan.task_scope.as_ref()) {
841        let _ = state
842            .session()
843            .set(super::tool_scope::DECLARED_TOOLS_KEY, scope.declared());
844    }
845
846    let phase_machine_mutex = plan.phase_machine.map(|mut machine| {
847        machine.set_clock(state.clock());
848        tokio::sync::Mutex::new(machine)
849    });
850    let temporal_arc = plan.temporal.map(Arc::new);
851    let background_tracker = Arc::new(BackgroundToolTracker::new());
852
853    // Create telemetry (recorded by the router)
854    let telemetry = Arc::new(SessionTelemetry::new());
855    let telem_cancel = CancellationToken::new();
856
857    // Build control plane config
858    let mut control_plane = ControlPlaneConfig {
859        soft_turn: plan.soft_turn_timeout.map(SoftTurnDetector::new),
860        steering_mode: plan.steering_mode,
861        context_delivery: plan.context_delivery,
862        delivery: plan.delivery,
863        needs_fulfillment: plan.repair_config.map(NeedsFulfillment::new),
864        persistence: plan.persistence,
865        session_id: plan.session_id,
866        tool_advisory: plan.tool_advisory,
867        tool_scope: plan.tool_scope,
868        task_scope: plan.task_scope,
869        code_mode: plan.code_mode,
870        base_instruction: plan.base_instruction,
871        pending_context: None, // set after PendingContext is created below
872        middleware: {
873            let mut chain = crate::middleware::MiddlewareChain::new();
874            for layer in plan.middleware {
875                chain.add(layer);
876            }
877            Arc::new(chain)
878        },
879        flow: flow_monitor.clone(),
880        decisions: plan.decisions,
881        tasks: plan.tasks,
882        task_status,
883        redactor: plan.redactor,
884        lockstep: plan.lockstep,
885        playback: super::playback::PlaybackClock::new(state.clock()),
886    };
887
888    // Create shared PendingContext for deferred delivery.
889    // The SAME Arc is given to both the DeferredWriter (which drains it before
890    // user sends) and the ControlPlaneConfig (which the processor uses to push
891    // context turns from the control lane).
892    // A stage whose voice timing defers context needs the queue even when the
893    // session as a whole delivers immediately.
894    let stage_defers = flow_monitor.as_ref().is_some_and(|stack| {
895        stack
896            .lock()
897            .timing_policies()
898            .values()
899            .any(|t| t.context_delivery == Some(ContextDelivery::Deferred))
900    });
901    let pending_context = if plan.context_delivery == ContextDelivery::Deferred || stage_defers {
902        Some(Arc::new(PendingContext::new()))
903    } else {
904        None
905    };
906
907    // Wrap writer in DeferredWriter if deferred context delivery is enabled.
908    let (writer, user_writer) = if let Some(ref pending) = pending_context {
909        let deferred: Arc<dyn SessionWriter> =
910            Arc::new(DeferredWriter::new(raw_writer.clone(), pending.clone()));
911        // Processor uses raw_writer for internal sends (lifecycle context
912        // goes through PendingContext, not through the writer directly).
913        // User-facing LiveHandle uses the DeferredWriter.
914        (raw_writer, deferred)
915    } else {
916        (raw_writer.clone(), raw_writer)
917    };
918
919    // Pass shared pending context to control plane config
920    control_plane.pending_context = pending_context.clone();
921
922    // Create LiveEvent broadcast channel
923    use super::events::LiveEvent;
924    use tokio::sync::broadcast;
925    let (live_event_tx, _) = broadcast::channel::<LiveEvent>(plan.event_capacity);
926
927    SessionRuntime {
928        session,
929        callbacks,
930        dispatcher: plan.dispatcher,
931        extractors: plan.extractors,
932        computed: plan.computed,
933        phase_machine: phase_machine_mutex,
934        watchers: plan.watchers,
935        temporal: temporal_arc,
936        greeting: plan.greeting,
937        state,
938        execution_modes: plan.execution_modes,
939        background_tracker,
940        telemetry,
941        telemetry_interval: plan.telemetry_interval,
942        control_plane,
943        pending_context,
944        writer,
945        user_writer,
946        event_rx,
947        on_usage_cb,
948        live_event_tx,
949        telem_cancel,
950        flow_monitor,
951    }
952}
953
954/// Stage 4: spawn the telemetry lane, event processor, and periodic telemetry
955/// emitter, send any greeting, and assemble the [`LiveHandle`].
956/// Resolve once the session has definitively failed to come up, returning a
957/// message that names the cause.
958///
959/// The L0 loop reports each failed setup attempt as `Error` and only says
960/// `Disconnected` when it stops retrying, so the last `Error` carries the real
961/// reason ("Setup failed: …") while `Disconnected` carries only "max attempts
962/// exceeded". Both are reported: the terminal event proves it is over, the
963/// retained error says why.
964///
965/// Pends forever if the session comes up normally — the caller races it against
966/// the phase wait.
967async fn wait_for_connect_failure(
968    events: &mut tokio::sync::broadcast::Receiver<SessionEvent>,
969) -> String {
970    let mut last_error: Option<String> = None;
971    loop {
972        match events.recv().await {
973            Ok(SessionEvent::Error(err)) => last_error = Some(err.to_string()),
974            Ok(SessionEvent::Disconnected(reason)) => {
975                let reason = reason.unwrap_or_else(|| "connection closed".to_string());
976                return match last_error {
977                    Some(err) => format!("{reason} ({err})"),
978                    None => reason,
979                };
980            }
981            Ok(_) => {}
982            // The sender is gone, which is itself terminal.
983            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
984                return last_error.unwrap_or_else(|| "session ended during setup".to_string());
985            }
986            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
987        }
988    }
989}
990
991pub(crate) async fn spawn_lanes(rt: SessionRuntime) -> Result<LiveHandle, AgentError> {
992    use super::events::LiveEvent;
993
994    // One root span per session, so a trace backend shows every turn of a
995    // conversation under it, keyed by the conversation id (the persistence
996    // session id when set, else the transport's). The lanes are spawned
997    // inside it and each turn span is its child.
998    let conversation_id = rt
999        .control_plane
1000        .session_id
1001        .clone()
1002        .unwrap_or_else(|| rt.session.session_id().to_string());
1003    let session_span = tracing::info_span!(
1004        "live_session",
1005        "gen_ai.operation.name" = "live",
1006        "gen_ai.system" = "gemini",
1007        "gen_ai.conversation.id" = %conversation_id,
1008    );
1009    let entered = session_span.enter();
1010
1011    // Spawn the telemetry lane. The router records SessionTelemetry inline
1012    // and forwards only the events whose SessionSignals write state.
1013    let session_signals = Arc::new(SessionSignals::new(rt.state.clone()));
1014    let (telemetry_tap, telem_rx) =
1015        TelemetryTap::new(rt.telemetry.clone(), session_signals.clone());
1016    let _telem_handle = spawn_telemetry_lane(
1017        telem_rx,
1018        session_signals,
1019        rt.telem_cancel.clone(),
1020        rt.on_usage_cb,
1021    );
1022
1023    // The opening steering goes out before the lanes start and before any
1024    // greeting, so the model's first turn already has the first stage's
1025    // posture. Deferred delivery queues it with the first user send instead.
1026    let opening = super::control_plane::opening_steering(&rt.control_plane.flow, &rt.state);
1027    if !opening.is_empty() {
1028        let contents: Vec<_> = opening
1029            .into_iter()
1030            .map(gemini_genai_rs::prelude::Content::model)
1031            .collect();
1032        let delivery = rt
1033            .state
1034            .get::<crate::flow::VoiceTiming>(crate::flow::VOICE_TIMING_KEY)
1035            .and_then(|t| t.context_delivery)
1036            .unwrap_or(rt.control_plane.context_delivery);
1037        match (&delivery, &rt.pending_context) {
1038            (ContextDelivery::Deferred, Some(pending)) => pending.extend(contents),
1039            _ => rt
1040                .writer
1041                .send_client_content(contents, false)
1042                .await
1043                .map_err(AgentError::Session)?,
1044        }
1045    }
1046
1047    // Spawn fast + control lanes (no session_signals, no transcript mutex)
1048    let greeting_writer = rt.user_writer.clone();
1049    let playback = rt.control_plane.playback.clone();
1050    let task_status = rt.control_plane.task_status.clone();
1051    let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
1052        rt.event_rx,
1053        rt.callbacks,
1054        rt.dispatcher,
1055        rt.writer,
1056        rt.extractors,
1057        rt.state.clone(),
1058        rt.computed,
1059        rt.phase_machine,
1060        rt.watchers,
1061        rt.temporal,
1062        Some(rt.background_tracker.clone()),
1063        rt.execution_modes,
1064        rt.control_plane,
1065        rt.live_event_tx.clone(),
1066        Some(telemetry_tap),
1067    );
1068    drop(entered);
1069
1070    // Spawn periodic telemetry emitter if interval is set
1071    if let Some(interval) = rt.telemetry_interval {
1072        let telem_tx = rt.live_event_tx.clone();
1073        let telem_ref = rt.telemetry.clone();
1074        tokio::spawn(async move {
1075            let mut tick = tokio::time::interval(interval);
1076            let mut prev_turns = 0u64;
1077            loop {
1078                tick.tick().await;
1079                let snap = telem_ref.snapshot();
1080                if let Some(obj) = snap.as_object() {
1081                    let tc = obj
1082                        .get("turn_count")
1083                        .or_else(|| obj.get("response_count"))
1084                        .and_then(serde_json::Value::as_u64)
1085                        .unwrap_or(0);
1086                    if tc > prev_turns {
1087                        let latency = obj
1088                            .get("last_response_latency_ms")
1089                            .and_then(serde_json::Value::as_u64)
1090                            .unwrap_or(0) as u32;
1091                        let prompt = obj
1092                            .get("prompt_token_count")
1093                            .and_then(serde_json::Value::as_u64)
1094                            .unwrap_or(0) as u32;
1095                        let response = obj
1096                            .get("response_token_count")
1097                            .and_then(serde_json::Value::as_u64)
1098                            .unwrap_or(0) as u32;
1099                        let _ = telem_tx.send(LiveEvent::TurnMetrics {
1100                            turn: tc as u32,
1101                            latency_ms: latency,
1102                            prompt_tokens: prompt,
1103                            response_tokens: response,
1104                        });
1105                        prev_turns = tc;
1106                    }
1107                }
1108                if telem_tx.send(LiveEvent::Telemetry(snap)).is_err() {
1109                    break;
1110                }
1111            }
1112        });
1113    }
1114
1115    // Send greeting prompt to trigger model-initiated conversation
1116    if let Some(greeting) = rt.greeting {
1117        greeting_writer
1118            .send_text(greeting)
1119            .await
1120            .map_err(AgentError::Session)?;
1121    }
1122
1123    Ok(LiveHandle::new(
1124        rt.session,
1125        rt.user_writer,
1126        fast_handle,
1127        ctrl_handle,
1128        rt.state,
1129        rt.telemetry,
1130        rt.live_event_tx,
1131        rt.pending_context,
1132        rt.flow_monitor,
1133        rt.background_tracker,
1134        rt.telem_cancel,
1135    )
1136    .with_control_sender(ctrl_tx)
1137    .with_playback(playback)
1138    .with_task_status(task_status))
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144
1145    #[test]
1146    fn builder_creates_with_defaults() {
1147        let config = SessionConfig::new("test-key");
1148        let builder = LiveSessionBuilder::new(config);
1149        assert!(builder.dispatcher.is_none());
1150        assert!(builder.computed.is_none());
1151        assert!(builder.phase_machine.is_none());
1152        assert!(builder.watchers.is_none());
1153        assert!(builder.temporal.is_none());
1154    }
1155
1156    #[test]
1157    fn into_plan_derives_defaults() {
1158        let config = SessionConfig::new("test-key");
1159        let plan = LiveSessionBuilder::new(config)
1160            .into_plan()
1161            .expect("default builder should produce a plan");
1162
1163        // Config is carried (taken out only at connect time).
1164        assert!(plan.config.is_some());
1165        // Defaults preserved.
1166        assert!(plan.dispatcher.is_none());
1167        assert!(plan.phase_machine.is_none());
1168        assert!(plan.persistence.is_none());
1169        assert!(plan.session_id.is_none());
1170        assert!(plan.greeting.is_none());
1171        assert!(plan.soft_turn_timeout.is_none());
1172        assert!(plan.telemetry_interval.is_none());
1173        assert!(plan.repair_config.is_none());
1174        assert!(plan.flow.is_none());
1175        assert!(plan.execution_modes.is_empty());
1176        assert!(plan.middleware.is_empty());
1177        assert_eq!(plan.steering_mode, SteeringMode::default());
1178        assert_eq!(plan.context_delivery, ContextDelivery::default());
1179        // Default builder enables tool advisory.
1180        assert!(plan.tool_advisory);
1181    }
1182
1183    #[test]
1184    fn into_plan_carries_persistence_and_session_id() {
1185        let config = SessionConfig::new("test-key");
1186        let plan = LiveSessionBuilder::new(config)
1187            .session_id("user-123-session-456")
1188            .into_plan()
1189            .expect("plan derivation should succeed");
1190
1191        assert_eq!(plan.session_id.as_deref(), Some("user-123-session-456"));
1192    }
1193
1194    #[test]
1195    fn into_plan_carries_steering_and_context_delivery() {
1196        let config = SessionConfig::new("test-key");
1197        let plan = LiveSessionBuilder::new(config)
1198            .steering_mode(SteeringMode::ContextInjection)
1199            .context_delivery(ContextDelivery::Deferred)
1200            .tool_advisory(false)
1201            .into_plan()
1202            .expect("plan derivation should succeed");
1203
1204        assert_eq!(plan.steering_mode, SteeringMode::ContextInjection);
1205        assert_eq!(plan.context_delivery, ContextDelivery::Deferred);
1206        assert!(!plan.tool_advisory);
1207    }
1208
1209    fn two_phase_machine() -> PhaseMachine {
1210        use super::super::phase::Phase;
1211        let mut verify = Phase::new("verify", "Verify the caller.");
1212        verify.tools_enabled = Some(vec!["verify_identity".into()]);
1213        let mut serve = Phase::new("serve", "Serve the caller.");
1214        serve.tools_enabled = Some(vec!["get_balance".into()]);
1215        let mut machine = PhaseMachine::new("verify");
1216        machine.add_phase(verify);
1217        machine.add_phase(serve);
1218        machine
1219    }
1220
1221    fn banking_config(model: gemini_genai_rs::prelude::ModelId) -> SessionConfig {
1222        use gemini_genai_rs::prelude::{FunctionDeclaration, Tool};
1223        let decl = |name: &str| FunctionDeclaration {
1224            name: name.into(),
1225            description: name.into(),
1226            parameters: None,
1227            behavior: None,
1228        };
1229        SessionConfig::new("test-key")
1230            .model(model)
1231            .system_instruction("You are a bank agent.")
1232            .add_tool(Tool::functions(vec![
1233                decl("verify_identity"),
1234                decl("get_balance"),
1235            ]))
1236    }
1237
1238    fn declared(config: &SessionConfig) -> Vec<String> {
1239        config
1240            .tools
1241            .iter()
1242            .filter_map(|t| t.function_declarations.as_ref())
1243            .flatten()
1244            .map(|d| d.name.clone())
1245            .collect()
1246    }
1247
1248    fn instruction_text(config: &SessionConfig) -> String {
1249        config
1250            .system_instruction
1251            .iter()
1252            .flat_map(|c| &c.parts)
1253            .filter_map(|p| match p {
1254                gemini_genai_rs::prelude::Part::Text { text } => Some(text.as_str()),
1255                _ => None,
1256            })
1257            .collect()
1258    }
1259
1260    fn lookup_dispatcher() -> ToolDispatcher {
1261        let mut dispatcher = ToolDispatcher::new();
1262        for name in ["lookup", "book"] {
1263            dispatcher.register_function(Arc::new(crate::tool::SimpleTool::new(
1264                name,
1265                format!("{name} tool"),
1266                None,
1267                |_| async { Ok(serde_json::json!({})) },
1268            )));
1269        }
1270        dispatcher
1271    }
1272
1273    #[test]
1274    fn a_code_tool_is_declared_at_setup_beside_the_tools() {
1275        let plan = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1276            .dispatcher(lookup_dispatcher())
1277            .code_mode(crate::code_mode::CodeMode::new().function("lookup"))
1278            .into_plan()
1279            .expect("plan");
1280        let mut names = declared(plan.config.as_ref().unwrap());
1281        names.sort();
1282        assert_eq!(names, vec!["book", "lookup", "run_code"]);
1283        assert!(plan.code_mode.is_some());
1284    }
1285
1286    #[test]
1287    fn a_code_tool_must_expose_tools_the_session_has() {
1288        let err = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1289            .dispatcher(lookup_dispatcher())
1290            .code_mode(crate::code_mode::CodeMode::new().function("refund"))
1291            .into_plan()
1292            .err()
1293            .expect("an unknown function is refused");
1294        assert!(err.to_string().contains("refund"), "{err}");
1295
1296        let err = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1297            .dispatcher(lookup_dispatcher())
1298            .code_mode(
1299                crate::code_mode::CodeMode::new()
1300                    .function("lookup")
1301                    .tool_name("book"),
1302            )
1303            .into_plan()
1304            .err()
1305            .expect("a name clash is refused");
1306        assert!(err.to_string().contains("'book'"), "{err}");
1307    }
1308
1309    #[test]
1310    fn context_update_steering_declares_only_the_initial_phase_at_setup() {
1311        use gemini_genai_rs::prelude::ModelId;
1312        let plan = LiveSessionBuilder::new(banking_config(ModelId::LIVE_3_8))
1313            .phase_machine(two_phase_machine())
1314            .steering_mode(SteeringMode::ContextUpdate)
1315            .into_plan()
1316            .expect("plan derivation should succeed");
1317
1318        assert_eq!(plan.steering_mode, SteeringMode::ContextUpdate);
1319        let config = plan.config.as_ref().unwrap();
1320        assert_eq!(declared(config), vec!["verify_identity"]);
1321        assert_eq!(
1322            instruction_text(config),
1323            "You are a bank agent.\n\nVerify the caller."
1324        );
1325        // The full catalog stays with the scope for later phases.
1326        let scope = plan.tool_scope.as_ref().expect("scope under ContextUpdate");
1327        assert_eq!(scope.declared(), vec!["verify_identity"]);
1328        assert_eq!(
1329            plan.base_instruction.as_deref(),
1330            Some("You are a bank agent.")
1331        );
1332    }
1333
1334    #[test]
1335    fn a_task_session_declares_only_task_control_where_context_update_is_accepted() {
1336        use gemini_genai_rs::prelude::ModelId;
1337        let session = |model, steering: Option<SteeringMode>| {
1338            let mut builder = LiveSessionBuilder::new(
1339                SessionConfig::new("test-key")
1340                    .model(model)
1341                    .system_instruction("You are a bank agent."),
1342            )
1343            .tasks(crate::tasks::two_skill_runtime());
1344            if let Some(mode) = steering {
1345                builder = builder.steering_mode(mode);
1346            }
1347            builder.into_plan().expect("plan derivation should succeed")
1348        };
1349
1350        let plan = session(ModelId::LIVE_3_8, None);
1351        let config = plan.config.as_ref().unwrap();
1352        // `task_control` and each skill's typed entry point.
1353        assert_eq!(
1354            declared(config),
1355            vec!["task_control", "start_billing", "start_faq"]
1356        );
1357        let scope = plan.task_scope.as_ref().expect("a task scope on 3.8");
1358        assert_eq!(
1359            scope.declared(),
1360            vec!["start_billing", "start_faq", "task_control"]
1361        );
1362        assert!(
1363            instruction_text(config).contains("Installed skills"),
1364            "the catalog stays in the instruction"
1365        );
1366        assert!(
1367            plan.tool_scope.is_none(),
1368            "phases and flows are not involved"
1369        );
1370
1371        let plan = session(ModelId::LIVE_3_8, Some(SteeringMode::ContextUpdate));
1372        assert!(plan.task_scope.is_some());
1373
1374        // A model without `contextUpdate`, or a steering mode chosen against
1375        // it, keeps every skill's tools declared.
1376        for plan in [
1377            session(ModelId::FLASH_2_5_NATIVE_AUDIO_LATEST, None),
1378            session(ModelId::LIVE_3_8, Some(SteeringMode::Hybrid)),
1379        ] {
1380            assert!(plan.task_scope.is_none());
1381            assert_eq!(
1382                declared(plan.config.as_ref().unwrap()),
1383                vec![
1384                    "task_control",
1385                    "start_billing",
1386                    "start_faq",
1387                    "billing__verify",
1388                    "billing__pay",
1389                    "faq__answer"
1390                ]
1391            );
1392        }
1393    }
1394
1395    #[test]
1396    fn context_update_steering_falls_back_to_hybrid_without_model_support() {
1397        use gemini_genai_rs::prelude::ModelId;
1398        let plan = LiveSessionBuilder::new(banking_config(ModelId::FLASH_2_5_NATIVE_AUDIO_LATEST))
1399            .phase_machine(two_phase_machine())
1400            .steering_mode(SteeringMode::ContextUpdate)
1401            .into_plan()
1402            .expect("plan derivation should succeed");
1403
1404        assert_eq!(plan.steering_mode, SteeringMode::Hybrid);
1405        assert!(plan.tool_scope.is_none());
1406        let config = plan.config.as_ref().unwrap();
1407        assert_eq!(
1408            declared(config),
1409            vec!["verify_identity", "get_balance"],
1410            "every tool is declared, as under the other modes"
1411        );
1412        assert_eq!(instruction_text(config), "You are a bank agent.");
1413    }
1414
1415    #[test]
1416    fn other_steering_modes_declare_every_tool_at_setup() {
1417        use gemini_genai_rs::prelude::ModelId;
1418        let plan = LiveSessionBuilder::new(banking_config(ModelId::LIVE_3_8))
1419            .phase_machine(two_phase_machine())
1420            .steering_mode(SteeringMode::Hybrid)
1421            .into_plan()
1422            .expect("plan derivation should succeed");
1423        assert!(plan.tool_scope.is_none());
1424        assert_eq!(
1425            declared(plan.config.as_ref().unwrap()),
1426            vec!["verify_identity", "get_balance"]
1427        );
1428    }
1429
1430    #[test]
1431    fn into_plan_carries_greeting_and_telemetry_interval() {
1432        let config = SessionConfig::new("test-key");
1433        let plan = LiveSessionBuilder::new(config)
1434            .greeting("Hello there")
1435            .telemetry_interval(std::time::Duration::from_secs(5))
1436            .soft_turn_timeout(std::time::Duration::from_secs(2))
1437            .into_plan()
1438            .expect("plan derivation should succeed");
1439
1440        assert_eq!(plan.greeting.as_deref(), Some("Hello there"));
1441        assert_eq!(
1442            plan.telemetry_interval,
1443            Some(std::time::Duration::from_secs(5))
1444        );
1445        assert_eq!(
1446            plan.soft_turn_timeout,
1447            Some(std::time::Duration::from_secs(2))
1448        );
1449    }
1450
1451    #[test]
1452    fn into_plan_carries_event_capacity() {
1453        let default = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1454            .into_plan()
1455            .expect("plan derivation should succeed");
1456        assert_eq!(
1457            default.event_capacity,
1458            super::super::events::DEFAULT_EVENT_CAPACITY
1459        );
1460
1461        let custom = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1462            .event_capacity(64)
1463            .into_plan()
1464            .expect("plan derivation should succeed");
1465        assert_eq!(custom.event_capacity, 64);
1466
1467        // `broadcast::channel(0)` panics, so zero is raised to one.
1468        let zero = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1469            .event_capacity(0)
1470            .into_plan()
1471            .expect("plan derivation should succeed");
1472        assert_eq!(zero.event_capacity, 1);
1473    }
1474
1475    #[tokio::test]
1476    async fn event_capacity_bounds_how_far_a_subscriber_can_lag() {
1477        use super::super::events::LiveEvent;
1478        use gemini_genai_rs::session::SessionState;
1479        use tokio::sync::broadcast::error::RecvError;
1480
1481        let (command_tx, _command_rx) = tokio::sync::mpsc::channel(8);
1482        let (event_tx, _) = tokio::sync::broadcast::channel(16);
1483        let (phase_tx, phase_rx) = tokio::sync::watch::channel(SessionPhase::Active);
1484        let state = Arc::new(SessionState::with_events(phase_tx, event_tx.clone()));
1485        let session = SessionHandle::new(command_tx, event_tx, state, phase_rx);
1486
1487        let plan = LiveSessionBuilder::new(SessionConfig::new("test-key"))
1488            .event_capacity(4)
1489            .into_plan()
1490            .expect("plan derivation should succeed");
1491        let runtime = build_runtime(plan, session);
1492        let mut rx = runtime.live_event_tx.subscribe();
1493        for _ in 0..5 {
1494            let _ = runtime.live_event_tx.send(LiveEvent::TurnComplete);
1495        }
1496        // Five sends into four slots: the oldest is gone.
1497        assert!(matches!(rx.recv().await, Err(RecvError::Lagged(1))));
1498        for _ in 0..4 {
1499            assert!(matches!(rx.recv().await, Ok(LiveEvent::TurnComplete)));
1500        }
1501    }
1502
1503    #[test]
1504    fn into_plan_validates_phase_machine() {
1505        // A PhaseMachine whose initial phase doesn't exist must fail validation
1506        // during plan derivation (no connection required).
1507        let config = SessionConfig::new("test-key");
1508        let pm = PhaseMachine::new("nonexistent");
1509        let result = LiveSessionBuilder::new(config)
1510            .phase_machine(pm)
1511            .into_plan();
1512        assert!(result.is_err(), "invalid phase machine should fail to plan");
1513    }
1514
1515    #[test]
1516    fn into_plan_carries_valid_phase_machine_and_seeds_nothing() {
1517        // A valid phase machine is carried into the plan; into_plan does NOT
1518        // seed state (that happens in build_runtime), so this stays I/O-free.
1519        let config = SessionConfig::new("test-key");
1520        let mut pm = PhaseMachine::new("start");
1521        pm.add_phase(crate::live::phase::Phase::new("start", "Start phase"));
1522        let plan = LiveSessionBuilder::new(config)
1523            .phase_machine(pm)
1524            .into_plan()
1525            .expect("valid phase machine should plan");
1526
1527        assert!(plan.phase_machine.is_some());
1528    }
1529
1530    #[test]
1531    fn into_plan_applies_non_blocking_to_background_tools() {
1532        use gemini_genai_rs::prelude::{FunctionCallingBehavior, FunctionDeclaration, Tool};
1533
1534        let decl = FunctionDeclaration {
1535            name: "search_kb".into(),
1536            description: "Search".into(),
1537            parameters: None,
1538            behavior: None,
1539        };
1540        let config = SessionConfig::new("test-key").add_tool(Tool::functions(vec![decl]));
1541
1542        let plan = LiveSessionBuilder::new(config)
1543            .tool_execution_mode(
1544                "search_kb",
1545                ToolExecutionMode::Background {
1546                    formatter: None,
1547                    scheduling: None,
1548                },
1549            )
1550            .into_plan()
1551            .expect("plan derivation should succeed");
1552
1553        let cfg = plan.config.expect("config carried");
1554        let decl = cfg.tools[0]
1555            .function_declarations
1556            .as_ref()
1557            .unwrap()
1558            .iter()
1559            .find(|d| d.name == "search_kb")
1560            .unwrap();
1561        assert_eq!(decl.behavior, Some(FunctionCallingBehavior::NonBlocking));
1562    }
1563}