Skip to main content

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