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