gemini_adk_fluent_rs/
conversation.rs

1//! The conversation compiler (Phase 1 MVP).
2//!
3//! Authors describe a voice experience in terms of **stages** that *say* things,
4//! *collect* slots, *commit* tools behind confirmation, and advance via *next*
5//! transitions. A [`Conversation`] builder produces a serializable
6//! [`ConversationSpec`] (the single source of truth), and [`Conversation::compile`]
7//! lowers it to the governed [`CompiledFlow`] IR — so the high level is sugar over
8//! the low level, never a parallel runtime (see
9//! `docs/plans/2026-06-06-conversation-compiler-rfc.md`).
10//!
11//! ```no_run
12//! # use gemini_adk_fluent_rs::prelude::*;
13//! # use gemini_adk_fluent_rs::conversation::Conversation;
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! let convo = Conversation::new("booking")
16//!     .stage("collect")
17//!         .instruction("Help the user book a table.")
18//!         .collect(["party_size", "slot"])
19//!         .next("check", Guard::captured(["party_size", "slot"]))
20//!     .stage("check")
21//!         .ground("Party of {party_size} at {slot}.")
22//!         .next("confirm", Guard::is_true("availability_ok"))
23//!     .stage("confirm")
24//!         .commit("book", Guard::is_true("user_confirmed"))
25//!         .next("done", Guard::called_ok("book"))
26//!     .stage("done").terminal()
27//!     .require(["done"])
28//!     .compile()?;
29//! let mut monitor = convo.monitor(Enforcement::Enforce);
30//! # let _ = &mut monitor; Ok(())
31//! # }
32//! ```
33//!
34//! ### Lowering semantics (MVP)
35//!
36//! - A stage lowers to a Flow [`Step`](gemini_adk_rs::flow). `say` → posture,
37//!   `ground` → grounding template, `allow` → tool whitelist.
38//! - `commit(tool, when)` gates a confirm-before-act tool (`tool` is auto-allowed
39//!   in the stage).
40//! - `next(to, when)` adds a forward edge: `to` depends on this stage (`after`)
41//!   and its activation gate is `when`. Multiple incoming edges are an **AND-join**
42//!   (all predecessors must complete) — richer topologies are Phase 3.
43//! - A non-terminal stage's completion is, in priority order: an explicit
44//!   `done`, else `captured(collect)` when it collects slots, else the disjunction
45//!   of its `next` conditions.
46
47use serde::{Deserialize, Serialize};
48use serde_json::Value;
49use std::collections::{BTreeMap, BTreeSet};
50use std::future::Future;
51use std::pin::Pin;
52use std::sync::Arc;
53use std::time::Duration;
54
55use gemini_adk_rs::extract::Extract;
56use gemini_adk_rs::flow::{CompiledFlow, Enforcement, Flow, FlowErrors, FlowMonitor, Guard, Pred};
57use gemini_adk_rs::frame::{Frame, FrameSpec};
58
59/// A boxed async fetcher: bind args (a JSON object) → resolved value.
60type SlotFetch =
61    Arc<dyn Fn(Value) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send>> + Send + Sync>;
62
63/// A registry of named async resolvers, bound to a [`ConversationSpec`]'s
64/// declared resolver slots at load time via
65/// [`Conversation::from_spec_with_resolvers`].
66///
67/// This is what makes a JSON spec a complete deployable unit: the spec carries
68/// the *declarations* (slot, resolver name, args, ttl) and the registry supplies
69/// the *implementations*.
70///
71/// ```no_run
72/// # use gemini_adk_fluent_rs::conversation::{Conversation, ConversationSpec, ResolverRegistry};
73/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
74/// # let spec = ConversationSpec::default();
75/// let registry = ResolverRegistry::new()
76///     .with("availability", |args| async move {
77///         let _ = args;
78///         Ok(serde_json::json!({ "open": true }))
79///     });
80/// let convo = Conversation::from_spec_with_resolvers(spec, &registry)?;
81/// # let _ = convo; Ok(())
82/// # }
83/// ```
84#[derive(Clone, Default)]
85pub struct ResolverRegistry {
86    fetchers: BTreeMap<String, SlotFetch>,
87}
88
89impl ResolverRegistry {
90    /// An empty registry.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// Register an async resolver under `name` (builder style).
96    pub fn with<F, Fut>(mut self, name: impl Into<String>, fetch: F) -> Self
97    where
98        F: Fn(Value) -> Fut + Send + Sync + 'static,
99        Fut: Future<Output = Result<Value, String>> + Send + 'static,
100    {
101        self.add(name, fetch);
102        self
103    }
104
105    /// Register an async resolver under `name`.
106    pub fn add<F, Fut>(&mut self, name: impl Into<String>, fetch: F)
107    where
108        F: Fn(Value) -> Fut + Send + Sync + 'static,
109        Fut: Future<Output = Result<Value, String>> + Send + 'static,
110    {
111        let fetch = Arc::new(fetch);
112        self.fetchers.insert(
113            name.into(),
114            Arc::new(move |v| {
115                let fetch = fetch.clone();
116                Box::pin(async move { fetch(v).await })
117            }),
118        );
119    }
120
121    /// Whether a resolver named `name` is registered.
122    pub fn contains(&self, name: &str) -> bool {
123        self.fetchers.contains_key(name)
124    }
125
126    /// A registry that stubs every resolver declared anywhere in `spec` (stages
127    /// and overlays) with a no-op returning JSON `null`.
128    ///
129    /// For **structural validation and deterministic simulation**, the resolver
130    /// *implementations* are irrelevant — a resolver is an external fetch, so in
131    /// a model-free test its output is supplied via a scenario `set` step (like
132    /// a tool result via `tool_ok`), and the stub is never actually invoked when
133    /// the slot is pre-set. Real implementations bind at deploy time via
134    /// [`Conversation::from_spec_with_resolvers`].
135    pub fn stubbing(spec: &ConversationSpec) -> Self {
136        let mut reg = Self::new();
137        let stages = spec
138            .stages
139            .iter()
140            .chain(spec.overlays.iter().flat_map(|o| o.stages.iter()));
141        for stage in stages {
142            for r in &stage.resolve {
143                let name = r.resolver_name().to_string();
144                if !reg.contains(&name) {
145                    reg.add(name, |_args| async { Ok(serde_json::Value::Null) });
146                }
147            }
148        }
149        reg
150    }
151
152    fn get(&self, name: &str) -> Option<SlotFetch> {
153        self.fetchers.get(name).cloned()
154    }
155}
156
157/// A resolver binding attached to a stage by [`Conversation::resolve_slot`]. The
158/// closure lives only in the builder (it is not serializable); the serializable
159/// [`ConversationSpec`] is unaffected.
160#[derive(Clone)]
161struct StageResolver {
162    stage: String,
163    name: String,
164    args: Vec<String>,
165    ttl: Option<Duration>,
166    fetch: SlotFetch,
167}
168
169/// A serializable declaration that a slot is filled by a *named* async resolver.
170///
171/// The resolver's implementation (the async fetch) is bound at load time from a
172/// [`ResolverRegistry`] — so a `ConversationSpec` carrying these is a complete,
173/// JSON-deployable unit once paired with a registry. This is the data half of
174/// [`Conversation::resolve_slot`]; the closure half lives only in the builder.
175#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
176pub struct ResolveSpec {
177    /// The slot (state key) the resolver fills. Added to the stage's `collect`.
178    pub slot: String,
179    /// The registered resolver name to bind (defaults to `slot` if omitted).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub resolver: Option<String>,
182    /// State keys passed to the resolver as a JSON object argument.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub args: Vec<String>,
185    /// Optional memoization TTL in seconds.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub ttl_secs: Option<u64>,
188}
189
190impl ResolveSpec {
191    /// The resolver name to look up in the registry (the explicit `resolver`, or
192    /// the slot name as a default).
193    fn resolver_name(&self) -> &str {
194        self.resolver.as_deref().unwrap_or(&self.slot)
195    }
196}
197
198/// A transition: advance to `to` when `when` holds.
199#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
200pub struct TransitionSpec {
201    /// Target stage id.
202    pub to: String,
203    /// The (serializable) guard that fires this transition.
204    pub when: Guard,
205}
206
207/// A confirm-before-act tool, gated by `when`.
208#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
209pub struct CommitSpec {
210    /// The committing tool (e.g. `book`, `charge_card`).
211    pub tool: String,
212    /// The guard that must hold before the tool is admitted.
213    pub when: Guard,
214}
215
216/// One authored conversation stage.
217#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
218pub struct StageSpec {
219    /// Unique stage id.
220    pub id: String,
221    /// Instruction projected as steering while the stage is active. Serialized
222    /// as `say` for compatibility; `instruction` is accepted on input.
223    #[serde(
224        default,
225        alias = "instruction",
226        skip_serializing_if = "Option::is_none"
227    )]
228    pub say: Option<String>,
229    /// Grounding template projected while active (`{key}` interpolation).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub ground: Option<String>,
232    /// Slots to collect; drives the default completion (`captured`).
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub collect: Vec<String>,
235    /// The frame whose slots this stage collects, if set via `collect_frame`. Its
236    /// recognizer-bearing slots lower to an extractor that fills the slots.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub frame: Option<FrameSpec>,
239    /// Tools available while this stage is active.
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub allow: Vec<String>,
242    /// Explicit completion guard (overrides the `collect`/`next` default).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub done: Option<Guard>,
245    /// A confirm-before-act tool committed in this stage.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub commit: Option<CommitSpec>,
248    /// Forward transitions out of this stage.
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub next: Vec<TransitionSpec>,
251    /// Explicit dependency stage ids (in addition to `next`-derived edges).
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub after: Vec<String>,
254    /// Whether this is a terminal stage.
255    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
256    pub terminal: bool,
257    /// Repair policy for this stage (reprompt/escalate on stalling).
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub repair: Option<RepairPolicy>,
260    /// Voice pacing while this stage is active: reprompt on silence, filler
261    /// cues for slow tools, holding the floor, endpointing, context delivery.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub timing: Option<VoiceTiming>,
264    /// Text the model must say word for word in this stage (strict canned
265    /// mode). The stage completes only once the output transcript matches it;
266    /// see [`gemini_adk_rs::flow::verbatim`]. The model holds the floor
267    /// unless `timing` says otherwise.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub verbatim: Option<String>,
270    /// Named-resolver slot declarations. Bound to implementations at load via a
271    /// [`ResolverRegistry`]; the data lives in the spec so it round-trips JSON.
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub resolve: Vec<ResolveSpec>,
274}
275
276// The digression/repair vocabulary is owned by the runtime: the authoring layer
277// lowers into it and never reimplements it. Re-exported here so existing
278// `conversation::{Resume, RepairPolicy, FlowStack}` paths keep working.
279pub use gemini_adk_rs::flow::{FlowStack, RepairPolicy, Resume, VoiceTiming};
280use gemini_adk_rs::flow::{Overlay, correction_flag, escalate_flag, verbatim_flag};
281
282/// A digression (overlay): a named sub-flow that suspends the main flow when its
283/// `trigger` holds, runs to completion, then resumes per `resume`.
284#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
285pub struct OverlaySpec {
286    /// Overlay name.
287    pub name: String,
288    /// The guard that activates this overlay (e.g. an `intent:*` flag).
289    pub trigger: Guard,
290    /// The overlay's own stages.
291    #[serde(default)]
292    pub stages: Vec<StageSpec>,
293    /// Stages required for the overlay to be considered complete.
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub require: Vec<String>,
296    /// What the main flow does once the overlay completes.
297    #[serde(default)]
298    pub resume: Resume,
299}
300
301/// The serializable authoring spec — the single source of truth from which the
302/// typed builder, YAML, and (later) codegen all derive.
303#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
304pub struct ConversationSpec {
305    /// Conversation name.
306    pub name: String,
307    /// The authored stages.
308    #[serde(default)]
309    pub stages: Vec<StageSpec>,
310    /// Stages that must be done for the conversation to be complete.
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub require: Vec<String>,
313    /// Digressions/overlays that can suspend and resume the main flow.
314    #[serde(default, skip_serializing_if = "Vec::is_empty")]
315    pub overlays: Vec<OverlaySpec>,
316    /// Cross-cutting policy aspects (safety/redaction/commit governance).
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub policies: Vec<crate::policy::Policy>,
319}
320
321/// Error compiling a [`ConversationSpec`] into a [`CompiledConversation`].
322#[derive(Debug)]
323pub enum ConversationError {
324    /// The spec has no stages.
325    Empty,
326    /// An authoring-level error (e.g. a transition to an unknown stage).
327    Spec(String),
328    /// The lowered flow failed referential/acyclicity validation.
329    Flow(Vec<String>),
330    /// The lowered flow failed to compile (unreachable steps, unguarded commit…).
331    Compile(FlowErrors),
332}
333
334impl std::fmt::Display for ConversationError {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        match self {
337            ConversationError::Empty => write!(f, "conversation has no stages"),
338            ConversationError::Spec(m) => write!(f, "conversation spec error: {m}"),
339            ConversationError::Flow(errs) => {
340                write!(f, "lowered flow is invalid: {}", errs.join("; "))
341            }
342            ConversationError::Compile(e) => write!(f, "lowered flow failed to compile: {e}"),
343        }
344    }
345}
346
347impl std::error::Error for ConversationError {}
348
349/// The JSON Schema for a [`ConversationSpec`], as a pretty-printed string.
350///
351/// This is the machine-readable authoring contract: a web form, an IDE, or an
352/// LLM/skill drafting a spec targets this schema. Generated from the same
353/// `#[derive(JsonSchema)]` types the runtime compiles, so it cannot drift.
354pub fn conversation_spec_schema() -> String {
355    let schema = schemars::schema_for!(ConversationSpec);
356    serde_json::to_string_pretty(&schema).expect("schema serialization is infallible")
357}
358
359impl serde::Serialize for ConversationError {
360    /// A machine-readable diagnostic so authoring tools (web/CLI/skills) can
361    /// render structured errors. Shape:
362    /// `{ "kind": "compile", "errors": [ { "kind": "unreachable_step", ... } ] }`.
363    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
364        use serde::ser::SerializeMap;
365        let mut m = s.serialize_map(None)?;
366        m.serialize_entry("message", &self.to_string())?;
367        match self {
368            ConversationError::Empty => {
369                m.serialize_entry("kind", "empty")?;
370            }
371            ConversationError::Spec(msg) => {
372                m.serialize_entry("kind", "spec")?;
373                m.serialize_entry("detail", msg)?;
374            }
375            ConversationError::Flow(errs) => {
376                m.serialize_entry("kind", "flow")?;
377                m.serialize_entry("errors", errs)?;
378            }
379            ConversationError::Compile(e) => {
380                m.serialize_entry("kind", "compile")?;
381                m.serialize_entry("errors", &e.0)?;
382            }
383        }
384        m.end()
385    }
386}
387
388/// A compiled digression: its trigger, lowered flow, extractors, and resume policy.
389#[derive(Clone)]
390pub struct CompiledOverlay {
391    /// Overlay name.
392    pub name: String,
393    /// Guard that activates the overlay.
394    pub trigger: Guard,
395    /// The overlay's lowered governance flow.
396    pub flow: CompiledFlow,
397    /// Extractors that fill the overlay's frame slots.
398    pub extractors: Vec<Extract>,
399    /// What the main flow does once this overlay completes.
400    pub resume: Resume,
401}
402
403impl CompiledOverlay {
404    /// The runtime [`Overlay`] this digression installs: trigger, governed flow
405    /// and resume policy. Extractors are registered separately (see
406    /// [`CompiledConversation::all_extractors`]).
407    pub fn to_runtime(&self) -> Overlay {
408        Overlay::new(
409            self.name.clone(),
410            self.trigger.clone(),
411            self.flow.clone(),
412            self.resume,
413        )
414    }
415}
416
417/// A compiled conversation: the validated main [`CompiledFlow`], the extractors
418/// that fill its frames' slots, any digressions, and the source spec.
419#[derive(Clone)]
420pub struct CompiledConversation {
421    flow: CompiledFlow,
422    extractors: Vec<Extract>,
423    overlays: Vec<CompiledOverlay>,
424    repair: BTreeMap<String, RepairPolicy>,
425    timing: BTreeMap<String, VoiceTiming>,
426    corrections: BTreeMap<String, Vec<String>>,
427    verbatim: BTreeMap<String, String>,
428    policies: Vec<crate::policy::Policy>,
429    spec: ConversationSpec,
430}
431
432// Manual: the lowered `Extract`s hold recognizer/resolver closures (not `Debug`),
433// so they are summarized by count.
434impl std::fmt::Debug for CompiledConversation {
435    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436        f.debug_struct("CompiledConversation")
437            .field("flow", &self.flow)
438            .field("extractors", &self.extractors.len())
439            .field("overlays", &self.overlays.len())
440            .field("policies", &self.policies)
441            .field("spec", &self.spec)
442            .finish()
443    }
444}
445
446impl CompiledConversation {
447    /// The governed flow IR this conversation lowered to.
448    pub fn flow(&self) -> &CompiledFlow {
449        &self.flow
450    }
451    /// The extractors lowered from `collect_frame` stages — register these on the
452    /// live session so each turn fills the frames' slots from the transcript.
453    pub fn extractors(&self) -> &[Extract] {
454        &self.extractors
455    }
456    /// The compiled digressions/overlays.
457    pub fn overlays(&self) -> &[CompiledOverlay] {
458        &self.overlays
459    }
460    /// The cross-cutting policy aspects attached to this conversation.
461    pub fn policies(&self) -> &[crate::policy::Policy] {
462        &self.policies
463    }
464    /// The set of state keys marked for redaction by `Policy::redact`.
465    pub fn redacted_fields(&self) -> BTreeSet<String> {
466        self.policies
467            .iter()
468            .flat_map(|p| p.redacted_keys().iter().cloned())
469            .collect()
470    }
471    /// Every extractor (main + overlays) — what [`Live::converse`](crate::live::Live)
472    /// registers so slots fill whether the main flow or a digression is active.
473    pub fn all_extractors(&self) -> Vec<Extract> {
474        let mut all = self.extractors.clone();
475        for ov in &self.overlays {
476            all.extend(ov.extractors.iter().cloned());
477        }
478        all
479    }
480    /// Build the runtime [`FlowStack`] — the main flow plus its digressions and
481    /// repair policies, with push-on-trigger / resume-on-completion.
482    ///
483    /// This is the artifact every execution path drives: the simulator
484    /// ([`Sim`](crate::simulation::Sim)) and a live session
485    /// ([`Live::converse`](crate::live::Live::converse)) both install exactly
486    /// this stack, so what a definition does is the same in each.
487    pub fn stack(&self, mode: Enforcement) -> FlowStack {
488        FlowStack::new(self.flow.clone(), mode)
489            .with_overlays(self.overlays.iter().map(CompiledOverlay::to_runtime))
490            .with_repairs(self.repair.clone())
491            .with_timings(self.timing.clone())
492            .with_corrections(self.corrections.clone())
493            .with_verbatims(self.verbatim.clone())
494    }
495    /// The per-stage repair policies the runtime applies to the main flow.
496    pub fn repair_policies(&self) -> &BTreeMap<String, RepairPolicy> {
497        &self.repair
498    }
499    /// The stages that must say their text word for word, keyed by stage id.
500    pub fn verbatim_policies(&self) -> &BTreeMap<String, String> {
501        &self.verbatim
502    }
503    /// The slots whose correction re-opens later stages, each with the state
504    /// keys the correction clears (see
505    /// [`FlowStack::with_correction`](gemini_adk_rs::flow::FlowStack::with_correction)).
506    pub fn correction_policies(&self) -> &BTreeMap<String, Vec<String>> {
507        &self.corrections
508    }
509    /// The per-stage voice timing, main flow and digressions, keyed by
510    /// stage id.
511    pub fn timing_policies(&self) -> &BTreeMap<String, VoiceTiming> {
512        &self.timing
513    }
514    /// The authoring spec it was compiled from.
515    pub fn spec(&self) -> &ConversationSpec {
516        &self.spec
517    }
518    /// Render the lowered flow as a Mermaid diagram.
519    pub fn to_mermaid(&self) -> String {
520        self.flow.to_mermaid()
521    }
522    /// Build a [`FlowMonitor`] over the lowered flow.
523    pub fn monitor(&self, mode: Enforcement) -> FlowMonitor {
524        FlowMonitor::compiled(self.flow.clone(), mode)
525    }
526}
527
528/// Fluent builder that produces a [`ConversationSpec`]; sugar over the spec.
529///
530/// (Not `Debug`: [`resolve_slot`](Conversation::resolve_slot) bindings hold async
531/// closures. The serializable [`ConversationSpec`] is `Debug` via [`spec`](Conversation::spec).)
532#[derive(Clone, Default)]
533pub struct Conversation {
534    spec: ConversationSpec,
535    resolvers: Vec<StageResolver>,
536    /// When `Some(i)`, stage setters target `spec.overlays[i]` instead of the main
537    /// flow (between `.overlay(..)` and `.end_overlay()`).
538    current_overlay: Option<usize>,
539}
540
541impl Conversation {
542    /// Start a new conversation.
543    pub fn new(name: impl Into<String>) -> Self {
544        Self {
545            spec: ConversationSpec {
546                name: name.into(),
547                ..Default::default()
548            },
549            resolvers: Vec::new(),
550            current_overlay: None,
551        }
552    }
553
554    /// Begin authoring a new stage; subsequent setters apply to it. Routes to the
555    /// active overlay when between `.overlay(..)` and `.end_overlay()`.
556    pub fn stage(mut self, id: impl Into<String>) -> Self {
557        let stage = StageSpec {
558            id: id.into(),
559            ..Default::default()
560        };
561        match self.current_overlay {
562            Some(i) => self.spec.overlays[i].stages.push(stage),
563            None => self.spec.stages.push(stage),
564        }
565        self
566    }
567
568    /// Begin authoring a digression/overlay; subsequent `.stage(..)` calls (until
569    /// `.end_overlay()`) populate it. Set its activation guard with `.trigger(..)`
570    /// (an overlay with no trigger never fires — fail-closed).
571    pub fn overlay(mut self, name: impl Into<String>) -> Self {
572        self.spec.overlays.push(OverlaySpec {
573            name: name.into(),
574            // Fail-closed default: never triggers until `.trigger(..)` is set.
575            trigger: Guard::is_true("__overlay_never_triggers__"),
576            stages: Vec::new(),
577            require: Vec::new(),
578            resume: Resume::Previous,
579        });
580        self.current_overlay = Some(self.spec.overlays.len() - 1);
581        self
582    }
583
584    /// Set the activation guard of the overlay currently being authored.
585    pub fn trigger(mut self, guard: Guard) -> Self {
586        if let Some(i) = self.current_overlay {
587            self.spec.overlays[i].trigger = guard;
588        }
589        self
590    }
591
592    /// Set the resume policy of the overlay currently being authored.
593    pub fn resume(mut self, resume: Resume) -> Self {
594        if let Some(i) = self.current_overlay {
595            self.spec.overlays[i].resume = resume;
596        }
597        self
598    }
599
600    /// End overlay authoring; subsequent `.stage(..)` calls target the main
601    /// flow again. (Not a sub-builder exit — the `Conversation` is the same
602    /// value throughout — so it is named for what it does.)
603    pub fn end_overlay(mut self) -> Self {
604        self.current_overlay = None;
605        self
606    }
607
608    /// Append a pre-built [`StageSpec`] (e.g. from a [`Motif`](crate::motifs::Motif)).
609    /// Routes to the active overlay when authoring one, else the main flow.
610    /// Subsequent stage setters (`next`, `say`, …) apply to it.
611    pub fn add_stage(mut self, stage: StageSpec) -> Self {
612        match self.current_overlay {
613            Some(i) => self.spec.overlays[i].stages.push(stage),
614            None => self.spec.stages.push(stage),
615        }
616        self
617    }
618
619    /// Append a pre-built [`OverlaySpec`] (e.g. a `Motif::faq_digression`). Leaves
620    /// overlay-authoring mode (the overlay is already complete).
621    pub fn add_overlay(mut self, overlay: OverlaySpec) -> Self {
622        self.spec.overlays.push(overlay);
623        self.current_overlay = None;
624        self
625    }
626
627    /// Attach a cross-cutting [`Policy`](crate::policy::Policy) aspect.
628    pub fn policy(mut self, policy: impl Into<crate::policy::Policy>) -> Self {
629        self.spec.policies.push(policy.into());
630        self
631    }
632
633    fn current(&mut self) -> &mut StageSpec {
634        let stages = match self.current_overlay {
635            Some(i) => &mut self.spec.overlays[i].stages,
636            None => &mut self.spec.stages,
637        };
638        stages
639            .last_mut()
640            .expect("call .stage(..) before configuring a stage")
641    }
642
643    /// Set the stage's instruction: guidance projected to the model while the
644    /// stage is active. It steers what the model says; it is not verbatim
645    /// speech and carries no guarantee of exact wording.
646    pub fn instruction(mut self, text: impl Into<String>) -> Self {
647        self.current().say = Some(text.into());
648        self
649    }
650
651    /// Alias of [`instruction`](Self::instruction). The name suggests exact
652    /// speech, which this never was; prefer `instruction` in new code.
653    pub fn say(self, text: impl Into<String>) -> Self {
654        self.instruction(text)
655    }
656
657    /// Set the stage's grounding template.
658    pub fn ground(mut self, template: impl Into<String>) -> Self {
659        self.current().ground = Some(template.into());
660        self
661    }
662
663    /// Collect the given slots in this stage (drives default completion).
664    pub fn collect<I, S>(mut self, fields: I) -> Self
665    where
666        I: IntoIterator<Item = S>,
667        S: Into<String>,
668    {
669        self.current().collect = fields.into_iter().map(Into::into).collect();
670        self
671    }
672
673    /// Collect the slots of a typed [`gemini_adk_rs::frame::Frame`] in this
674    /// stage. The frame's slot state-keys drive the `captured` completion; its
675    /// metadata (prompts/confirm/pii) is available via `F::frame()` for
676    /// confirmation and repair.
677    pub fn collect_frame<F: Frame>(mut self) -> Self {
678        let spec = F::frame();
679        let stage = self.current();
680        stage.collect = spec.slot_keys();
681        stage.frame = Some(spec);
682        self
683    }
684
685    /// Allow the given tools while this stage is active.
686    pub fn allow<I, S>(mut self, tools: I) -> Self
687    where
688        I: IntoIterator<Item = S>,
689        S: Into<String>,
690    {
691        self.current().allow = tools.into_iter().map(Into::into).collect();
692        self
693    }
694
695    /// Set an explicit completion guard for this stage: the stage counts as
696    /// complete when `guard` holds (default: all `collect`ed slots captured).
697    pub fn complete_when(mut self, guard: Guard) -> Self {
698        self.current().done = Some(guard);
699        self
700    }
701
702    /// Commit a confirm-before-act tool in this stage, gated by `when`.
703    pub fn commit(mut self, tool: impl Into<String>, when: Guard) -> Self {
704        self.current().commit = Some(CommitSpec {
705            tool: tool.into(),
706            when,
707        });
708        self
709    }
710
711    /// Add a forward transition to `to` when `when` holds.
712    pub fn next(mut self, to: impl Into<String>, when: Guard) -> Self {
713        self.current().next.push(TransitionSpec {
714            to: to.into(),
715            when,
716        });
717        self
718    }
719
720    /// Fill a slot in the current stage from an **async resolver** — a tool call,
721    /// HTTP fetch, MCP request, or agent. `args` names the `State` keys bound into
722    /// the JSON object passed to `fetch`; the returned value fills `name`. With a
723    /// `ttl`, results are memoized by `(field, canonical args)`.
724    ///
725    /// The slot is added to the stage's `collect`, so its `captured` completion
726    /// waits for the resolution. The closure lives only in the builder; the
727    /// serializable spec is unaffected.
728    pub fn resolve_slot<I, S, F, Fut>(
729        mut self,
730        name: impl Into<String>,
731        args: I,
732        ttl: Option<Duration>,
733        fetch: F,
734    ) -> Self
735    where
736        I: IntoIterator<Item = S>,
737        S: Into<String>,
738        F: Fn(Value) -> Fut + Send + Sync + 'static,
739        Fut: Future<Output = Result<Value, String>> + Send + 'static,
740    {
741        let name = name.into();
742        let stage = self.current().id.clone();
743        let args: Vec<String> = args.into_iter().map(Into::into).collect();
744        if !self.current().collect.contains(&name) {
745            self.current().collect.push(name.clone());
746        }
747        // Record the serializable declaration too, so `into_spec()` carries the
748        // resolver and the spec can later be re-bound from a `ResolverRegistry`.
749        self.current().resolve.push(ResolveSpec {
750            slot: name.clone(),
751            resolver: None,
752            args: args.clone(),
753            ttl_secs: ttl.map(|d| d.as_secs()),
754        });
755        let fetch = Arc::new(fetch);
756        self.resolvers.push(StageResolver {
757            stage,
758            name,
759            args,
760            ttl,
761            fetch: Arc::new(move |v| {
762                let fetch = fetch.clone();
763                Box::pin(async move { fetch(v).await })
764            }),
765        });
766        self
767    }
768
769    /// Add an explicit dependency on another stage.
770    pub fn after(mut self, dep: impl Into<String>) -> Self {
771        self.current().after.push(dep.into());
772        self
773    }
774
775    /// Mark the current stage terminal.
776    pub fn terminal(mut self) -> Self {
777        self.current().terminal = true;
778        self
779    }
780
781    /// Attach a [`RepairPolicy`] to the current stage.
782    pub fn repair(mut self, policy: RepairPolicy) -> Self {
783        self.current().repair = Some(policy);
784        self
785    }
786
787    /// Require the current stage to say `text` word for word (strict canned
788    /// mode): it completes only once the model's output transcript matches.
789    /// The model holds the floor while it reads, unless the stage sets
790    /// [`timing`](Self::timing). Needs output transcription on the session.
791    pub fn verbatim(mut self, text: impl Into<String>) -> Self {
792        self.current().verbatim = Some(text.into());
793        self
794    }
795
796    /// Set the current stage's voice pacing; see [`VoiceTiming`].
797    ///
798    /// ```
799    /// # use gemini_adk_fluent_rs::prelude::*;
800    /// use std::time::Duration;
801    /// Conversation::new("disclosure")
802    ///     .stage("read_terms")
803    ///     .say("Read the terms word for word.")
804    ///     .timing(VoiceTiming::new().uninterruptible())
805    ///     .stage("confirm")
806    ///     .collect(["agreed"])
807    ///     .timing(VoiceTiming::new().reprompt_after(Duration::from_secs(6)));
808    /// ```
809    pub fn timing(mut self, timing: VoiceTiming) -> Self {
810        self.current().timing = Some(timing);
811        self
812    }
813
814    /// Require these stages for completion (lowers to a Flow `require`). Targets
815    /// the active overlay when authoring one, else the main flow.
816    pub fn require<I, S>(mut self, steps: I) -> Self
817    where
818        I: IntoIterator<Item = S>,
819        S: Into<String>,
820    {
821        let req: Vec<String> = steps.into_iter().map(Into::into).collect();
822        match self.current_overlay {
823            Some(i) => self.spec.overlays[i].require = req,
824            None => self.spec.require = req,
825        }
826        self
827    }
828
829    /// The spec built so far.
830    pub fn spec(&self) -> &ConversationSpec {
831        &self.spec
832    }
833
834    /// Consume into the underlying spec.
835    pub fn into_spec(self) -> ConversationSpec {
836        self.spec
837    }
838
839    /// Compile from a [`ConversationSpec`] (e.g. parsed from JSON/YAML).
840    ///
841    /// If the spec declares any named-resolver slots (`stage.resolve`), this
842    /// errors — those slots would be collected but never filled. Use
843    /// [`Conversation::from_spec_with_resolvers`] to bind them.
844    pub fn from_spec(spec: ConversationSpec) -> Result<CompiledConversation, ConversationError> {
845        Self::from_spec_with_resolvers(spec, &ResolverRegistry::new())
846    }
847
848    /// Compile a [`ConversationSpec`] with its declared resolvers **stubbed**
849    /// (each returns JSON `null`) — for structural validation, model-free
850    /// simulation, and CI, where resolver outputs are supplied by scenario
851    /// `set` steps rather than live fetches.
852    ///
853    /// Use this (not [`from_spec`](Self::from_spec)) when compiling a spec from
854    /// untrusted JSON for testing/authoring; use
855    /// [`from_spec_with_resolvers`](Self::from_spec_with_resolvers) at deploy
856    /// time to bind real implementations.
857    pub fn from_spec_stubbing_resolvers(
858        spec: ConversationSpec,
859    ) -> Result<CompiledConversation, ConversationError> {
860        let registry = ResolverRegistry::stubbing(&spec);
861        Self::from_spec_with_resolvers(spec, &registry)
862    }
863
864    /// Compile a [`ConversationSpec`] and bind its declared named-resolver slots
865    /// from `registry`. This makes a JSON spec + a resolver registry a complete
866    /// deployable unit.
867    ///
868    /// Errors if the spec references a resolver name absent from `registry`.
869    pub fn from_spec_with_resolvers(
870        spec: ConversationSpec,
871        registry: &ResolverRegistry,
872    ) -> Result<CompiledConversation, ConversationError> {
873        let mut resolvers = Vec::new();
874        let stages = spec
875            .stages
876            .iter()
877            .chain(spec.overlays.iter().flat_map(|o| o.stages.iter()));
878        for stage in stages {
879            for r in &stage.resolve {
880                let fetch = registry.get(r.resolver_name()).ok_or_else(|| {
881                    ConversationError::Spec(format!(
882                        "stage '{}' slot '{}' needs resolver '{}', which is not in the registry",
883                        stage.id,
884                        r.slot,
885                        r.resolver_name()
886                    ))
887                })?;
888                resolvers.push(StageResolver {
889                    stage: stage.id.clone(),
890                    name: r.slot.clone(),
891                    args: r.args.clone(),
892                    ttl: r.ttl_secs.map(Duration::from_secs),
893                    fetch,
894                });
895            }
896        }
897        compile_spec(spec, resolvers)
898    }
899
900    /// Lower and validate into a [`CompiledConversation`].
901    pub fn compile(self) -> Result<CompiledConversation, ConversationError> {
902        compile_spec(self.spec, self.resolvers)
903    }
904}
905
906impl crate::live::Live {
907    /// Drive a [`Live`](crate::live::Live) session from a compiled conversation:
908    /// **govern** with its lowered flow, **install** its digressions and repair
909    /// policies on the session's [`FlowStack`], and **register** the extractors
910    /// that fill its frames' slots each turn. The one-liner entrypoint for "run
911    /// this conversation": the session drives the same stack
912    /// [`CompiledConversation::stack`] builds for the simulator.
913    ///
914    /// ```no_run
915    /// # use gemini_adk_fluent_rs::prelude::*;
916    /// # use gemini_adk_fluent_rs::conversation::Conversation;
917    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
918    /// let convo = Conversation::new("booking")
919    ///     .stage("done").terminal()
920    ///     .require(["done"])
921    ///     .compile()?;
922    /// let handle = Live::builder()
923    ///     .converse(&convo)
924    ///     .connect_from_env()
925    ///     .await?;
926    /// # let _ = handle; Ok(())
927    /// # }
928    /// ```
929    pub fn converse(self, convo: &CompiledConversation) -> Self {
930        self.govern_compiled(convo.flow().clone())
931            .install_conversation(convo)
932    }
933
934    /// Like [`converse`](Self::converse) but attaches the flow in **observe** mode
935    /// (nothing blocked; deviations recorded) while still registering extractors.
936    pub fn converse_observe(self, convo: &CompiledConversation) -> Self {
937        self.observe_compiled(convo.flow().clone())
938            .install_conversation(convo)
939    }
940
941    /// Everything a compiled conversation declares beyond its main flow: the
942    /// digressions and repair policies (installed on the session's
943    /// [`FlowStack`]) and the extractors (main + overlays). The main flow was
944    /// attached by the caller in the chosen enforcement mode.
945    fn install_conversation(mut self, convo: &CompiledConversation) -> Self {
946        self.digressions = convo
947            .overlays()
948            .iter()
949            .map(CompiledOverlay::to_runtime)
950            .collect();
951        self.repair_policies = convo.repair_policies().clone();
952        // A timing set on the builder with `stage_timing` wins over the
953        // conversation's, whichever was called first.
954        for (step, timing) in convo.timing_policies() {
955            self.stage_timings
956                .entry(step.clone())
957                .or_insert_with(|| timing.clone());
958        }
959        self.corrections = convo.correction_policies().clone();
960        self.verbatims = convo.verbatim_policies().clone();
961        // A safety hand-off is already lowered into the digressions above;
962        // redaction and commit governance are enforced at connect. Policies
963        // added with `Live::policy` are kept alongside.
964        let mut policies: Vec<_> = convo
965            .policies()
966            .iter()
967            .filter(|p| !matches!(p, crate::policy::Policy::SafetyHandoff { .. }))
968            .cloned()
969            .collect();
970        policies.append(&mut self.policies);
971        self.policies = policies;
972        for extract in convo.all_extractors() {
973            self = self.extract_record(extract);
974        }
975        self
976    }
977}
978
979fn is_always(g: &Guard) -> bool {
980    matches!(g, Guard::Spec(Pred::Always))
981}
982
983/// Combine guards into a single disjunction, collapsing trivial cases.
984fn any_of(guards: Vec<Guard>) -> Option<Guard> {
985    if guards.is_empty() {
986        return None;
987    }
988    if guards.iter().any(is_always) {
989        return Some(Guard::always());
990    }
991    if guards.len() == 1 {
992        return guards.into_iter().next();
993    }
994    Some(Guard::any(guards))
995}
996
997/// Lower a set of stages (the main flow or an overlay) into a [`CompiledFlow`],
998/// with conversation-level referential checks.
999fn lower_flow(stages: &[StageSpec], require: &[String]) -> Result<CompiledFlow, ConversationError> {
1000    if stages.is_empty() {
1001        return Err(ConversationError::Empty);
1002    }
1003    let ids: BTreeSet<&str> = stages.iter().map(|s| s.id.as_str()).collect();
1004    if ids.len() != stages.len() {
1005        return Err(ConversationError::Spec("duplicate stage ids".into()));
1006    }
1007    for s in stages {
1008        for t in &s.next {
1009            if !ids.contains(t.to.as_str()) {
1010                return Err(ConversationError::Spec(format!(
1011                    "stage '{}' transitions to unknown stage '{}'",
1012                    s.id, t.to
1013                )));
1014            }
1015        }
1016        for d in &s.after {
1017            if !ids.contains(d.as_str()) {
1018                return Err(ConversationError::Spec(format!(
1019                    "stage '{}' depends on unknown stage '{}'",
1020                    s.id, d
1021                )));
1022            }
1023        }
1024        if let Some(target) = s.repair.as_ref().and_then(|r| r.escalate_to.as_ref())
1025            && !ids.contains(target.as_str())
1026        {
1027            return Err(ConversationError::Spec(format!(
1028                "stage '{}' escalates to unknown stage '{}'",
1029                s.id, target
1030            )));
1031        }
1032    }
1033    for r in require {
1034        if !ids.contains(r.as_str()) {
1035            return Err(ConversationError::Spec(format!(
1036                "require references unknown stage '{r}'"
1037            )));
1038        }
1039    }
1040
1041    // Incoming edges: target -> [(source, when)].
1042    let mut incoming: BTreeMap<&str, Vec<(&str, Guard)>> = BTreeMap::new();
1043    for s in stages {
1044        for t in &s.next {
1045            incoming
1046                .entry(t.to.as_str())
1047                .or_default()
1048                .push((s.id.as_str(), t.when.clone()));
1049        }
1050        // Repair escalation is an extra edge gated on the escalate signal.
1051        if let Some(target) = s.repair.as_ref().and_then(|r| r.escalate_to.as_ref()) {
1052            incoming
1053                .entry(target.as_str())
1054                .or_default()
1055                .push((s.id.as_str(), Guard::is_true(escalate_flag(&s.id))));
1056        }
1057    }
1058
1059    let mut fb = Flow::new();
1060    for s in stages {
1061        fb = fb.step(&s.id);
1062
1063        let mut deps: BTreeSet<&str> = s.after.iter().map(String::as_str).collect();
1064        if let Some(inc) = incoming.get(s.id.as_str()) {
1065            for (src, _) in inc {
1066                deps.insert(src);
1067            }
1068        }
1069        for d in deps {
1070            fb = fb.after(d);
1071        }
1072
1073        if let Some(inc) = incoming.get(s.id.as_str())
1074            && let Some(gate) = any_of(inc.iter().map(|(_, w)| w.clone()).collect())
1075        {
1076            fb = fb.gate(gate);
1077        }
1078
1079        if let Some(posture) = stage_posture(s) {
1080            fb = fb.posture(posture);
1081        }
1082        if let Some(ground) = &s.ground {
1083            fb = fb.ground(ground.clone());
1084        }
1085
1086        let mut allow: Vec<String> = s.allow.clone();
1087        if let Some(c) = &s.commit
1088            && !allow.contains(&c.tool)
1089        {
1090            allow.push(c.tool.clone());
1091        }
1092        if !allow.is_empty() {
1093            fb = fb.allow(allow);
1094        }
1095        if let Some(c) = &s.commit {
1096            fb = fb.commit(&c.tool, c.when.clone());
1097        }
1098
1099        if s.terminal {
1100            fb = fb.terminal();
1101        } else {
1102            let done = stage_completion(s).ok_or_else(|| {
1103                ConversationError::Spec(format!(
1104                    "non-terminal stage '{}' has no completion (add collect, next, or done)",
1105                    s.id
1106                ))
1107            })?;
1108            fb = fb.done(done);
1109        }
1110    }
1111
1112    // A corrected slot re-opens the stages downstream of it.
1113    for rule in reopen_rules(stages) {
1114        if let Some(when) = any_of(
1115            rule.slots
1116                .iter()
1117                .map(|slot| Guard::is_true(correction_flag(slot)))
1118                .collect(),
1119        ) {
1120            fb = fb.reset(rule.steps).when(when);
1121        }
1122    }
1123
1124    if !require.is_empty() {
1125        fb = fb.require(require.to_vec());
1126    }
1127
1128    let flow = fb.build().map_err(|e| ConversationError::Flow(e.issues))?;
1129    flow.compile().map_err(ConversationError::Compile)
1130}
1131
1132/// The extractors lowered from a stage list's `collect_frame` frames.
1133fn frame_extractors(stages: &[StageSpec]) -> Vec<Extract> {
1134    stages
1135        .iter()
1136        .filter_map(|s| s.frame.as_ref().and_then(FrameSpec::to_extract))
1137        .collect()
1138}
1139
1140fn compile_spec(
1141    mut spec: ConversationSpec,
1142    resolvers: Vec<StageResolver>,
1143) -> Result<CompiledConversation, ConversationError> {
1144    // Apply cross-cutting policies. SafetyHandoff lowers to a `safety` digression
1145    // (terminate on intent); Redact/Commit are carried for the runtime.
1146    for policy in spec.policies.clone() {
1147        if let crate::policy::Policy::SafetyHandoff { intents } = policy
1148            && let Some(trigger) = any_of(
1149                intents
1150                    .iter()
1151                    .map(|i| Guard::is_true(format!("intent:{i}")))
1152                    .collect(),
1153            )
1154        {
1155            spec.overlays.push(OverlaySpec {
1156                name: "safety".into(),
1157                trigger,
1158                stages: vec![StageSpec {
1159                    id: "safety_handoff".into(),
1160                    say: Some("Safety concern detected — hand off to a human now.".into()),
1161                    terminal: true,
1162                    ..Default::default()
1163                }],
1164                require: Vec::new(),
1165                resume: Resume::Terminate,
1166            });
1167        }
1168    }
1169
1170    // A declared resolver slot behaves like a collected slot: the stage's
1171    // implicit `captured` completion must wait for the resolution. The builder
1172    // path (`resolve_slot`) adds it eagerly; specs parsed from JSON get the
1173    // same normalization here, before lowering.
1174    for stage in spec
1175        .stages
1176        .iter_mut()
1177        .chain(spec.overlays.iter_mut().flat_map(|o| o.stages.iter_mut()))
1178    {
1179        for slot in stage
1180            .resolve
1181            .iter()
1182            .map(|r| r.slot.clone())
1183            .collect::<Vec<_>>()
1184        {
1185            if !stage.collect.contains(&slot) {
1186                stage.collect.push(slot);
1187            }
1188        }
1189    }
1190
1191    // Main flow.
1192    let flow = lower_flow(&spec.stages, &spec.require)?;
1193
1194    // Resolver bindings must reference a known stage — main or overlay. For a
1195    // stage id that appears in both, the main flow wins (ids are expected to be
1196    // globally unique across a spec).
1197    let ids: BTreeSet<&str> = spec.stages.iter().map(|s| s.id.as_str()).collect();
1198    let mut overlay_of: BTreeMap<&str, usize> = BTreeMap::new();
1199    for (i, ov) in spec.overlays.iter().enumerate() {
1200        for s in &ov.stages {
1201            overlay_of.entry(s.id.as_str()).or_insert(i);
1202        }
1203    }
1204    for r in &resolvers {
1205        if !ids.contains(r.stage.as_str()) && !overlay_of.contains_key(r.stage.as_str()) {
1206            return Err(ConversationError::Spec(format!(
1207                "resolver for slot '{}' references unknown stage '{}'",
1208                r.name, r.stage
1209            )));
1210        }
1211    }
1212
1213    // Group resolver bindings per stage, routed to the main flow or the owning
1214    // overlay so overlay-declared resolvers actually fill their slots.
1215    let build_resolver_extractor = |stage: &str, binds: &[&StageResolver]| {
1216        let mut builder = Extract::record(format!("{}__{}_resolve", spec.name, stage));
1217        for r in binds {
1218            let fetch = r.fetch.clone();
1219            builder = builder.field_resolve(r.name.clone(), r.args.clone(), r.ttl, move |args| {
1220                let fetch = fetch.clone();
1221                async move { fetch(args).await }
1222            });
1223        }
1224        builder.build()
1225    };
1226    let mut by_stage: BTreeMap<&str, Vec<&StageResolver>> = BTreeMap::new();
1227    for r in &resolvers {
1228        by_stage.entry(r.stage.as_str()).or_default().push(r);
1229    }
1230    let mut overlay_extractors: BTreeMap<usize, Vec<Extract>> = BTreeMap::new();
1231
1232    // Main extractors: frame recognizers + resolver-slot bindings.
1233    let mut extractors = frame_extractors(&spec.stages);
1234    for (stage, binds) in &by_stage {
1235        let extractor = build_resolver_extractor(stage, binds);
1236        if ids.contains(stage) {
1237            extractors.push(extractor);
1238        } else if let Some(&i) = overlay_of.get(stage) {
1239            overlay_extractors.entry(i).or_default().push(extractor);
1240        }
1241    }
1242
1243    // Overlays: each lowers to its own validated flow + extractors. An overlay
1244    // with no explicit `require` is complete when its terminal stages are done —
1245    // so completion is meaningful (without it, `is_complete()` is trivially true).
1246    let mut overlays = Vec::with_capacity(spec.overlays.len());
1247    for ov in &spec.overlays {
1248        let require = if ov.require.is_empty() {
1249            ov.stages
1250                .iter()
1251                .filter(|s| s.terminal)
1252                .map(|s| s.id.clone())
1253                .collect()
1254        } else {
1255            ov.require.clone()
1256        };
1257        let ov_flow = lower_flow(&ov.stages, &require)?;
1258        let mut ov_extractors = frame_extractors(&ov.stages);
1259        if let Some(bound) = overlay_extractors.remove(&overlays.len()) {
1260            ov_extractors.extend(bound);
1261        }
1262        overlays.push(CompiledOverlay {
1263            name: ov.name.clone(),
1264            trigger: ov.trigger.clone(),
1265            flow: ov_flow,
1266            extractors: ov_extractors,
1267            resume: ov.resume,
1268        });
1269    }
1270
1271    // Per-stage repair policies for the runtime to apply.
1272    let repair = spec
1273        .stages
1274        .iter()
1275        .filter_map(|s| s.repair.clone().map(|p| (s.id.clone(), p)))
1276        .collect();
1277
1278    // Per-stage voice timing, main flow and digressions alike.
1279    let timing = spec
1280        .stages
1281        .iter()
1282        .chain(spec.overlays.iter().flat_map(|ov| ov.stages.iter()))
1283        .filter_map(|s| {
1284            let timing = s.timing.clone().or_else(|| {
1285                s.verbatim
1286                    .as_ref()
1287                    .map(|_| VoiceTiming::new().uninterruptible())
1288            });
1289            timing.map(|t| (s.id.clone(), t))
1290        })
1291        .collect();
1292
1293    // Verbatim stages, main flow and digressions alike.
1294    let verbatim = spec
1295        .stages
1296        .iter()
1297        .chain(spec.overlays.iter().flat_map(|ov| ov.stages.iter()))
1298        .filter_map(|s| s.verbatim.clone().map(|t| (s.id.clone(), t)))
1299        .collect();
1300
1301    // Slots whose correction re-opens downstream stages (lowered as resets
1302    // in the main flow above); the stack watches them.
1303    let mut corrections: BTreeMap<String, Vec<String>> = BTreeMap::new();
1304    for rule in reopen_rules(&spec.stages) {
1305        for slot in rule.slots {
1306            let clear = corrections.entry(slot).or_default();
1307            for key in &rule.clear {
1308                if !clear.contains(key) {
1309                    clear.push(key.clone());
1310                }
1311            }
1312        }
1313    }
1314
1315    let policies = spec.policies.clone();
1316
1317    Ok(CompiledConversation {
1318        flow,
1319        extractors,
1320        overlays,
1321        repair,
1322        timing,
1323        corrections,
1324        verbatim,
1325        policies,
1326        spec,
1327    })
1328}
1329
1330/// The completion guard for a non-terminal stage, by priority:
1331/// explicit `done` → `captured(collect)` → disjunction of `next` conditions.
1332/// When repair escalation is configured, the stage may also complete by escalating
1333/// (so a stalled stage can hand off even though its normal completion never fired).
1334/// How a correction to each collected slot re-opens the conversation: the
1335/// stages downstream of the stage that collected it, and the state keys to
1336/// clear so those stages run again (their commit confirmations).
1337struct Reopen {
1338    slots: Vec<String>,
1339    steps: Vec<String>,
1340    clear: Vec<String>,
1341}
1342
1343/// For every stage that collects slots, the stages that depend on it
1344/// (through `after`, `next` or repair escalation, transitively) and would
1345/// have to run again if one of those slots were corrected.
1346fn reopen_rules(stages: &[StageSpec]) -> Vec<Reopen> {
1347    // Direct dependents: source -> stages that come after it.
1348    let mut dependents: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
1349    for s in stages {
1350        for d in &s.after {
1351            dependents
1352                .entry(d.as_str())
1353                .or_default()
1354                .insert(s.id.as_str());
1355        }
1356        for t in &s.next {
1357            dependents
1358                .entry(s.id.as_str())
1359                .or_default()
1360                .insert(t.to.as_str());
1361        }
1362        if let Some(target) = s.repair.as_ref().and_then(|r| r.escalate_to.as_ref()) {
1363            dependents
1364                .entry(s.id.as_str())
1365                .or_default()
1366                .insert(target.as_str());
1367        }
1368    }
1369    let collected: BTreeSet<&str> = stages
1370        .iter()
1371        .flat_map(|s| s.collect.iter().map(String::as_str))
1372        .collect();
1373    stages
1374        .iter()
1375        .filter(|s| !s.collect.is_empty())
1376        .filter_map(|s| {
1377            let mut downstream: BTreeSet<&str> = BTreeSet::new();
1378            let mut queue = vec![s.id.as_str()];
1379            while let Some(id) = queue.pop() {
1380                for next in dependents.get(id).into_iter().flatten() {
1381                    if *next != s.id && downstream.insert(next) {
1382                        queue.push(next);
1383                    }
1384                }
1385            }
1386            if downstream.is_empty() {
1387                return None;
1388            }
1389            let clear = stages
1390                .iter()
1391                .filter(|st| downstream.contains(st.id.as_str()))
1392                .filter_map(|st| st.commit.as_ref())
1393                .flat_map(|c| c.when.state_keys())
1394                .filter(|k| !collected.contains(k.as_str()))
1395                .collect::<BTreeSet<_>>()
1396                .into_iter()
1397                .collect();
1398            Some(Reopen {
1399                slots: s.collect.clone(),
1400                steps: downstream.into_iter().map(str::to_string).collect(),
1401                clear,
1402            })
1403        })
1404        .collect()
1405}
1406
1407/// The posture a stage projects: its `say`, plus the exact-reading
1408/// instruction of a verbatim stage.
1409fn stage_posture(s: &StageSpec) -> Option<String> {
1410    let read = s.verbatim.as_ref().map(|text| {
1411        format!(
1412            "Say the following exactly as written, word for word. Do not paraphrase, \
1413             add to or leave out any part of it:\n\n{text}"
1414        )
1415    });
1416    match (&s.say, read) {
1417        (Some(say), Some(read)) => Some(format!("{say}\n\n{read}")),
1418        (say, read) => say.clone().or(read),
1419    }
1420}
1421
1422fn stage_completion(s: &StageSpec) -> Option<Guard> {
1423    let base = stage_base_completion(s);
1424    // A verbatim stage also waits for its text to have been said.
1425    match (base, s.verbatim.is_some()) {
1426        (base, false) => base,
1427        (None, true) => Some(Guard::is_true(verbatim_flag(&s.id))),
1428        (Some(base), true) => Some(Guard::all(vec![base, Guard::is_true(verbatim_flag(&s.id))])),
1429    }
1430}
1431
1432fn stage_base_completion(s: &StageSpec) -> Option<Guard> {
1433    let base = if let Some(g) = &s.done {
1434        Some(g.clone())
1435    } else if !s.collect.is_empty() {
1436        Some(Guard::captured(s.collect.clone()))
1437    } else {
1438        any_of(s.next.iter().map(|t| t.when.clone()).collect())
1439    };
1440    if s.repair
1441        .as_ref()
1442        .and_then(|r| r.escalate_to.as_ref())
1443        .is_some()
1444    {
1445        let esc = Guard::is_true(escalate_flag(&s.id));
1446        return Some(match base {
1447            Some(b) => Guard::any(vec![b, esc]),
1448            None => esc,
1449        });
1450    }
1451    base
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456    use super::*;
1457    use gemini_adk_rs::flow::Enforcement;
1458    use gemini_adk_rs::state::State;
1459
1460    fn booking() -> CompiledConversation {
1461        Conversation::new("booking")
1462            .stage("collect")
1463            .instruction("Help the user book a table.")
1464            .collect(["party_size", "slot"])
1465            .next("check", Guard::captured(["party_size", "slot"]))
1466            .stage("check")
1467            .ground("Party of {party_size} at {slot}.")
1468            .next("confirm", Guard::is_true("availability_ok"))
1469            .stage("confirm")
1470            .commit("book", Guard::is_true("user_confirmed"))
1471            .next("done", Guard::called_ok("book"))
1472            .stage("done")
1473            .terminal()
1474            .require(["done"])
1475            .compile()
1476            .expect("booking compiles")
1477    }
1478
1479    #[test]
1480    fn compiles_to_a_governed_flow() {
1481        let convo = booking();
1482        // The commit tool is in the tool universe and gated.
1483        assert!(convo.flow().tool_surface().tools.contains("book"));
1484        assert_eq!(convo.flow().flow().steps.len(), 4);
1485    }
1486
1487    #[test]
1488    fn lowered_flow_enforces_stage_order_and_commit() {
1489        let convo = booking();
1490        let mut mon = convo.monitor(Enforcement::Enforce);
1491        let state = State::new();
1492
1493        // First stage active; book is blocked (not allowed here + not confirmed).
1494        let ex = mon.explain(&state);
1495        assert!(ex.active.contains(&"collect".to_string()));
1496        assert!(ex.blocked_tools.contains_key("book"));
1497
1498        // Collect the slots → collect completes, check activates.
1499        let _ = state.set("party_size", 4u8);
1500        let _ = state.set("slot", "tomorrow 7pm");
1501        mon.on_turn(&state);
1502        assert!(mon.explain(&state).active.contains(&"check".to_string()));
1503
1504        // Availability → confirm activates; book still needs confirmation.
1505        let _ = state.set("availability_ok", true);
1506        mon.on_turn(&state);
1507        assert!(mon.admits_tool("book", &state).is_err());
1508
1509        // Confirm → book admitted; calling it completes the conversation.
1510        let _ = state.set("user_confirmed", true);
1511        assert!(mon.admits_tool("book", &state).is_ok());
1512        mon.on_tool_ok("book", &state);
1513        mon.on_turn(&state);
1514        assert!(mon.is_complete());
1515    }
1516
1517    #[test]
1518    fn spec_round_trips_through_json() {
1519        let spec = booking().spec().clone();
1520        let json = serde_json::to_string(&spec).expect("serialize spec");
1521        let back: ConversationSpec = serde_json::from_str(&json).expect("deserialize spec");
1522        let recompiled = Conversation::from_spec(back).expect("recompile from spec");
1523        assert_eq!(recompiled.flow().flow().steps.len(), 4);
1524    }
1525
1526    #[tokio::test]
1527    async fn named_resolver_spec_round_trips_and_binds_from_registry() {
1528        // A spec declaring a named-resolver slot (data only, no closure).
1529        let json = r#"
1530        {
1531          "name": "booking",
1532          "stages": [
1533            { "id": "check",
1534              "resolve": [{ "slot": "availability", "resolver": "avail", "args": ["party_size"] }],
1535              "next": [{ "to": "done", "when": { "captured": ["availability"] } }] },
1536            { "id": "done", "terminal": true }
1537          ],
1538          "require": ["done"]
1539        }
1540        "#;
1541        let spec: ConversationSpec = serde_json::from_str(json).expect("parse");
1542        // Round-trips losslessly.
1543        let reser = serde_json::to_string(&spec).unwrap();
1544        let back: ConversationSpec = serde_json::from_str(&reser).unwrap();
1545        assert_eq!(back.stages[0].resolve[0].resolver_name(), "avail");
1546
1547        // Without a registry, an unbound resolver is a loud error (not a silently
1548        // unfillable slot).
1549        let err = Conversation::from_spec(back.clone()).expect_err("unbound resolver errors");
1550        assert!(matches!(err, ConversationError::Spec(m) if m.contains("avail")));
1551
1552        // With a registry, it compiles and the resolver extractor is wired.
1553        let registry = ResolverRegistry::new().with("avail", |_args| async move {
1554            Ok(serde_json::json!({ "open": true }))
1555        });
1556        let convo =
1557            Conversation::from_spec_with_resolvers(back, &registry).expect("binds and compiles");
1558        // The resolver lowered to an extractor that fills the `availability` slot.
1559        assert!(
1560            convo.extractors().iter().any(|e| e
1561                .field_state_keys()
1562                .iter()
1563                .any(|(_, k)| k == "availability")),
1564            "a resolver extractor filling 'availability' was compiled in"
1565        );
1566    }
1567
1568    #[tokio::test]
1569    async fn resolver_spec_is_validatable_and_simulatable_without_a_registry() {
1570        // The exact shape the CLI/Python/CI data-plane sees: a resolver slot
1571        // with no implementation available.
1572        let json = r#"{ "name": "r", "stages": [
1573            { "id": "check",
1574              "resolve": [{ "slot": "avail", "resolver": "lookup", "args": ["x"] }],
1575              "next": [{ "to": "done", "when": { "captured": ["avail"] } }] },
1576            { "id": "done", "terminal": true } ], "require": ["done"] }"#;
1577        let spec: ConversationSpec = serde_json::from_str(json).unwrap();
1578
1579        // Strict path (no registry) errors — as it should at deploy time.
1580        assert!(Conversation::from_spec(spec.clone()).is_err());
1581
1582        // Stubbing path compiles for structural validation + simulation.
1583        let convo = Conversation::from_spec_stubbing_resolvers(spec)
1584            .expect("stubbed resolvers compile for testing");
1585
1586        // A scenario supplies the resolver's output via `set` (the stub fetch is
1587        // never invoked when the slot is pre-set), and the flow completes.
1588        use crate::simulation::Sim;
1589        let mut sim = Sim::new(&convo, gemini_adk_rs::flow::Enforcement::Enforce);
1590        sim.set("avail", serde_json::json!({ "open": true }));
1591        sim.turn();
1592        assert!(sim.is_complete(), "resolver slot supplied by set completes");
1593    }
1594
1595    #[tokio::test]
1596    async fn overlay_resolver_binds_validates_and_fills_the_overlay() {
1597        // A resolver declared on an OVERLAY stage must be validated against the
1598        // registry and lowered into that overlay's extractors — not silently
1599        // ignored (which left the slot permanently unfilled at runtime).
1600        let json = r#"{ "name": "ov", "stages": [
1601            { "id": "main", "terminal": true } ],
1602          "require": ["main"],
1603          "overlays": [ { "name": "lookup", "trigger": { "is_true": "intent:lookup" },
1604            "stages": [
1605              { "id": "fetch", "resolve": [{ "slot": "balance", "resolver": "bal" }] },
1606              { "id": "ov_done", "terminal": true, "after": ["fetch"] } ] } ] }"#;
1607        let spec: ConversationSpec = serde_json::from_str(json).unwrap();
1608
1609        // Unbound overlay resolver is a loud error, same as a main-stage one.
1610        let err = Conversation::from_spec(spec.clone()).expect_err("unbound overlay resolver");
1611        assert!(matches!(err, ConversationError::Spec(m) if m.contains("bal")));
1612
1613        // Bound, it compiles and the extractor lands on the overlay.
1614        let registry =
1615            ResolverRegistry::new().with("bal", |_args| async move { Ok(serde_json::json!(42)) });
1616        let convo = Conversation::from_spec_with_resolvers(spec, &registry).expect("compiles");
1617        assert!(
1618            convo.overlays()[0]
1619                .extractors
1620                .iter()
1621                .any(|e| e.field_state_keys().iter().any(|(_, k)| k == "balance")),
1622            "the overlay carries the resolver extractor for 'balance'"
1623        );
1624    }
1625
1626    #[tokio::test]
1627    async fn resolver_slot_counts_toward_stage_completion_from_json() {
1628        // A resolver-only stage from JSON must gain the slot in `collect` (the
1629        // builder path adds it eagerly), so its implicit captured completion
1630        // exists and lowering succeeds instead of failing with "no completion".
1631        let json = r#"{ "name": "r2", "stages": [
1632            { "id": "check", "resolve": [{ "slot": "avail", "resolver": "lookup" }] },
1633            { "id": "done", "terminal": true, "after": ["check"] } ],
1634          "require": ["done"] }"#;
1635        let spec: ConversationSpec = serde_json::from_str(json).unwrap();
1636        let convo =
1637            Conversation::from_spec_stubbing_resolvers(spec).expect("resolver-only stage compiles");
1638        assert!(
1639            convo.spec().stages[0]
1640                .collect
1641                .contains(&"avail".to_string()),
1642            "declared resolver slot normalized into collect"
1643        );
1644    }
1645
1646    #[test]
1647    fn conversation_spec_schema_is_valid_json_with_expected_shape() {
1648        let schema_str = conversation_spec_schema();
1649        let schema: serde_json::Value =
1650            serde_json::from_str(&schema_str).expect("schema is valid JSON");
1651        // The root describes ConversationSpec; its `stages` property must exist
1652        // (proves the transitive JsonSchema derives wired through StageSpec).
1653        assert_eq!(schema["title"], "ConversationSpec");
1654        assert!(
1655            schema["properties"]["stages"].is_object(),
1656            "schema exposes the stages property: {schema}"
1657        );
1658        // The closed atom set (Pred, via Guard) must appear in $defs.
1659        assert!(
1660            schema["definitions"]["Pred"].is_object() || schema["$defs"]["Pred"].is_object(),
1661            "Pred atom schema is present"
1662        );
1663    }
1664
1665    #[test]
1666    fn conversation_error_serializes_machine_readable() {
1667        // An unguarded commit is rejected at compile with a Compile error.
1668        let err = Conversation::new("x")
1669            .stage("s")
1670            .commit("pay", Guard::always())
1671            .complete_when(Guard::called_ok("pay"))
1672            .next("done", Guard::called_ok("pay"))
1673            .stage("done")
1674            .terminal()
1675            .compile()
1676            .expect_err("unguarded commit must fail");
1677        let json = serde_json::to_value(&err).expect("error serializes");
1678        assert_eq!(json["kind"], "compile");
1679        assert!(json["message"].is_string());
1680        // The structured per-error list carries the tagged FlowError diagnostics.
1681        let errors = json["errors"].as_array().expect("errors array");
1682        assert!(
1683            errors.iter().any(|e| e["kind"] == "unguarded_commit_tool"),
1684            "structured FlowError kinds present: {json}"
1685        );
1686    }
1687
1688    #[test]
1689    fn collect_frame_uses_frame_slot_keys() {
1690        use gemini_adk_rs::frame::{Frame, FrameSpec, SlotSpec};
1691
1692        struct Booking;
1693        impl Frame for Booking {
1694            fn frame() -> FrameSpec {
1695                FrameSpec {
1696                    name: "booking".into(),
1697                    slots: vec![SlotSpec::new("party_size"), SlotSpec::new("slot")],
1698                }
1699            }
1700        }
1701
1702        let convo = Conversation::new("b")
1703            .stage("collect")
1704            .collect_frame::<Booking>()
1705            .next("done", Guard::captured(["party_size", "slot"]))
1706            .stage("done")
1707            .terminal()
1708            .compile()
1709            .expect("compiles");
1710
1711        // The collect stage completes on the frame's slots being captured.
1712        let mut mon = convo.monitor(Enforcement::Enforce);
1713        let state = State::new();
1714        assert!(mon.explain(&state).active.contains(&"collect".to_string()));
1715        let _ = state.set("party_size", 2u8);
1716        let _ = state.set("slot", "noon");
1717        mon.on_turn(&state);
1718        // Frame slots captured -> collect completes and the (terminal) done latches.
1719        assert!(mon.marking().done.contains("collect"));
1720        assert!(mon.marking().done.contains("done"));
1721    }
1722
1723    #[tokio::test]
1724    async fn collect_frame_extractor_fills_and_scores_slots() {
1725        use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
1726        use gemini_adk_rs::live::TranscriptTurn;
1727
1728        struct Order;
1729        impl Frame for Order {
1730            fn frame() -> FrameSpec {
1731                FrameSpec {
1732                    name: "order".into(),
1733                    slots: vec![SlotSpec {
1734                        recognizer: Some(SlotRecognizer::OneOf(vec![
1735                            "pizza".into(),
1736                            "salad".into(),
1737                        ])),
1738                        ..SlotSpec::new("item")
1739                    }],
1740                }
1741            }
1742        }
1743
1744        let convo = Conversation::new("o")
1745            .stage("collect")
1746            .collect_frame::<Order>()
1747            .next("done", Guard::captured(["item"]))
1748            .stage("done")
1749            .terminal()
1750            .compile()
1751            .expect("compiles");
1752
1753        // The frame lowered to exactly one extractor.
1754        assert_eq!(convo.extractors().len(), 1);
1755        let extractor = convo.extractors()[0].clone().into_extractor();
1756
1757        // Run it over a transcript turn against State — it fills the slot and
1758        // records confidence under `state_meta:` for evidence.
1759        let state = State::new();
1760        let window = vec![TranscriptTurn {
1761            turn_number: 0,
1762            user: "I'd like a large PIZZA".into(),
1763            model: String::new(),
1764            tool_calls: Vec::new(),
1765            timestamp: std::time::Instant::now(),
1766        }];
1767        let out = extractor.extract_with_state(&window, &state).await.unwrap();
1768        assert_eq!(out.get("item").and_then(|v| v.as_str()), Some("pizza"));
1769
1770        let ev = state.evidence("item");
1771        assert_eq!(ev.source.as_deref(), Some("extraction"));
1772        assert!(ev.confidence.unwrap() > 0.0);
1773    }
1774
1775    #[tokio::test]
1776    async fn validate_rejects_out_of_range_recognized_values() {
1777        use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec, SlotValidator};
1778        use gemini_adk_rs::live::TranscriptTurn;
1779
1780        struct Party;
1781        impl Frame for Party {
1782            fn frame() -> FrameSpec {
1783                FrameSpec {
1784                    name: "party".into(),
1785                    slots: vec![SlotSpec {
1786                        recognizer: Some(SlotRecognizer::Integer),
1787                        validate: Some(SlotValidator::Range {
1788                            min: Some(1.0),
1789                            max: Some(12.0),
1790                        }),
1791                        ..SlotSpec::new("party_size")
1792                    }],
1793                }
1794            }
1795        }
1796
1797        let convo = Conversation::new("p")
1798            .stage("collect")
1799            .collect_frame::<Party>()
1800            .next("done", Guard::captured(["party_size"]))
1801            .stage("done")
1802            .terminal()
1803            .compile()
1804            .expect("compiles");
1805        let extractor = convo.extractors()[0].clone().into_extractor();
1806
1807        let run = |text: &str| {
1808            let extractor = extractor.clone();
1809            let text = text.to_string();
1810            async move {
1811                let state = State::new();
1812                let window = vec![TranscriptTurn {
1813                    turn_number: 0,
1814                    user: text,
1815                    model: String::new(),
1816                    tool_calls: Vec::new(),
1817                    timestamp: std::time::Instant::now(),
1818                }];
1819                let out = extractor.extract_with_state(&window, &state).await.unwrap();
1820                out.get("party_size").cloned()
1821            }
1822        };
1823
1824        // In range -> filled; out of range -> rejected (no value promoted).
1825        assert_eq!(run("a table for 4").await, Some(serde_json::json!(4)));
1826        assert_eq!(run("a table for 40").await, None);
1827    }
1828
1829    #[tokio::test]
1830    async fn resolve_slot_fills_from_async_fetch() {
1831        use gemini_adk_rs::live::TranscriptTurn;
1832
1833        let convo = Conversation::new("c")
1834            .stage("check")
1835            .resolve_slot("availability", ["party_size"], None, |args| async move {
1836                // Echo an availability decision derived from the bound arg.
1837                let n = args
1838                    .get("party_size")
1839                    .and_then(serde_json::Value::as_i64)
1840                    .unwrap_or(0);
1841                Ok(serde_json::json!(n <= 8))
1842            })
1843            .next("done", Guard::is_set("availability"))
1844            .stage("done")
1845            .terminal()
1846            .compile()
1847            .expect("compiles");
1848
1849        // The resolver lowered to an extractor.
1850        assert_eq!(convo.extractors().len(), 1);
1851        let extractor = convo.extractors()[0].clone().into_extractor();
1852
1853        let state = State::new();
1854        let _ = state.set("party_size", 4i64);
1855        let window = vec![TranscriptTurn {
1856            turn_number: 0,
1857            user: "any".into(),
1858            model: String::new(),
1859            tool_calls: Vec::new(),
1860            timestamp: std::time::Instant::now(),
1861        }];
1862        let out = extractor.extract_with_state(&window, &state).await.unwrap();
1863        assert_eq!(out.get("availability"), Some(&serde_json::json!(true)));
1864    }
1865
1866    #[test]
1867    fn converse_registers_flow_and_extractors() {
1868        // Smoke test: the one-liner entrypoint wires onto a Live builder.
1869        use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
1870
1871        struct Order;
1872        impl Frame for Order {
1873            fn frame() -> FrameSpec {
1874                FrameSpec {
1875                    name: "order".into(),
1876                    slots: vec![SlotSpec {
1877                        recognizer: Some(SlotRecognizer::OneOf(vec!["pizza".into()])),
1878                        ..SlotSpec::new("item")
1879                    }],
1880                }
1881            }
1882        }
1883        let convo = Conversation::new("o")
1884            .stage("collect")
1885            .collect_frame::<Order>()
1886            .next("done", Guard::captured(["item"]))
1887            .stage("done")
1888            .terminal()
1889            .compile()
1890            .expect("compiles");
1891
1892        // Builds without panic; converse is the one-liner that govern()s + registers.
1893        let _live = crate::live::Live::builder().converse(&convo);
1894    }
1895
1896    #[test]
1897    fn overlay_suspends_main_then_resumes_previous() {
1898        // Main: a -> b. FAQ overlay triggered by intent:faq, single terminal stage,
1899        // resume Previous. While the overlay runs, the main marking is untouched.
1900        let convo = Conversation::new("support")
1901            .stage("a")
1902            .next("b", Guard::is_true("a_done"))
1903            .stage("b")
1904            .terminal()
1905            .overlay("faq")
1906            .trigger(Guard::is_true("intent:faq"))
1907            // A gated answer stage so the overlay does not complete in one turn.
1908            .stage("answer")
1909            .complete_when(Guard::is_true("faq_answered"))
1910            .next("faq_end", Guard::is_true("faq_answered"))
1911            .stage("faq_end")
1912            .terminal()
1913            .resume(Resume::Previous)
1914            .end_overlay()
1915            .compile()
1916            .expect("compiles");
1917
1918        assert_eq!(convo.overlays().len(), 1);
1919        let mut stack = convo.stack(Enforcement::Enforce);
1920        let state = State::new();
1921
1922        // Main is active on `a`.
1923        assert!(stack.explain(&state).active.contains(&"a".to_string()));
1924        assert!(stack.active_overlay().is_none());
1925
1926        // Intent fires -> digression suspends the main flow and stays active.
1927        let _ = state.set("intent:faq", true);
1928        stack.on_turn(&state);
1929        assert_eq!(stack.active_overlay(), Some("faq"));
1930
1931        // Answer the FAQ and clear the intent so the overlay completes. It is
1932        // still the projected layer for this closing turn.
1933        let _ = state.set("faq_answered", true);
1934        let _ = state.set("intent:faq", false);
1935        stack.on_turn(&state);
1936        assert_eq!(stack.active_overlay(), Some("faq"));
1937
1938        // Next boundary: main resumed exactly where it was, still on `a`.
1939        stack.on_turn(&state);
1940        assert!(stack.active_overlay().is_none());
1941        assert!(stack.explain(&state).active.contains(&"a".to_string()));
1942
1943        // Main continues normally afterward.
1944        let _ = state.set("a_done", true);
1945        stack.on_turn(&state);
1946        assert!(stack.current().marking().done.contains("a"));
1947    }
1948
1949    /// The artifact `converse()` installs on a live session is the same stack
1950    /// the simulator drives. Build both from one compiled conversation, advance
1951    /// them over the same state and turn sequence, and require identical
1952    /// governance at every turn: active steps, admitted/blocked tools, the
1953    /// active digression, and repair signals.
1954    #[test]
1955    fn live_and_simulator_drive_the_same_stack() {
1956        use crate::simulation::Sim;
1957
1958        let convo = Conversation::new("support")
1959            .stage("collect")
1960            .allow(["lookup"])
1961            .complete_when(Guard::is_true("info"))
1962            .next("done", Guard::is_true("info"))
1963            .repair(RepairPolicy::new(2, 3).escalate_to("handoff"))
1964            .stage("done")
1965            .terminal()
1966            .stage("handoff")
1967            .complete_when(Guard::is_true("handoff_complete"))
1968            .overlay("faq")
1969            .trigger(Guard::is_true("intent:faq"))
1970            .stage("answer")
1971            .allow(["search_faq"])
1972            .complete_when(Guard::is_true("faq_answered"))
1973            .next("faq_end", Guard::is_true("faq_answered"))
1974            .stage("faq_end")
1975            .terminal()
1976            .resume(Resume::Previous)
1977            .end_overlay()
1978            .compile()
1979            .expect("compiles");
1980
1981        // What a live session installs (the builder's view, no network).
1982        let live = crate::live::Live::builder().converse(&convo);
1983        assert_eq!(
1984            live.digressions()
1985                .iter()
1986                .map(gemini_adk_rs::flow::Overlay::name)
1987                .collect::<Vec<_>>(),
1988            ["faq"]
1989        );
1990        assert!(live.repair_policies().contains_key("collect"));
1991        let monitor = FlowMonitor::compiled(convo.flow().clone(), Enforcement::Enforce);
1992        let mut installed = crate::live::connect::assemble_stack(
1993            monitor,
1994            live.digressions().to_vec(),
1995            live.repair_policies().clone(),
1996            std::collections::BTreeMap::new(),
1997            std::collections::BTreeMap::new(),
1998            std::collections::BTreeMap::new(),
1999            &[],
2000        );
2001
2002        // What the simulator drives.
2003        let mut sim = Sim::new(&convo, Enforcement::Enforce);
2004        // Both stacks read the same facts.
2005        let state = sim.state().clone();
2006
2007        let script: [&[(&str, bool)]; 6] = [
2008            &[],
2009            &[("intent:faq", true)],
2010            &[("faq_answered", true), ("intent:faq", false)],
2011            &[],
2012            &[],
2013            &[],
2014        ];
2015        for facts in script {
2016            for (k, v) in facts {
2017                let _ = state.set(*k, *v);
2018            }
2019            sim.turn();
2020            installed.on_turn(&state);
2021            let a = sim.explain();
2022            let b = installed.explain(&state);
2023            assert_eq!(a.active, b.active);
2024            assert_eq!(a.allowed_tools, b.allowed_tools);
2025            assert_eq!(a.blocked_tools, b.blocked_tools);
2026            assert_eq!(sim.active_overlay(), installed.active_overlay());
2027            assert_eq!(sim.is_complete(), installed.is_complete());
2028        }
2029        // The script exercised a digression and a repair escalation.
2030        assert!(
2031            installed
2032                .explain(&state)
2033                .active
2034                .contains(&"handoff".to_string())
2035        );
2036        assert_eq!(state.get::<bool>("repair:collect:escalate"), Some(true));
2037    }
2038
2039    /// Replacing the governing flow discards the conversation's digressions:
2040    /// they only mean something relative to the main flow they suspend.
2041    #[test]
2042    fn regoverning_drops_installed_digressions() {
2043        let convo = Conversation::new("s")
2044            .stage("a")
2045            .terminal()
2046            .overlay("faq")
2047            .trigger(Guard::is_true("intent:faq"))
2048            .stage("x")
2049            .terminal()
2050            .end_overlay()
2051            .compile()
2052            .expect("compiles");
2053        let live = crate::live::Live::builder().converse(&convo);
2054        assert_eq!(live.digressions().len(), 1);
2055        let live = live.govern(Flow::new().step("only").terminal().build().expect("valid"));
2056        assert!(live.digressions().is_empty());
2057    }
2058
2059    #[test]
2060    fn overlay_spec_round_trips_through_json() {
2061        let spec = Conversation::new("s")
2062            .stage("main")
2063            .terminal()
2064            .overlay("cancel")
2065            .trigger(Guard::is_true("intent:cancel"))
2066            .stage("confirm")
2067            .terminal()
2068            .resume(Resume::Terminate)
2069            .end_overlay()
2070            .into_spec();
2071        let json = serde_json::to_string(&spec).unwrap();
2072        let back: ConversationSpec = serde_json::from_str(&json).unwrap();
2073        assert_eq!(back.overlays.len(), 1);
2074        assert_eq!(back.overlays[0].resume, Resume::Terminate);
2075        // Recompiles from the round-tripped spec.
2076        assert!(Conversation::from_spec(back).is_ok());
2077    }
2078
2079    #[tokio::test]
2080    async fn safety_policy_terminates_on_intent() {
2081        use crate::policy::Policy;
2082        use crate::simulation::Sim;
2083
2084        let convo = Conversation::new("support")
2085            .policy(Policy::safety_handoff(["self_harm", "abuse"]))
2086            .policy(Policy::redact(["card_number"]))
2087            .stage("triage")
2088            .next("resolve", Guard::is_true("triaged"))
2089            .stage("resolve")
2090            .terminal()
2091            .require(["resolve"])
2092            .compile()
2093            .expect("compiles");
2094
2095        // Redaction set is recorded for the runtime.
2096        assert!(convo.redacted_fields().contains("card_number"));
2097        // SafetyHandoff lowered to a `safety` digression.
2098        assert!(convo.overlays().iter().any(|o| o.name == "safety"));
2099
2100        let mut sim = Sim::new(&convo, Enforcement::Enforce);
2101        assert!(sim.active().contains(&"triage".to_string()));
2102        assert!(!sim.is_complete());
2103
2104        // A safety intent fires -> the `safety` digression drives this turn,
2105        // and its hand-off instruction is what the model is told.
2106        sim.set("intent:abuse", true);
2107        sim.turn();
2108        assert_eq!(sim.active_overlay(), Some("safety"));
2109        assert!(!sim.is_complete());
2110        assert!(
2111            sim.postures()
2112                .iter()
2113                .any(|p| p.contains("hand off to a human")),
2114            "{:?}",
2115            sim.postures()
2116        );
2117
2118        // Next boundary: the conversation has terminated.
2119        sim.turn();
2120        assert!(sim.is_complete());
2121        assert!(sim.active().is_empty());
2122    }
2123
2124    /// The user changes a detail after confirming: the confirmation is
2125    /// withdrawn and the commit is blocked until they confirm again.
2126    #[tokio::test]
2127    async fn a_corrected_slot_reopens_the_confirmation() {
2128        use crate::simulation::{Scenario, SimStep};
2129        use serde_json::json;
2130        let convo = Conversation::new("booking")
2131            .stage("collect")
2132            .collect(["party_size"])
2133            .stage("book")
2134            .after("collect")
2135            .commit("book_table", Guard::is_true("confirmed"))
2136            .complete_when(Guard::called_ok("book_table"))
2137            .stage("end")
2138            .after("book")
2139            .terminal()
2140            .compile()
2141            .unwrap();
2142        assert_eq!(
2143            convo.correction_policies()["party_size"],
2144            ["confirmed"],
2145            "a correction to party_size clears the booking confirmation"
2146        );
2147
2148        let set = |key: &str, value: serde_json::Value| SimStep::Set {
2149            key: key.into(),
2150            value,
2151        };
2152        let scenario = Scenario {
2153            name: "correction".into(),
2154            steps: vec![
2155                set("party_size", json!(4)),
2156                SimStep::Turn,
2157                SimStep::ExpectActive(vec!["book".into()]),
2158                set("confirmed", json!(true)),
2159                SimStep::Turn,
2160                SimStep::ExpectAllowed("book_table".into()),
2161                // "Actually, make it five."
2162                set("party_size", json!(5)),
2163                SimStep::Turn,
2164                SimStep::ExpectDenied("book_table".into()),
2165                SimStep::ExpectActive(vec!["book".into()]),
2166                set("confirmed", json!(true)),
2167                SimStep::Turn,
2168                SimStep::ExpectAllowed("book_table".into()),
2169                SimStep::ToolOk("book_table".into()),
2170                SimStep::ExpectComplete,
2171            ],
2172        };
2173        scenario.run(&convo, Enforcement::Enforce).await.unwrap();
2174    }
2175
2176    /// Repeated barge-ins escalate a stage to its hand-off.
2177    #[tokio::test]
2178    async fn repeated_barge_ins_escalate_to_the_handoff() {
2179        use crate::simulation::{Scenario, SimStep};
2180        let convo = Conversation::new("support")
2181            .stage("collect")
2182            .collect(["issue"])
2183            .repair(
2184                RepairPolicy::new(10, 10)
2185                    .escalate_after_interruptions(2)
2186                    .escalate_to("handoff"),
2187            )
2188            .stage("handoff")
2189            .terminal()
2190            .compile()
2191            .unwrap();
2192        let scenario = Scenario {
2193            name: "barge-ins".into(),
2194            steps: vec![
2195                SimStep::Turn,
2196                SimStep::ExpectActive(vec!["collect".into()]),
2197                SimStep::Interrupt,
2198                SimStep::Turn,
2199                SimStep::ExpectActive(vec!["collect".into()]),
2200                SimStep::Interrupt,
2201                SimStep::ExpectSlot {
2202                    key: escalate_flag("collect"),
2203                    value: serde_json::json!(true),
2204                },
2205                SimStep::Turn,
2206                // The terminal hand-off completes the conversation.
2207                SimStep::ExpectComplete,
2208            ],
2209        };
2210        scenario.run(&convo, Enforcement::Enforce).await.unwrap();
2211    }
2212
2213    #[test]
2214    fn stage_timing_round_trips_and_reaches_the_stack() {
2215        use std::time::Duration;
2216        let convo = Conversation::new("pacing")
2217            .stage("ask")
2218            .collect(["name"])
2219            .timing(VoiceTiming::new().reprompt_after(Duration::from_secs(6)))
2220            .add_stage(crate::motifs::Motif::disclosure("terms", "terms_ack"))
2221            .compile()
2222            .unwrap();
2223
2224        let json = serde_json::to_value(convo.spec()).unwrap();
2225        assert_eq!(json["stages"][0]["timing"]["reprompt_after_ms"], 6000);
2226        assert_eq!(json["stages"][1]["timing"]["interruptible"], false);
2227        let back: ConversationSpec = serde_json::from_value(json).unwrap();
2228        assert_eq!(
2229            back.stages[0].timing,
2230            Some(VoiceTiming::new().reprompt_after(Duration::from_secs(6)))
2231        );
2232
2233        assert!(convo.timing_policies()["terms"].holds_floor());
2234        let stack = convo.stack(Enforcement::Enforce);
2235        assert_eq!(stack.timing_policies().len(), 2);
2236    }
2237
2238    #[test]
2239    fn converse_keeps_timings_and_policies_set_on_the_builder() {
2240        use std::time::Duration;
2241        let convo = Conversation::new("pacing")
2242            .stage("ask")
2243            .collect(["name"])
2244            .timing(VoiceTiming::new().reprompt_after(Duration::from_secs(6)))
2245            .stage("done")
2246            .after("ask")
2247            .terminal()
2248            .policy(crate::policy::Policy::redact(["card"]))
2249            .compile()
2250            .unwrap();
2251        let mine = VoiceTiming::new().reprompt_after(Duration::from_secs(3));
2252
2253        // Before and after `converse`, the builder's own settings survive.
2254        for live in [
2255            crate::live::Live::builder()
2256                .stage_timing("ask", mine.clone())
2257                .policy(crate::policy::Policy::redact(["ssn"]))
2258                .converse(&convo),
2259            crate::live::Live::builder()
2260                .converse(&convo)
2261                .stage_timing("ask", mine.clone())
2262                .policy(crate::policy::Policy::redact(["ssn"])),
2263        ] {
2264            assert_eq!(live.stage_timings["ask"], mine);
2265            assert_eq!(live.policies.len(), 2, "{:?}", live.policies);
2266        }
2267    }
2268
2269    #[tokio::test]
2270    async fn repair_reprompts_then_escalates_to_handoff() {
2271        use crate::simulation::Sim;
2272
2273        // `collect` needs `info`; if the user stalls, reprompt after 2 turns and
2274        // escalate (route to `handoff`) after 3.
2275        let convo = Conversation::new("support")
2276            .stage("collect")
2277            .complete_when(Guard::is_true("info"))
2278            .next("done", Guard::is_true("info"))
2279            .repair(RepairPolicy::new(2, 3).escalate_to("handoff"))
2280            .stage("done")
2281            .terminal()
2282            // Non-terminal so it stays *active* (terminal stages latch instantly).
2283            .stage("handoff")
2284            .complete_when(Guard::is_true("handoff_complete"))
2285            .compile()
2286            .expect("compiles");
2287
2288        let mut sim = Sim::new(&convo, Enforcement::Enforce);
2289        assert!(sim.active().contains(&"collect".to_string()));
2290
2291        // User stalls. Turn 1 active: no signal yet.
2292        sim.turn();
2293        assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), None);
2294        // Turn 2 active: reprompt raised.
2295        sim.turn();
2296        assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), Some(true));
2297        assert!(sim.active().contains(&"collect".to_string()));
2298        // Turn 3 active: escalate raised -> stage completes via escalation -> handoff.
2299        sim.turn();
2300        assert_eq!(sim.slot::<bool>("repair:collect:escalate"), Some(true));
2301        assert!(sim.active().contains(&"handoff".to_string()));
2302        assert!(!sim.active().contains(&"collect".to_string()));
2303    }
2304
2305    #[tokio::test]
2306    async fn repair_signal_clears_when_stage_satisfied() {
2307        use crate::simulation::Sim;
2308
2309        let convo = Conversation::new("s")
2310            .stage("collect")
2311            .complete_when(Guard::is_true("info"))
2312            .next("done", Guard::is_true("info"))
2313            .repair(RepairPolicy::new(1, 9))
2314            .stage("done")
2315            .terminal()
2316            .require(["done"])
2317            .compile()
2318            .expect("compiles");
2319
2320        let mut sim = Sim::new(&convo, Enforcement::Enforce);
2321        sim.turn(); // active 1 turn -> reprompt (threshold 1)
2322        assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), Some(true));
2323
2324        // User provides info -> collect completes this turn; the signal clears the
2325        // following turn (once the stage is observed no longer active).
2326        sim.set("info", true);
2327        sim.turn();
2328        sim.turn();
2329        assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), Some(false));
2330        assert!(sim.is_complete());
2331    }
2332
2333    #[test]
2334    fn rejects_transition_to_unknown_stage() {
2335        let err = Conversation::new("x")
2336            .stage("a")
2337            .next("ghost", Guard::always())
2338            .stage("b")
2339            .terminal()
2340            .compile()
2341            .expect_err("unknown target must fail");
2342        assert!(matches!(err, ConversationError::Spec(_)));
2343    }
2344
2345    #[test]
2346    fn rejects_unguarded_commit_via_flow_compile() {
2347        // commit guarded by Always is an unguarded commit — Flow::compile rejects it.
2348        let err = Conversation::new("x")
2349            .stage("s")
2350            .commit("pay", Guard::always())
2351            .complete_when(Guard::called_ok("pay"))
2352            .next("done", Guard::called_ok("pay"))
2353            .stage("done")
2354            .terminal()
2355            .compile()
2356            .expect_err("unguarded commit must fail");
2357        assert!(matches!(err, ConversationError::Compile(_)));
2358    }
2359}