Skip to main content

gemini_adk_rs/flow/
stack.rs

1//! `FlowStack` — the runtime above the DAG: a main flow plus its digressions.
2//!
3//! A [`FlowMonitor`] governs one [`Flow`]. Real conversations
4//! leave the main task temporarily — a side question, a cancel, a hand-off —
5//! and expect to come back to it. A [`FlowStack`] holds the **main** monitor
6//! plus a set of [`Overlay`]s (digressions): when an overlay's trigger holds,
7//! the main flow is suspended untouched, the overlay's own monitor drives the
8//! session until it completes, and the main flow then continues per the
9//! overlay's [`Resume`] policy.
10//!
11//! This is the *only* governance object the Live control plane drives. A
12//! session governed by a bare flow is a stack with no overlays, so every
13//! execution path — the simulator, a live session, a replay — advances the same
14//! type with the same semantics. The authoring layer lowers a conversation into
15//! a stack; it does not implement one.
16//!
17//! Digressions nest: a digression can itself be interrupted by another (one
18//! not already on the active path), which drives until it completes and then
19//! resumes the one beneath it per its own [`Resume`] policy.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::sync::Arc;
23
24use serde::{Deserialize, Serialize};
25
26use super::timing::{VOICE_TIMING_KEY, VoiceTiming};
27use super::{
28    CompiledFlow, Enforcement, Flow, FlowExplanation, FlowMonitor, FlowSnapshot, Guard, Marking,
29    Step, StepAction,
30};
31use crate::state::State;
32
33/// The state key raised when a stage's repair policy escalates.
34pub fn escalate_flag(stage: &str) -> String {
35    format!("repair:{stage}:escalate")
36}
37
38/// The state key raised when a stage's repair policy asks for a reprompt.
39pub fn reprompt_flag(stage: &str) -> String {
40    format!("repair:{stage}:reprompt")
41}
42
43/// The state key raised for one turn when the user corrects `slot`: its
44/// value changed from one captured value to another. See
45/// [`FlowStack::with_correction`].
46pub fn correction_flag(slot: &str) -> String {
47    format!("correction:{slot}")
48}
49
50/// Written when the governed flow admits a tool call, before it runs:
51/// `{"tool": name, "id": call id}`. With [`TOOL_DENIED_KEY`] and
52/// [`TOOL_RESULT_KEY`] it puts every governance decision about a tool in the
53/// mutation journal, in order, which is what makes a recorded session
54/// replayable as a scenario.
55pub const TOOL_CALL_KEY: &str = "flow:tool_call";
56
57/// Written when the governed flow refuses a tool call:
58/// `{"tool": name, "id": call id, "reason": why}`.
59pub const TOOL_DENIED_KEY: &str = "flow:tool_denied";
60
61/// Written when an admitted tool call completes:
62/// `{"tool": name, "id": call id, "ok": succeeded}`.
63pub const TOOL_RESULT_KEY: &str = "flow:tool_result";
64
65/// The state key that names the active digression (`null` when the main flow
66/// is driving). Published by the control plane at every turn boundary.
67pub const OVERLAY_STATE_KEY: &str = "flow:overlay";
68
69/// The state key raised (`true`) once a [`Resume::Terminate`] digression has
70/// ended the conversation. Governance is inert from then on: no postures, no
71/// admitted tools. The runtime does not hang up by itself — the application
72/// decides how a call ends — so watch this key (or
73/// [`FlowStack::is_terminated`]) and close the session. Published by the
74/// control plane at every turn boundary.
75pub const TERMINATED_STATE_KEY: &str = "flow:terminated";
76
77/// How the main flow continues after a digression (overlay) completes.
78///
79/// `Restart` resets the main flow's *monitor* — its marking, fired `on_enter`
80/// actions and reset edges — against the session's existing `State`. It does
81/// not clear state, so slots the user already filled stay filled. It is a
82/// fresh pass over the same conversation, not a fresh business task.
83#[derive(
84    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
85)]
86#[serde(rename_all = "snake_case")]
87pub enum Resume {
88    /// Resume the main flow exactly where it was suspended (history state).
89    #[default]
90    Previous,
91    /// Re-enter the main flow from its start (see the type docs for what that
92    /// does and does not reset). Repair signals and counters are cleared with
93    /// the marking: a fresh pass starts with no step already escalated.
94    Restart,
95    /// End the conversation (e.g. a cancel/handoff digression). From the next
96    /// turn boundary the stack governs nothing: see
97    /// [`FlowStack::is_terminated`] and [`TERMINATED_STATE_KEY`].
98    Terminate,
99}
100
101fn default_reprompt_after() -> u32 {
102    2
103}
104fn default_escalate_after() -> u32 {
105    4
106}
107
108/// A step's repair policy for the weird paths (silence, no-match, the user
109/// stalling). The stack sets `repair:{step}:reprompt` once the step has been
110/// active `reprompt_after` turns without completing, and `repair:{step}:escalate`
111/// after `escalate_after`. When `escalate_to` is set, the authoring layer lowers
112/// an extra edge gated on the escalate signal — a deterministic "give up and
113/// hand off".
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
115pub struct RepairPolicy {
116    /// Turns the step may be active before a reprompt signal is raised.
117    #[serde(default = "default_reprompt_after")]
118    pub reprompt_after: u32,
119    /// Turns the step may be active before an escalation signal is raised.
120    #[serde(default = "default_escalate_after")]
121    pub escalate_after: u32,
122    /// Step to route to on escalation (also completes the current step).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub escalate_to: Option<String>,
125    /// Escalate once the user has barged in this many times while the step
126    /// is active (they keep cutting the model off: it is not working).
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub escalate_after_interruptions: Option<u32>,
129    /// Escalate once this many tool calls have failed or timed out while
130    /// the step is active.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub escalate_after_tool_failures: Option<u32>,
133}
134
135impl Default for RepairPolicy {
136    fn default() -> Self {
137        Self {
138            reprompt_after: default_reprompt_after(),
139            escalate_after: default_escalate_after(),
140            escalate_to: None,
141            escalate_after_interruptions: None,
142            escalate_after_tool_failures: None,
143        }
144    }
145}
146
147impl RepairPolicy {
148    /// A policy with the given reprompt/escalate turn thresholds.
149    pub fn new(reprompt_after: u32, escalate_after: u32) -> Self {
150        Self {
151            reprompt_after,
152            escalate_after,
153            ..Self::default()
154        }
155    }
156
157    /// Route to `step` on escalation (also completes the current step).
158    pub fn escalate_to(mut self, step: impl Into<String>) -> Self {
159        self.escalate_to = Some(step.into());
160        self
161    }
162
163    /// Also escalate after `n` barge-ins while the step is active.
164    pub fn escalate_after_interruptions(mut self, n: u32) -> Self {
165        self.escalate_after_interruptions = Some(n);
166        self
167    }
168
169    /// Also escalate after `n` failed or timed-out tool calls while the step
170    /// is active.
171    pub fn escalate_after_tool_failures(mut self, n: u32) -> Self {
172        self.escalate_after_tool_failures = Some(n);
173        self
174    }
175}
176
177/// A digression the runtime can enter: its trigger, governed flow and resume
178/// policy. Built from a compiled flow so it carries proof of compilation.
179#[derive(Debug, Clone)]
180pub struct Overlay {
181    name: String,
182    trigger: Guard,
183    flow: Flow,
184    resume: Resume,
185}
186
187impl Overlay {
188    /// A digression over a compiled flow.
189    pub fn new(
190        name: impl Into<String>,
191        trigger: Guard,
192        flow: CompiledFlow,
193        resume: Resume,
194    ) -> Self {
195        Self {
196            name: name.into(),
197            trigger,
198            flow: flow.into_flow(),
199            resume,
200        }
201    }
202
203    /// The digression's name.
204    pub fn name(&self) -> &str {
205        &self.name
206    }
207    /// The guard that activates it, evaluated against the main flow's context.
208    pub fn trigger(&self) -> &Guard {
209        &self.trigger
210    }
211    /// The digression's governed flow.
212    pub fn flow(&self) -> &Flow {
213        &self.flow
214    }
215    /// Mutable access to the flow, for connect-time merges (ambient tools).
216    pub fn flow_mut(&mut self) -> &mut Flow {
217        &mut self.flow
218    }
219    /// What the main flow does once this digression completes.
220    pub fn resume(&self) -> Resume {
221        self.resume
222    }
223}
224
225/// A digression currently suspending the main flow.
226struct ActiveOverlay {
227    name: String,
228    monitor: FlowMonitor,
229    resume: Resume,
230}
231
232/// See [`FlowStack::decision_scope`].
233#[derive(Debug, Clone, Default, PartialEq, Eq)]
234pub struct DecisionScope {
235    /// Questions named by the driving layer's guards and the enterable
236    /// digressions' triggers.
237    pub questions: BTreeSet<String>,
238    /// State keys the active steps' guards and those triggers read.
239    pub reads: BTreeSet<String>,
240}
241
242/// A shared, lock-protected [`FlowStack`] — the form in which the Live
243/// control plane owns governance, so runtime surfaces (e.g.
244/// [`LiveHandle::explain`](crate::live::LiveHandle::explain)) can snapshot it
245/// concurrently. All methods are synchronous: lock briefly and never hold the
246/// guard across an `await`.
247pub type SharedFlowStack = Arc<parking_lot::Mutex<FlowStack>>;
248
249/// The main flow plus its digressions, with push-on-trigger and
250/// resume-on-completion.
251///
252/// While a digression is active, governance — tool admission, postures and
253/// grounds, `explain()` — delegates to the **active** layer, and the main
254/// flow's marking is untouched, so [`Resume::Previous`] resumes exactly where
255/// it left off. Driven by `State` and guards: model-free and deterministic.
256///
257/// A digression stays the active layer through the turn on which it
258/// completes, so that turn's projection carries its closing instruction (the
259/// terminal stage's posture — "hand off to a human now"), and its
260/// [`Resume`] policy applies at the *next* turn boundary. Without that, a
261/// digression that completes on its entry turn would never be seen at all.
262pub struct FlowStack {
263    main: FlowMonitor,
264    mode: Enforcement,
265    overlays: Vec<Overlay>,
266    /// The digressions suspending the main flow, outermost first. The last
267    /// one drives; each suspends the one beneath it.
268    active: Vec<ActiveOverlay>,
269    /// The digression that ended the conversation, once one has.
270    terminated: Option<String>,
271    /// Per-main-step repair policies.
272    repair: BTreeMap<String, RepairPolicy>,
273    /// Consecutive turns each main step has been active without completing.
274    active_turns: BTreeMap<String, u32>,
275    /// Per-step voice timing, keyed by step id in whichever layer is active.
276    timing: BTreeMap<String, VoiceTiming>,
277    /// Slots whose correction re-opens later stages, with the state keys to
278    /// clear when that happens (e.g. the confirmation of a commit stage).
279    corrections: BTreeMap<String, Vec<String>>,
280    /// The last value seen for each watched slot.
281    slot_values: BTreeMap<String, serde_json::Value>,
282    /// Text each step must have said word for word.
283    verbatim: BTreeMap<String, String>,
284    /// Barge-ins each main step has seen while active.
285    interruptions: BTreeMap<String, u32>,
286    /// Failed tool calls each main step has seen while active.
287    tool_failures: BTreeMap<String, u32>,
288}
289
290impl std::fmt::Debug for FlowStack {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        f.debug_struct("FlowStack")
293            .field("mode", &self.mode)
294            .field("overlays", &self.overlays.len())
295            .field(
296                "active",
297                &self.active.iter().map(|a| &a.name).collect::<Vec<_>>(),
298            )
299            .field("terminated", &self.terminated)
300            .field("repair", &self.repair)
301            .field("timing", &self.timing)
302            .field("corrections", &self.corrections)
303            .finish()
304    }
305}
306
307impl FlowStack {
308    /// A stack over a compiled main flow with no digressions yet.
309    pub fn new(main: CompiledFlow, mode: Enforcement) -> Self {
310        Self::from_monitor(FlowMonitor::compiled(main, mode))
311    }
312
313    /// A stack whose main layer is an existing monitor (keeps its `on_enter`
314    /// actions and mode). This is how a bare governed flow becomes the one
315    /// governance object the control plane drives.
316    pub fn from_monitor(main: FlowMonitor) -> Self {
317        Self {
318            mode: main.mode(),
319            main,
320            overlays: Vec::new(),
321            active: Vec::new(),
322            terminated: None,
323            repair: BTreeMap::new(),
324            active_turns: BTreeMap::new(),
325            timing: BTreeMap::new(),
326            corrections: BTreeMap::new(),
327            verbatim: BTreeMap::new(),
328            slot_values: BTreeMap::new(),
329            interruptions: BTreeMap::new(),
330            tool_failures: BTreeMap::new(),
331        }
332    }
333
334    /// Add a digression.
335    pub fn with_overlay(mut self, overlay: Overlay) -> Self {
336        self.overlays.push(overlay);
337        self
338    }
339
340    /// Add several digressions, in trigger-priority order.
341    pub fn with_overlays(mut self, overlays: impl IntoIterator<Item = Overlay>) -> Self {
342        self.overlays.extend(overlays);
343        self
344    }
345
346    /// Admit installed service tools in the main flow and every digression.
347    /// Tool-specific constraints still apply. Call this after adding overlays
348    /// when constructing a stack, before it starts processing events.
349    pub fn with_ambient_tools(mut self, tools: impl IntoIterator<Item = String>) -> Self {
350        let tools: Vec<_> = tools.into_iter().collect();
351        let extend = |flow: &mut Flow| {
352            for name in &tools {
353                if !flow.ambient.contains(name) {
354                    flow.ambient.push(name.clone());
355                }
356            }
357        };
358        extend(&mut self.main.flow);
359        for overlay in &mut self.overlays {
360            extend(&mut overlay.flow);
361        }
362        for active in &mut self.active {
363            extend(&mut active.monitor.flow);
364        }
365        self
366    }
367
368    /// Attach a repair policy to a main-flow step.
369    pub fn with_repair(mut self, step: impl Into<String>, policy: RepairPolicy) -> Self {
370        self.repair.insert(step.into(), policy);
371        self
372    }
373
374    /// Attach repair policies keyed by main-flow step.
375    pub fn with_repairs(
376        mut self,
377        policies: impl IntoIterator<Item = (String, RepairPolicy)>,
378    ) -> Self {
379        self.repair.extend(policies);
380        self
381    }
382
383    /// Watch `slot` for corrections: when its value changes from one captured
384    /// value to another, [`correction_flag`]`(slot)` is raised for that turn
385    /// and `clear` keys are removed from state.
386    ///
387    /// Raising the flag does nothing by itself. The main flow reacts through
388    /// a [`Constraint::Reset`](super::Constraint::Reset) gated on it, which
389    /// un-latches the stages downstream of the slot so they run again with
390    /// the corrected value. A `Conversation` lowers exactly that for every
391    /// collected slot, clearing the confirmation of any commit stage it
392    /// re-opens.
393    pub fn with_correction(
394        mut self,
395        slot: impl Into<String>,
396        clear: impl IntoIterator<Item = String>,
397    ) -> Self {
398        self.corrections
399            .insert(slot.into(), clear.into_iter().collect());
400        self
401    }
402
403    /// Watch several slots; see [`with_correction`](Self::with_correction).
404    pub fn with_corrections(
405        mut self,
406        corrections: impl IntoIterator<Item = (String, Vec<String>)>,
407    ) -> Self {
408        self.corrections.extend(corrections);
409        self
410    }
411
412    /// The watched slots and the keys each correction clears.
413    pub fn correction_policies(&self) -> &BTreeMap<String, Vec<String>> {
414        &self.corrections
415    }
416
417    /// Require `step` to say `text` word for word; see
418    /// [`verbatim`](super::verbatim). The stack publishes the requirement
419    /// while the step is active. Its completion guard must also require
420    /// [`verbatim_flag`](super::verbatim_flag)`(step)`, which a
421    /// `Conversation` lowers for you.
422    pub fn with_verbatim(mut self, step: impl Into<String>, text: impl Into<String>) -> Self {
423        self.verbatim.insert(step.into(), text.into());
424        self
425    }
426
427    /// Verbatim requirements keyed by step.
428    pub fn with_verbatims(mut self, texts: impl IntoIterator<Item = (String, String)>) -> Self {
429        self.verbatim.extend(texts);
430        self
431    }
432
433    /// The verbatim requirements keyed by step.
434    pub fn verbatim_policies(&self) -> &BTreeMap<String, String> {
435        &self.verbatim
436    }
437
438    /// Attach voice timing to a step (main flow or digression).
439    pub fn with_timing(mut self, step: impl Into<String>, timing: VoiceTiming) -> Self {
440        self.timing.insert(step.into(), timing);
441        self
442    }
443
444    /// Attach voice timing keyed by step.
445    pub fn with_timings(
446        mut self,
447        timings: impl IntoIterator<Item = (String, VoiceTiming)>,
448    ) -> Self {
449        self.timing.extend(timings);
450        self
451    }
452
453    /// The voice timing keyed by step.
454    pub fn timing_policies(&self) -> &BTreeMap<String, VoiceTiming> {
455        &self.timing
456    }
457
458    /// The merged timing of the steps active right now in the driving layer
459    /// (empty when none of them has timing, or the conversation is over).
460    pub fn active_timing(&self, state: &State) -> VoiceTiming {
461        if self.timing.is_empty() || self.terminated.is_some() {
462            return VoiceTiming::default();
463        }
464        self.current()
465            .active_steps(state)
466            .iter()
467            .filter_map(|step| self.timing.get(&step.id))
468            .fold(VoiceTiming::default(), |acc, t| acc.merge(t))
469    }
470
471    /// Publish [`active_timing`](Self::active_timing) to
472    /// [`VOICE_TIMING_KEY`] in state, where the runtime's audio path, timers
473    /// and turn lifecycle read it. Writes only on change; removes the key
474    /// when no timing applies. The stack calls this itself after every turn
475    /// and tool call; call it once when installing the stack.
476    pub fn publish_timing(&self, state: &State) {
477        self.publish_verbatim(state);
478        if self.timing.is_empty() {
479            return;
480        }
481        let timing = self.active_timing(state);
482        let current = state.get::<VoiceTiming>(VOICE_TIMING_KEY);
483        if timing.is_empty() {
484            if current.is_some() {
485                state.remove(VOICE_TIMING_KEY);
486            }
487        } else if current.as_ref() != Some(&timing) {
488            let _ = state.set(VOICE_TIMING_KEY, &timing);
489        }
490    }
491
492    /// Publish the active step's verbatim requirement to
493    /// [`VERBATIM_KEY`](super::VERBATIM_KEY), or remove it when no active step
494    /// has one. Called with [`publish_timing`](Self::publish_timing).
495    fn publish_verbatim(&self, state: &State) {
496        if self.verbatim.is_empty() {
497            return;
498        }
499        let required = (self.terminated.is_none())
500            .then(|| {
501                self.current().active_steps(state).iter().find_map(|step| {
502                    self.verbatim
503                        .get(&step.id)
504                        .map(|text| super::VerbatimRequirement {
505                            step: step.id.clone(),
506                            text: text.clone(),
507                        })
508                })
509            })
510            .flatten();
511        let current = state.get::<super::VerbatimRequirement>(super::VERBATIM_KEY);
512        match required {
513            Some(req) if current.as_ref() != Some(&req) => {
514                let _ = state.set(super::VERBATIM_KEY, &req);
515            }
516            None if current.is_some() => {
517                state.remove(super::VERBATIM_KEY);
518            }
519            _ => {}
520        }
521    }
522
523    /// Wrap in a [`SharedFlowStack`] for shared ownership between the control
524    /// lane (which advances it) and runtime accessors (which snapshot it).
525    pub fn into_shared(self) -> SharedFlowStack {
526        Arc::new(parking_lot::Mutex::new(self))
527    }
528
529    /// The enforcement mode (shared by every layer).
530    pub fn mode(&self) -> Enforcement {
531        self.mode
532    }
533
534    /// The digressions this stack can enter, in trigger-priority order.
535    pub fn overlays(&self) -> &[Overlay] {
536        &self.overlays
537    }
538
539    /// Mutable access to the digressions, for connect-time merges.
540    pub fn overlays_mut(&mut self) -> &mut [Overlay] {
541        &mut self.overlays
542    }
543
544    /// The repair policies keyed by main-flow step.
545    pub fn repair_policies(&self) -> &BTreeMap<String, RepairPolicy> {
546        &self.repair
547    }
548
549    /// The main flow's monitor, whether or not it is currently driving.
550    pub fn main(&self) -> &FlowMonitor {
551        &self.main
552    }
553
554    /// The monitor currently driving — the active digression if any, else the
555    /// main flow.
556    pub fn current(&self) -> &FlowMonitor {
557        self.active.last().map_or(&self.main, |a| &a.monitor)
558    }
559
560    fn current_mut(&mut self) -> &mut FlowMonitor {
561        match self.active.last_mut() {
562            Some(a) => &mut a.monitor,
563            None => &mut self.main,
564        }
565    }
566
567    /// The name of the driving digression, if one is suspending the main flow.
568    pub fn active_overlay(&self) -> Option<&str> {
569        self.active.last().map(|a| a.name.as_str())
570    }
571
572    /// Every active digression, outermost first: a digression can itself be
573    /// interrupted by another, which then drives until it completes.
574    pub fn overlay_path(&self) -> Vec<&str> {
575        self.active.iter().map(|a| a.name.as_str()).collect()
576    }
577
578    /// Whether the conversation is finished (main complete, or a `Terminate`
579    /// digression ran).
580    pub fn is_complete(&self) -> bool {
581        self.terminated.is_some() || (self.active.is_empty() && self.main.is_complete())
582    }
583
584    /// Whether a `Terminate` digression ended the conversation. From then on
585    /// the stack governs nothing: no active steps or postures, every tool
586    /// denied. The runtime does not close the session by itself; the
587    /// application reads this (or [`TERMINATED_STATE_KEY`]) and hangs up.
588    pub fn is_terminated(&self) -> bool {
589        self.terminated.is_some()
590    }
591
592    /// Why a tool is denied once the conversation has ended.
593    fn termination_denial(&self) -> Option<String> {
594        self.terminated
595            .as_ref()
596            .map(|by| format!("the conversation has ended: digression `{by}` terminated it"))
597    }
598
599    /// Whether the active digression has run to completion and is being
600    /// projected for its closing turn.
601    fn active_is_closing(&self) -> bool {
602        self.active.last().is_some_and(|a| a.monitor.is_complete())
603    }
604
605    /// Index of the first overlay whose trigger holds against the main
606    /// context and that is not already on the active path (a digression
607    /// cannot interrupt itself).
608    fn triggered(&self, state: &State) -> Option<usize> {
609        self.overlays.iter().position(|ov| {
610            !self.active.iter().any(|a| a.name == ov.name) && self.main.eval(&ov.trigger, state)
611        })
612    }
613
614    /// Enter overlay `idx` on top of the active path, driving its first turn
615    /// so single-step overlays latch. If that completes it, it still stays
616    /// active for this turn's projection (see the type docs).
617    fn enter(&mut self, idx: usize, state: &State) {
618        let ov = &self.overlays[idx];
619        let mut monitor = FlowMonitor::new(ov.flow.clone(), self.mode);
620        monitor.on_turn(state);
621        self.active.push(ActiveOverlay {
622            name: ov.name.clone(),
623            monitor,
624            resume: ov.resume,
625        });
626    }
627
628    /// Raise the correction flag of every watched slot whose value changed
629    /// from one captured value to another since the last turn, clearing the
630    /// keys its rule names. A re-statement in different case, spacing or
631    /// punctuation is not a change (see
632    /// [`equivalent_values`](crate::state::equivalent_values)). The main
633    /// flow lowers the flags once it has advanced past them.
634    fn detect_corrections(&mut self, state: &State) {
635        if self.corrections.is_empty() {
636            return;
637        }
638        for (slot, clear) in &self.corrections {
639            let current = state.get_raw(slot);
640            let corrected = matches!(
641                (self.slot_values.get(slot), &current),
642                (Some(before), Some(now)) if !crate::state::equivalent_values(before, now)
643            );
644            if corrected {
645                let _ = state.set(correction_flag(slot), true);
646                for key in clear {
647                    state.remove(key);
648                }
649            }
650            match current {
651                Some(v) => {
652                    self.slot_values.insert(slot.clone(), v);
653                }
654                None => {
655                    self.slot_values.remove(slot);
656                }
657            }
658        }
659    }
660
661    /// Count a barge-in against every active main step and escalate those
662    /// whose policy's `escalate_after_interruptions` is reached. Called by
663    /// the control plane when the user interrupts the model.
664    pub fn on_interrupted(&mut self, state: &State) {
665        self.count_against_active(
666            state,
667            |p| p.escalate_after_interruptions,
668            |s| &mut s.interruptions,
669        );
670    }
671
672    /// Count a failed (or timed-out) tool call against every active main step
673    /// and escalate those whose policy's `escalate_after_tool_failures` is
674    /// reached.
675    pub fn on_tool_failed(&mut self, state: &State) {
676        self.count_against_active(
677            state,
678            |p| p.escalate_after_tool_failures,
679            |s| &mut s.tool_failures,
680        );
681    }
682
683    fn count_against_active(
684        &mut self,
685        state: &State,
686        threshold: impl Fn(&RepairPolicy) -> Option<u32>,
687        counters: impl Fn(&mut Self) -> &mut BTreeMap<String, u32>,
688    ) {
689        if self.terminated.is_some() || !self.active.is_empty() || self.repair.is_empty() {
690            return;
691        }
692        let steps: Vec<(String, u32)> = self
693            .main
694            .active_steps(state)
695            .iter()
696            .filter_map(|s| {
697                self.repair
698                    .get(&s.id)
699                    .and_then(&threshold)
700                    .map(|limit| (s.id.clone(), limit))
701            })
702            .collect();
703        for (step, limit) in steps {
704            let count = counters(self).entry(step.clone()).or_insert(0);
705            *count += 1;
706            if *count >= limit {
707                let _ = state.set(escalate_flag(&step), true);
708            }
709        }
710    }
711
712    /// Bump per-step active-turn counters for the main flow and raise repair
713    /// signals when thresholds are hit. Clears signals for steps that are no
714    /// longer active.
715    fn apply_repair(&mut self, state: &State, counted: bool) {
716        if self.repair.is_empty() {
717            return;
718        }
719        let active: BTreeSet<String> = self
720            .main
721            .active_steps(state)
722            .iter()
723            .map(|s| s.id.clone())
724            .collect();
725        let left: Vec<String> = self
726            .active_turns
727            .keys()
728            .filter(|k| !active.contains(*k))
729            .cloned()
730            .collect();
731        for step in left {
732            self.active_turns.remove(&step);
733            self.interruptions.remove(&step);
734            self.tool_failures.remove(&step);
735            let _ = state.set(reprompt_flag(&step), false);
736            // A step that completed *by escalating* must keep its escalate
737            // signal: the authoring layer lowers `escalate_to` into an edge
738            // gated on it, and gates are evaluated every turn, so clearing the
739            // flag here would drop the hand-off target out of the active set
740            // one turn after it was entered. Clear it only when the step left
741            // the active set without completing (its gate closed). Steps a
742            // `Constraint::Reset` un-latches are cleared in `advance_main`,
743            // before the re-latch can see the stale signal.
744            if !self.main.marking().done.contains(&step) {
745                let _ = state.set(escalate_flag(&step), false);
746            }
747        }
748        for step in &active {
749            let count = self.active_turns.entry(step.clone()).or_insert(0);
750            if counted {
751                *count += 1;
752            }
753            if let Some(rp) = self.repair.get(step) {
754                if *count >= rp.reprompt_after {
755                    let _ = state.set(reprompt_flag(step), true);
756                }
757                if *count >= rp.escalate_after {
758                    let _ = state.set(escalate_flag(step), true);
759                }
760            }
761        }
762    }
763
764    /// Forget everything the repair bookkeeping knows about `step`: its
765    /// active-turn count and both signals. Used when a step is un-latched (a
766    /// reset, or a restart of the whole main flow), because the lowered
767    /// `escalate_to` completion guard references the escalate signal and would
768    /// otherwise re-complete the step the moment it is re-latched.
769    fn clear_repair(&mut self, step: &str, state: &State) {
770        self.active_turns.remove(step);
771        self.interruptions.remove(step);
772        self.tool_failures.remove(step);
773        let _ = state.set(reprompt_flag(step), false);
774        let _ = state.set(escalate_flag(step), false);
775    }
776
777    /// Apply a closed digression's resume policy to the layer beneath it:
778    /// the next digression on the path, or the main flow.
779    fn apply_resume(&mut self, by: String, resume: Resume, state: &State) {
780        match resume {
781            // The suspended layer's marking was untouched — nothing to do.
782            Resume::Previous => {}
783            Resume::Restart => match self.active.last_mut() {
784                Some(beneath) => beneath.monitor.restart(),
785                None => {
786                    self.main.restart();
787                    // A fresh pass starts with no step already escalated.
788                    let steps: Vec<String> = self.repair.keys().cloned().collect();
789                    for step in steps {
790                        self.clear_repair(&step, state);
791                    }
792                }
793            },
794            Resume::Terminate => {
795                self.active.clear();
796                self.terminated = Some(by);
797            }
798        }
799    }
800
801    /// Advance the main flow one turn: resets first (shedding the repair
802    /// signals of any step they un-latch), then repair bookkeeping over the
803    /// pre-turn active set, then the re-latch.
804    fn advance_main(&mut self, state: &State, counted: bool) {
805        for step in self.main.begin_turn(state) {
806            self.clear_repair(&step, state);
807        }
808        // The main flow has now seen any correction raised since it last
809        // advanced (its reset edges fired above): lower the flags so the
810        // next correction is a fresh rising edge.
811        for slot in self.corrections.keys() {
812            let flag = correction_flag(slot);
813            if state.get::<bool>(&flag) == Some(true) {
814                let _ = state.set(&flag, false);
815            }
816        }
817        // Repair bookkeeping is based on the pre-turn active set so an
818        // escalation signal can take effect this turn.
819        self.apply_repair(state, counted);
820        self.main.relatch(state);
821    }
822
823    /// Advance one turn.
824    ///
825    /// A digression that completed on the previous turn has had its closing
826    /// turn projected; its resume policy applies now, and the turn then
827    /// proceeds as if the main flow had been driving all along (a new
828    /// digression may trigger, or the main flow advances). Otherwise: advance
829    /// the active digression, enter a triggered one (suspending the main
830    /// flow), or advance the main flow.
831    pub fn on_turn(&mut self, state: &State) {
832        self.on_turn_counted(state, true);
833    }
834
835    /// Advance one turn, saying whether it counts toward repair thresholds.
836    ///
837    /// A turn the caller had no part in, such as the greeting or the model
838    /// speaking a tool's result, is not a stall: with `counted` false the
839    /// flow advances as usual but no step's `reprompt_after` /
840    /// `escalate_after` count moves.
841    pub fn on_turn_counted(&mut self, state: &State, counted: bool) {
842        self.advance_turn(state, counted);
843        self.publish_timing(state);
844    }
845
846    fn advance_turn(&mut self, state: &State, counted: bool) {
847        if self.terminated.is_some() {
848            return;
849        }
850        // A correction can come while a digression drives; its flag stays
851        // raised until the main flow advances and sees it.
852        self.detect_corrections(state);
853        if self.active_is_closing() {
854            let closed = self.active.pop().expect("checked above");
855            self.apply_resume(closed.name, closed.resume, state);
856            if self.terminated.is_some() {
857                return;
858            }
859        }
860        // A triggered digression suspends whichever layer is driving, the
861        // main flow or another digression.
862        if let Some(idx) = self.triggered(state) {
863            self.enter(idx, state);
864            return;
865        }
866        match self.active.last_mut() {
867            Some(active) => active.monitor.on_turn(state),
868            None => self.advance_main(state, counted),
869        }
870    }
871
872    /// Record a successful tool call against the active layer. A no-op once the
873    /// conversation has been terminated: nothing is governing, so there is no
874    /// marking for the call to advance. (In `Enforce` the call is denied before
875    /// it runs; in `Observe` it runs but must not move a flow that has ended.)
876    ///
877    /// A tool can itself fire a reset (`reset(..).when(called_ok(..))`), so the
878    /// main layer sheds the repair signals of whatever that un-latches, exactly
879    /// as the main layer does at a turn boundary. Repair
880    /// is tracked for the main flow only, so a digression just delegates.
881    pub fn on_tool_ok(&mut self, tool: &str, state: &State) {
882        self.advance_tool_ok(tool, state);
883        self.publish_timing(state);
884    }
885
886    fn advance_tool_ok(&mut self, tool: &str, state: &State) {
887        if self.terminated.is_some() {
888            return;
889        }
890        match self.active.last_mut() {
891            Some(active) => active.monitor.on_tool_ok(tool, state),
892            None => {
893                for step in self.main.begin_tool_ok(tool, state) {
894                    self.clear_repair(&step, state);
895                }
896                // No `apply_repair` here: repair counters advance per turn, not
897                // per tool call.
898                self.main.relatch(state);
899            }
900        }
901    }
902
903    /// Observe a tool call for conformance against the active layer (see
904    /// [`FlowMonitor::observe_tool`]).
905    ///
906    /// After termination nothing is advanced, but in `Observe` the call is
907    /// still recorded as a deviation: a tool used after the conversation ended
908    /// is exactly what that mode exists to catch, and the monitor cannot see it
909    /// for itself because the denial is the stack's, not the flow's.
910    pub fn observe_tool(&mut self, tool: &str, ok: bool, state: &State) {
911        if let Some(denial) = self.termination_denial() {
912            if self.mode == Enforcement::Observe {
913                self.main.record_violation(tool, denial);
914            }
915            return;
916        }
917        // The conformance check is the *stack's*, not the active monitor's, and
918        // the call is recorded through `Self::on_tool_ok` so a tool-fired reset
919        // still sheds its repair signals.
920        if self.mode == Enforcement::Observe
921            && let Err(reason) = self.admits_tool(tool, state)
922        {
923            self.current_mut().record_violation(tool, reason);
924        }
925        if ok {
926            self.on_tool_ok(tool, state);
927        } else {
928            self.on_tool_failed(state);
929        }
930    }
931
932    /// Whether `tool` is admitted right now (delegates to the active layer).
933    /// Every tool is denied once the conversation has been terminated.
934    pub fn admits_tool(&self, tool: &str, state: &State) -> Result<(), String> {
935        if let Some(denial) = self.termination_denial() {
936            return Err(denial);
937        }
938        self.current().admits_tool(tool, state)
939    }
940
941    /// Whether the active layer would admit `tool` once its
942    /// `never(tool).until(..)` guards hold (see
943    /// [`FlowMonitor::offers_tool`]). Nothing is offered after termination.
944    pub fn offers_tool(&self, tool: &str, state: &State) -> bool {
945        self.termination_denial().is_none()
946            && (self.current().offers_tool(tool, state) || self.digression_offers(tool, state))
947    }
948
949    /// Whether a digression the caller's next words could open would admit
950    /// `tool` on entry: its trigger turns on a decision (see
951    /// [`Guard::possible`]) and a step it starts with allows the tool. Then
952    /// the model can call it in the turn the caller asks, and the gate enters
953    /// the digression first (see [`gate_scope`](Self::gate_scope)).
954    fn digression_offers(&self, tool: &str, state: &State) -> bool {
955        self.overlays.iter().any(|ov| {
956            !self.active.iter().any(|a| a.name == ov.name)
957                && self.main.possible(&ov.trigger, state).is_none()
958                && ov
959                    .flow
960                    .steps
961                    .iter()
962                    .filter(|s| s.after.is_empty())
963                    .any(|s| s.allow.iter().any(|a| a == tool))
964        })
965    }
966
967    /// The state keys read by the guards that refuse `tool` in the active
968    /// layer right now (see [`FlowMonitor::blocking_keys`]).
969    pub fn blocking_keys(&self, tool: &str, state: &State) -> std::collections::BTreeSet<String> {
970        if self.termination_denial().is_some() {
971            return std::collections::BTreeSet::new();
972        }
973        self.current().blocking_keys(tool, state)
974    }
975
976    /// Explain the active layer's control-plane state. After termination:
977    /// nothing active, nothing admitted, every tool blocked with the reason —
978    /// the conversation is over, so it is waiting for nothing. To ask instead
979    /// what it *never finished*, read [`main()`](Self::main): its marking and
980    /// `unmet_requirements()` are kept intact for exactly that audit.
981    pub fn explain(&self, state: &State) -> FlowExplanation {
982        let mut ex = self.current().explain(state);
983        if let Some(denial) = self.termination_denial() {
984            ex.active.clear();
985            ex.active_progress.clear();
986            ex.missing_requirements.clear();
987            for tool in std::mem::take(&mut ex.allowed_tools) {
988                ex.blocked_tools.insert(tool, denial.clone());
989            }
990            for reason in ex.blocked_tools.values_mut() {
991                *reason = denial.clone();
992            }
993        }
994        ex
995    }
996
997    /// Observe the current layer and whole-stack lifecycle together.
998    ///
999    /// Callers holding a shared stack lock get one consistent marking and
1000    /// overlay path. Guard evaluation reads `State`, which may change
1001    /// independently; this is not a transaction across all session state.
1002    pub fn snapshot(&self, state: &State) -> FlowSnapshot {
1003        FlowSnapshot {
1004            explanation: self.explain(state),
1005            done: self.marking().done.iter().cloned().collect(),
1006            complete: self.is_complete(),
1007            overlay_path: self.overlay_path().into_iter().map(str::to_owned).collect(),
1008            terminated: self.is_terminated(),
1009        }
1010    }
1011
1012    /// What a decision model should be asked now: the questions named by the
1013    /// driving layer's guards and by the triggers of the digressions that can
1014    /// still be entered, and the state keys the driving layer could act on
1015    /// (for decisions that write a key). Empty after termination.
1016    pub fn decision_scope(&self, state: &State) -> DecisionScope {
1017        if self.is_terminated() {
1018            return DecisionScope::default();
1019        }
1020        let current = self.current();
1021        let mut questions = current.flow().decisions();
1022        let mut reads = current.reads_now(state);
1023        for ov in &self.overlays {
1024            if !self.active.iter().any(|a| a.name == ov.name) {
1025                questions.extend(ov.trigger.decisions());
1026                reads.extend(ov.trigger.state_keys());
1027            }
1028        }
1029        DecisionScope { questions, reads }
1030    }
1031
1032    /// What decides whether `tools` are admitted now: the questions in the
1033    /// driving layer's `never(tool).until(..)` guards on them (a stage's
1034    /// commit), in the way of the [frontier](FlowMonitor::frontier_steps)
1035    /// steps that allow one, and in the triggers of the digressions that
1036    /// would admit one of them, with the state keys those guards read. A call
1037    /// no decision governs gets an empty scope, so it is not held up by a
1038    /// decision round.
1039    pub fn gate_scope(&self, tools: &[String], state: &State) -> DecisionScope {
1040        if self.is_terminated() {
1041            return DecisionScope::default();
1042        }
1043        let mut scope = DecisionScope::default();
1044        for c in &self.current().flow().constraints {
1045            if let crate::flow::Constraint::NeverUntil { tool, until } = c
1046                && tools.contains(tool)
1047            {
1048                scope.questions.extend(until.decisions());
1049                scope.reads.extend(until.state_keys());
1050            }
1051        }
1052        for tool in tools {
1053            let frontier = self.current().frontier_questions(tool, state);
1054            scope.questions.extend(frontier.questions);
1055            scope.reads.extend(frontier.reads);
1056        }
1057        for ov in &self.overlays {
1058            if !self.active.iter().any(|a| a.name == ov.name)
1059                && tools.iter().any(|t| ov.flow.tool_universe().contains(t))
1060            {
1061                scope.questions.extend(ov.trigger.decisions());
1062                scope.reads.extend(ov.trigger.state_keys());
1063            }
1064        }
1065        scope
1066    }
1067
1068    /// Apply fresh decisions mid-turn: enter a digression whose trigger now
1069    /// holds, or re-evaluate the driving layer. Counts no turn and advances
1070    /// no repair policy, so it can run before a tool call is admitted: a
1071    /// caller who asks for a person and a model that transfers in the same
1072    /// turn then see the transfer counted inside the handoff digression.
1073    pub fn on_decisions(&mut self, state: &State) {
1074        if self.is_terminated() {
1075            return;
1076        }
1077        if let Some(idx) = self.triggered(state) {
1078            self.enter(idx, state);
1079        } else {
1080            self.relatch(state);
1081        }
1082        self.publish_timing(state);
1083    }
1084
1085    /// Re-evaluate the current layer after directly setting state.
1086    ///
1087    /// This does not count a turn, trigger or resume digressions, or advance
1088    /// repair policies. Use [`on_turn`](Self::on_turn) for a user turn.
1089    pub fn relatch(&mut self, state: &State) {
1090        if self.is_terminated() {
1091            return;
1092        }
1093        match self.active.last_mut() {
1094            Some(active) => active.monitor.relatch(state),
1095            None => {
1096                for step in self.main.apply_resets(state) {
1097                    self.clear_repair(&step, state);
1098                }
1099                self.main.relatch(state);
1100            }
1101        }
1102    }
1103
1104    /// The active layer's marking (the last driving layer's, after
1105    /// termination — kept for audit).
1106    pub fn marking(&self) -> &Marking {
1107        self.current().marking()
1108    }
1109
1110    /// Steps of the active layer that are eligible but not yet done. Empty
1111    /// after termination.
1112    pub fn active_steps(&self, state: &State) -> Vec<&Step> {
1113        if self.terminated.is_some() {
1114            return Vec::new();
1115        }
1116        self.current().active_steps(state)
1117    }
1118
1119    /// Postures to project this turn: the active layer's active steps', or —
1120    /// on the turn a digression completes — its closing steps' (see
1121    /// [`FlowMonitor::closing_postures`]). Empty after termination.
1122    pub fn active_postures(&self, state: &State) -> Vec<String> {
1123        if self.terminated.is_some() {
1124            return Vec::new();
1125        }
1126        if self.active_is_closing() {
1127            return self.current().closing_postures();
1128        }
1129        self.current().active_postures(state)
1130    }
1131
1132    /// Grounding lines to project this turn, chosen like
1133    /// [`active_postures`](Self::active_postures).
1134    pub fn active_grounds(&self, state: &State) -> Vec<String> {
1135        if self.terminated.is_some() {
1136            return Vec::new();
1137        }
1138        if self.active_is_closing() {
1139            return self.current().closing_grounds(state);
1140        }
1141        self.current().active_grounds(state)
1142    }
1143
1144    /// The active layer's unmet requirements. Empty after termination.
1145    pub fn unmet_requirements(&self) -> Vec<String> {
1146        if self.terminated.is_some() {
1147            return Vec::new();
1148        }
1149        self.current().unmet_requirements()
1150    }
1151
1152    /// Steps of the active layer that became active since the last call.
1153    pub fn take_newly_active(&mut self, state: &State) -> Vec<String> {
1154        if self.terminated.is_some() {
1155            return Vec::new();
1156        }
1157        self.current_mut().take_newly_active(state)
1158    }
1159
1160    /// The `on_enter` action registered for a step of the active layer.
1161    pub fn enter_action(&self, step: &str) -> Option<&StepAction> {
1162        if self.terminated.is_some() {
1163            return None;
1164        }
1165        self.current().enter_action(step)
1166    }
1167
1168    /// Replace a main-flow step's posture. Returns `true` when the step exists.
1169    pub fn set_posture(&mut self, step_id: &str, posture: Option<String>) -> bool {
1170        self.main.set_posture(step_id, posture)
1171    }
1172
1173    /// Replace a main-flow step's grounding template. Returns `true` when the
1174    /// step exists.
1175    pub fn set_ground(&mut self, step_id: &str, ground: Option<String>) -> bool {
1176        self.main.set_ground(step_id, ground)
1177    }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183
1184    fn main_flow() -> CompiledFlow {
1185        Flow::new()
1186            .step("a")
1187            .done(Guard::is_true("a_done"))
1188            .step("b")
1189            .after("a")
1190            .terminal()
1191            .build()
1192            .expect("valid")
1193            .compile()
1194            .expect("compiles")
1195    }
1196
1197    fn faq_overlay() -> Overlay {
1198        let flow = Flow::new()
1199            .step("answer")
1200            .done(Guard::is_true("faq_answered"))
1201            .step("faq_end")
1202            .after("answer")
1203            .terminal()
1204            .require(["faq_end"])
1205            .build()
1206            .expect("valid")
1207            .compile()
1208            .expect("compiles");
1209        Overlay::new("faq", Guard::is_true("intent:faq"), flow, Resume::Previous)
1210    }
1211
1212    fn overlay(name: &str, trigger: &str, done_key: &str, resume: Resume) -> Overlay {
1213        let end = format!("{name}_end");
1214        let flow = Flow::new()
1215            .step(format!("{name}_step"))
1216            .done(Guard::is_true(done_key))
1217            .step(&end)
1218            .after(format!("{name}_step"))
1219            .terminal()
1220            .require([end.clone()])
1221            .build()
1222            .expect("valid")
1223            .compile()
1224            .expect("compiles");
1225        Overlay::new(name, Guard::is_true(trigger), flow, resume)
1226    }
1227
1228    #[test]
1229    fn snapshot_keeps_layer_progress_separate_from_stack_completion() {
1230        let state = State::new();
1231        let mut stack =
1232            FlowStack::new(main_flow(), Enforcement::Enforce).with_overlay(faq_overlay());
1233        let _ = state.set("a_done", true);
1234        stack.on_turn(&state);
1235        let _ = state.set("intent:faq", true);
1236        stack.on_turn(&state);
1237        let _ = state.set("faq_answered", true);
1238        stack.on_turn(&state);
1239
1240        let snapshot = stack.snapshot(&state);
1241        assert_eq!(snapshot.done, ["answer", "faq_end"]);
1242        assert_eq!(snapshot.overlay_path, ["faq"]);
1243        assert!(snapshot.explanation.missing_requirements.is_empty());
1244        assert!(!snapshot.complete, "a closing digression is still active");
1245        assert!(!snapshot.terminated);
1246        assert!(stack.main().marking().done.contains("a"));
1247
1248        let _ = state.set("intent:faq", false);
1249        stack.on_turn(&state);
1250        let snapshot = stack.snapshot(&state);
1251        assert_eq!(snapshot.done, ["a", "b"]);
1252        assert!(snapshot.overlay_path.is_empty());
1253        assert!(snapshot.complete);
1254    }
1255
1256    #[test]
1257    fn snapshot_reports_termination_without_inventing_main_progress() {
1258        let state = State::new();
1259        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce).with_overlay(overlay(
1260            "cancel",
1261            "cancel",
1262            "cancelled",
1263            Resume::Terminate,
1264        ));
1265        let _ = state.set("cancel", true);
1266        let _ = state.set("cancelled", true);
1267        stack.on_turn(&state);
1268        assert!(
1269            !stack.snapshot(&state).terminated,
1270            "closing turn comes first"
1271        );
1272        stack.on_turn(&state);
1273        let snapshot = stack.snapshot(&state);
1274        assert!(snapshot.terminated);
1275        assert!(snapshot.complete);
1276        assert!(snapshot.explanation.active.is_empty());
1277        assert!(snapshot.explanation.allowed_tools.is_empty());
1278        assert!(snapshot.done.is_empty(), "main task was never completed");
1279        let _ = state.set("a_done", true);
1280        stack.relatch(&state);
1281        assert!(stack.snapshot(&state).done.is_empty());
1282    }
1283
1284    #[test]
1285    fn a_digression_can_be_interrupted_by_another() {
1286        let state = State::new();
1287        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce)
1288            .with_overlay(overlay("faq", "intent:faq", "faq_done", Resume::Previous))
1289            .with_overlay(overlay(
1290                "clarify",
1291                "intent:clarify",
1292                "clarified",
1293                Resume::Previous,
1294            ));
1295
1296        let _ = state.set("intent:faq", true);
1297        stack.on_turn(&state);
1298        assert_eq!(stack.overlay_path(), ["faq"]);
1299
1300        // Mid-FAQ the user needs a clarification: it nests on top.
1301        let _ = state.set("intent:clarify", true);
1302        stack.on_turn(&state);
1303        assert_eq!(stack.overlay_path(), ["faq", "clarify"]);
1304        assert_eq!(stack.active_overlay(), Some("clarify"));
1305
1306        // The clarification completes (closing turn), then FAQ drives again.
1307        let _ = state.set("intent:clarify", false);
1308        let _ = state.set("clarified", true);
1309        stack.on_turn(&state);
1310        assert_eq!(stack.overlay_path(), ["faq", "clarify"], "closing turn");
1311        stack.on_turn(&state);
1312        assert_eq!(stack.overlay_path(), ["faq"]);
1313
1314        // FAQ completes; the main flow resumes where it was.
1315        let _ = state.set("intent:faq", false);
1316        let _ = state.set("faq_done", true);
1317        stack.on_turn(&state);
1318        stack.on_turn(&state);
1319        assert!(stack.overlay_path().is_empty());
1320        assert_eq!(stack.explain(&state).active, ["a"]);
1321    }
1322
1323    #[test]
1324    fn a_nested_terminate_ends_the_whole_conversation() {
1325        let state = State::new();
1326        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce)
1327            .with_overlay(overlay("faq", "intent:faq", "faq_done", Resume::Previous))
1328            .with_overlay(overlay(
1329                "cancel",
1330                "intent:cancel",
1331                "cancelled",
1332                Resume::Terminate,
1333            ));
1334        let _ = state.set("intent:faq", true);
1335        stack.on_turn(&state);
1336        let _ = state.set("intent:cancel", true);
1337        let _ = state.set("cancelled", true);
1338        stack.on_turn(&state);
1339        assert_eq!(stack.overlay_path(), ["faq", "cancel"]);
1340        stack.on_turn(&state);
1341        assert!(stack.is_terminated());
1342        assert!(stack.overlay_path().is_empty());
1343    }
1344
1345    #[test]
1346    fn a_digression_does_not_re_enter_itself() {
1347        let state = State::new();
1348        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce).with_overlay(overlay(
1349            "faq",
1350            "intent:faq",
1351            "faq_done",
1352            Resume::Previous,
1353        ));
1354        let _ = state.set("intent:faq", true);
1355        stack.on_turn(&state);
1356        stack.on_turn(&state);
1357        assert_eq!(
1358            stack.overlay_path(),
1359            ["faq"],
1360            "the trigger still holding does not stack it twice"
1361        );
1362    }
1363
1364    #[test]
1365    fn barge_ins_and_tool_failures_escalate_the_active_step() {
1366        let state = State::new();
1367        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce).with_repair(
1368            "a",
1369            RepairPolicy::new(10, 10)
1370                .escalate_after_interruptions(2)
1371                .escalate_after_tool_failures(3),
1372        );
1373        stack.on_turn(&state);
1374        stack.on_interrupted(&state);
1375        assert_eq!(state.get::<bool>(&escalate_flag("a")), None);
1376        stack.on_interrupted(&state);
1377        assert_eq!(state.get::<bool>(&escalate_flag("a")), Some(true));
1378
1379        let state = State::new();
1380        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce).with_repair(
1381            "a",
1382            RepairPolicy::new(10, 10).escalate_after_tool_failures(2),
1383        );
1384        stack.on_turn(&state);
1385        stack.observe_tool("lookup", false, &state);
1386        stack.observe_tool("lookup", false, &state);
1387        assert_eq!(state.get::<bool>(&escalate_flag("a")), Some(true));
1388    }
1389
1390    #[test]
1391    fn a_corrected_slot_raises_its_flag_once_and_clears_its_keys() {
1392        let state = State::new();
1393        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce)
1394            .with_correction("party_size", ["confirmed".to_string()]);
1395        let _ = state.set("party_size", 4);
1396        let _ = state.set("confirmed", true);
1397        stack.on_turn(&state);
1398        assert_eq!(
1399            state.get::<bool>(&correction_flag("party_size")),
1400            None,
1401            "first value is not a correction"
1402        );
1403
1404        let _ = state.set("party_size", 5);
1405        stack.detect_corrections(&state);
1406        assert_eq!(
1407            state.get::<bool>(&correction_flag("party_size")),
1408            Some(true)
1409        );
1410        assert_eq!(
1411            state.get::<bool>("confirmed"),
1412            None,
1413            "the confirmation is cleared"
1414        );
1415
1416        // The main flow advances past it: the flag drops for the next edge.
1417        stack.on_turn(&state);
1418        assert_eq!(
1419            state.get::<bool>(&correction_flag("party_size")),
1420            Some(false)
1421        );
1422    }
1423
1424    #[test]
1425    fn a_restated_slot_is_not_a_correction() {
1426        // An extractor re-reads the transcript each turn and may re-state a
1427        // value in different case or spacing; that must not clear the yes.
1428        let state = State::new();
1429        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce)
1430            .with_correction("slot", ["confirmed".to_string()]);
1431        let _ = state.set("slot", "tomorrow at 7 pm");
1432        stack.on_turn(&state);
1433        let _ = state.set("confirmed", true);
1434        let _ = state.set("slot", "Tomorrow at 7 PM.");
1435        stack.detect_corrections(&state);
1436        assert_eq!(state.get::<bool>(&correction_flag("slot")), None);
1437        assert_eq!(state.get::<bool>("confirmed"), Some(true));
1438
1439        let _ = state.set("slot", "tomorrow at 8 pm");
1440        stack.detect_corrections(&state);
1441        assert_eq!(state.get::<bool>(&correction_flag("slot")), Some(true));
1442        assert_eq!(state.get::<bool>("confirmed"), None);
1443    }
1444
1445    #[test]
1446    fn timing_follows_the_active_step_and_digression() {
1447        use std::time::Duration;
1448        let state = State::new();
1449        let a = VoiceTiming::new().reprompt_after(Duration::from_secs(6));
1450        let answer = VoiceTiming::new().uninterruptible();
1451        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce)
1452            .with_overlay(faq_overlay())
1453            .with_timing("a", a.clone())
1454            .with_timing("answer", answer.clone());
1455
1456        stack.publish_timing(&state);
1457        assert_eq!(state.get::<VoiceTiming>(VOICE_TIMING_KEY), Some(a.clone()));
1458
1459        // A digression takes over: its step's timing applies.
1460        let _ = state.set("intent:faq", true);
1461        stack.on_turn(&state);
1462        assert_eq!(state.get::<VoiceTiming>(VOICE_TIMING_KEY), Some(answer));
1463
1464        // Back in the main flow, past `a`: `b` has no timing, so none applies.
1465        let _ = state.set("intent:faq", false);
1466        let _ = state.set("faq_answered", true);
1467        stack.on_turn(&state);
1468        stack.on_turn(&state);
1469        assert_eq!(state.get::<VoiceTiming>(VOICE_TIMING_KEY), Some(a));
1470        let _ = state.set("a_done", true);
1471        stack.on_turn(&state);
1472        assert_eq!(state.get::<VoiceTiming>(VOICE_TIMING_KEY), None);
1473    }
1474
1475    #[test]
1476    fn bare_stack_behaves_like_its_monitor() {
1477        let state = State::new();
1478        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce);
1479        let mut mon = FlowMonitor::compiled(main_flow(), Enforcement::Enforce);
1480        for turn in 0..3 {
1481            if turn == 1 {
1482                let _ = state.set("a_done", true);
1483            }
1484            stack.on_turn(&state);
1485            mon.on_turn(&state);
1486            assert_eq!(stack.explain(&state).active, mon.explain(&state).active);
1487            assert_eq!(stack.marking().done, mon.marking().done);
1488        }
1489        assert!(stack.is_complete());
1490    }
1491
1492    #[test]
1493    fn overlay_suspends_main_then_resumes_previous() {
1494        let state = State::new();
1495        let mut stack =
1496            FlowStack::new(main_flow(), Enforcement::Enforce).with_overlay(faq_overlay());
1497
1498        assert!(stack.explain(&state).active.contains(&"a".to_string()));
1499        assert!(stack.active_overlay().is_none());
1500
1501        let _ = state.set("intent:faq", true);
1502        stack.on_turn(&state);
1503        assert_eq!(stack.active_overlay(), Some("faq"));
1504        assert!(stack.explain(&state).active.contains(&"answer".to_string()));
1505
1506        // The turn the digression completes it is still the projected layer
1507        // (its closing turn); the main flow is untouched underneath.
1508        let _ = state.set("faq_answered", true);
1509        let _ = state.set("intent:faq", false);
1510        stack.on_turn(&state);
1511        assert_eq!(stack.active_overlay(), Some("faq"));
1512        assert!(stack.explain(&state).active.is_empty());
1513        assert!(stack.main().marking().done.is_empty());
1514
1515        // Next boundary: resumed exactly where it was, and that same turn
1516        // advances the main flow.
1517        let _ = state.set("a_done", true);
1518        stack.on_turn(&state);
1519        assert!(stack.active_overlay().is_none());
1520        assert!(stack.main().marking().done.contains("a"));
1521        assert!(stack.explain(&state).active.is_empty());
1522        assert!(stack.is_complete());
1523    }
1524
1525    /// A digression that completes on its entry turn (the shape the safety
1526    /// hand-off policy lowers to: one terminal stage) must still be seen: it
1527    /// is the projected layer for that turn, its closing instruction is what
1528    /// the model is told, and the main flow's tools are not admitted.
1529    #[test]
1530    fn entry_complete_digression_is_projected_before_it_resumes() {
1531        let state = State::new();
1532        let main = Flow::new()
1533            .step("a")
1534            .allow(["main_tool"])
1535            .posture("MAIN")
1536            .done(Guard::is_true("a_done"))
1537            .step("b")
1538            .after("a")
1539            .terminal()
1540            .build()
1541            .expect("valid")
1542            .compile()
1543            .expect("compiles");
1544        // The digression governs its closing turn, so what it says about tools
1545        // is what holds: this one locks the main flow's tool outright.
1546        let handoff = Flow::new()
1547            .step("handoff")
1548            .posture("HAND OFF NOW")
1549            .terminal()
1550            .require(["handoff"])
1551            .never("main_tool")
1552            .until(Guard::is_true("human_joined"))
1553            .build()
1554            .expect("valid")
1555            .compile()
1556            .expect("compiles");
1557        let mut stack = FlowStack::new(main, Enforcement::Enforce).with_overlay(Overlay::new(
1558            "safety",
1559            Guard::is_true("intent:abuse"),
1560            handoff,
1561            Resume::Previous,
1562        ));
1563        assert_eq!(stack.active_postures(&state), ["MAIN"]);
1564
1565        let _ = state.set("intent:abuse", true);
1566        stack.on_turn(&state);
1567        assert_eq!(stack.active_overlay(), Some("safety"));
1568        assert_eq!(stack.active_postures(&state), ["HAND OFF NOW"]);
1569        // Complete, so nothing is *active* — but it is still the governing layer.
1570        assert!(stack.active_steps(&state).is_empty());
1571        assert!(stack.admits_tool("main_tool", &state).is_err());
1572        assert!(!stack.is_complete());
1573
1574        let _ = state.set("intent:abuse", false);
1575        stack.on_turn(&state);
1576        assert!(stack.active_overlay().is_none());
1577        assert_eq!(stack.active_postures(&state), ["MAIN"]);
1578        assert!(stack.admits_tool("main_tool", &state).is_ok());
1579    }
1580
1581    #[test]
1582    fn restart_resets_marking_but_not_state() {
1583        let state = State::new();
1584        let cancel = {
1585            let flow = Flow::new()
1586                .step("confirm")
1587                .done(Guard::is_true("confirmed"))
1588                .step("cancel_end")
1589                .after("confirm")
1590                .terminal()
1591                .require(["cancel_end"])
1592                .build()
1593                .expect("valid")
1594                .compile()
1595                .expect("compiles");
1596            Overlay::new(
1597                "cancel",
1598                Guard::is_true("intent:cancel"),
1599                flow,
1600                Resume::Restart,
1601            )
1602        };
1603        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce).with_overlay(cancel);
1604
1605        let _ = state.set("a_done", true);
1606        stack.on_turn(&state);
1607        assert!(stack.main().marking().done.contains("a"));
1608
1609        let _ = state.set("intent:cancel", true);
1610        stack.on_turn(&state);
1611        assert_eq!(stack.active_overlay(), Some("cancel"));
1612        let _ = state.set("confirmed", true);
1613        let _ = state.set("intent:cancel", false);
1614        stack.on_turn(&state); // closing turn
1615        assert_eq!(stack.active_overlay(), Some("cancel"));
1616        assert!(stack.main().marking().done.contains("a"));
1617
1618        // Next boundary: the monitor restarts and re-latches in the same turn.
1619        // State is untouched, so `a` completes again from the same facts.
1620        stack.on_turn(&state);
1621        assert!(stack.active_overlay().is_none());
1622        assert_eq!(state.get::<bool>("a_done"), Some(true));
1623        assert!(stack.main().marking().done.contains("a"));
1624        assert_eq!(stack.main().marking().turns, 1);
1625    }
1626
1627    /// A restart is a fresh pass: a step that had escalated must not start
1628    /// the new pass already escalated. The lowered completion guard references
1629    /// the escalate signal, so a stale signal would complete it on re-latch.
1630    #[test]
1631    fn restart_clears_repair_signals() {
1632        let state = State::new();
1633        let main = escalating_flow();
1634        let cancel = {
1635            let flow = Flow::new()
1636                .step("bye")
1637                .terminal()
1638                .require(["bye"])
1639                .build()
1640                .expect("valid")
1641                .compile()
1642                .expect("compiles");
1643            Overlay::new(
1644                "cancel",
1645                Guard::is_true("intent:cancel"),
1646                flow,
1647                Resume::Restart,
1648            )
1649        };
1650        let mut stack = FlowStack::new(main, Enforcement::Enforce)
1651            .with_overlay(cancel)
1652            .with_repair("collect", RepairPolicy::new(1, 2).escalate_to("handoff"));
1653        stack.on_turn(&state);
1654        stack.on_turn(&state); // escalated: collect done, handoff active
1655        assert_eq!(stack.explain(&state).active, ["handoff"]);
1656
1657        let _ = state.set("intent:cancel", true);
1658        stack.on_turn(&state); // enters and completes the digression
1659        let _ = state.set("intent:cancel", false);
1660        stack.on_turn(&state); // restart + re-latch
1661        assert!(stack.active_overlay().is_none());
1662        assert_eq!(stack.explain(&state).active, ["collect"]);
1663        assert_eq!(state.get::<bool>(&escalate_flag("collect")), Some(false));
1664        // And it can escalate again, from a fresh count.
1665        stack.on_turn(&state);
1666        assert_eq!(stack.explain(&state).active, ["handoff"]);
1667    }
1668
1669    /// A `Terminate` digression is projected for its closing turn (the model is
1670    /// told to say goodbye), and from the next boundary the stack governs
1671    /// nothing: no steps, no postures, every tool denied, further turns no-ops.
1672    #[test]
1673    fn terminate_ends_the_conversation() {
1674        let state = State::new();
1675        let main = Flow::new()
1676            .step("a")
1677            .allow(["main_tool"])
1678            .posture("MAIN")
1679            .done(Guard::is_true("a_done"))
1680            .step("b")
1681            .after("a")
1682            .terminal()
1683            .require(["b"])
1684            .build()
1685            .expect("valid")
1686            .compile()
1687            .expect("compiles");
1688        let bye = {
1689            let flow = Flow::new()
1690                .step("bye")
1691                .posture("SAY GOODBYE")
1692                .terminal()
1693                .require(["bye"])
1694                .build()
1695                .expect("valid")
1696                .compile()
1697                .expect("compiles");
1698            Overlay::new("bye", Guard::is_true("intent:bye"), flow, Resume::Terminate)
1699        };
1700        let mut stack = FlowStack::new(main, Enforcement::Enforce).with_overlay(bye);
1701        let _ = state.set("intent:bye", true);
1702        stack.on_turn(&state);
1703        assert_eq!(stack.active_overlay(), Some("bye"));
1704        assert_eq!(stack.active_postures(&state), ["SAY GOODBYE"]);
1705        assert!(!stack.is_terminated());
1706        assert!(!stack.is_complete());
1707
1708        stack.on_turn(&state);
1709        assert!(stack.is_terminated());
1710        assert!(stack.is_complete());
1711        assert!(stack.active_overlay().is_none());
1712        assert!(stack.active_steps(&state).is_empty());
1713        assert!(stack.active_postures(&state).is_empty());
1714        assert!(stack.unmet_requirements().is_empty());
1715        let denied = stack.admits_tool("main_tool", &state).unwrap_err();
1716        assert!(denied.contains("terminated"), "{denied}");
1717        assert!(denied.contains("bye"), "{denied}");
1718        let ex = stack.explain(&state);
1719        assert!(ex.active.is_empty());
1720        assert!(ex.allowed_tools.is_empty());
1721        assert_eq!(ex.blocked_tools.get("main_tool"), Some(&denied));
1722
1723        // Later turns change nothing, whatever state says — and neither does a
1724        // tool that slipped through (only possible in `Observe`): a flow that
1725        // has ended must not keep advancing.
1726        let _ = state.set("a_done", true);
1727        stack.on_turn(&state);
1728        assert!(stack.main().marking().done.is_empty());
1729        stack.on_tool_ok("main_tool", &state);
1730        stack.observe_tool("main_tool", true, &state);
1731        assert!(stack.main().marking().done.is_empty());
1732        assert!(stack.admits_tool("main_tool", &state).is_err());
1733    }
1734
1735    /// `Observe` exists to record what a session did wrong without blocking it.
1736    /// A tool called after the conversation ended is exactly that, and the
1737    /// monitor cannot see it — the denial belongs to the stack — so the stack
1738    /// records it. The marking still does not move.
1739    #[test]
1740    fn observe_mode_records_a_tool_used_after_termination() {
1741        let state = State::new();
1742        let bye = {
1743            let flow = Flow::new()
1744                .step("bye")
1745                .terminal()
1746                .require(["bye"])
1747                .build()
1748                .expect("valid")
1749                .compile()
1750                .expect("compiles");
1751            Overlay::new("bye", Guard::is_true("intent:bye"), flow, Resume::Terminate)
1752        };
1753        let mut stack = FlowStack::new(main_flow(), Enforcement::Observe).with_overlay(bye);
1754        let _ = state.set("intent:bye", true);
1755        stack.on_turn(&state); // closing turn
1756        stack.on_turn(&state); // terminated
1757        assert!(stack.is_terminated());
1758
1759        let _ = state.set("a_done", true);
1760        stack.observe_tool("anything", true, &state);
1761        let violations = stack.main().violations();
1762        assert_eq!(violations.len(), 1, "{violations:?}");
1763        assert_eq!(violations[0].subject, "anything");
1764        assert!(
1765            violations[0].reason.contains("terminated"),
1766            "{violations:?}"
1767        );
1768        // Recorded, not acted on: the ended flow did not advance.
1769        assert!(stack.main().marking().done.is_empty());
1770    }
1771
1772    #[test]
1773    fn repair_raises_then_clears_signals() {
1774        let state = State::new();
1775        let mut stack = FlowStack::new(main_flow(), Enforcement::Enforce)
1776            .with_repair("a", RepairPolicy::new(1, 3));
1777        stack.on_turn(&state);
1778        assert_eq!(state.get::<bool>("repair:a:reprompt"), Some(true));
1779        assert_eq!(state.get::<bool>("repair:a:escalate"), None);
1780        let _ = state.set("a_done", true);
1781        stack.on_turn(&state);
1782        stack.on_turn(&state);
1783        assert_eq!(state.get::<bool>("repair:a:reprompt"), Some(false));
1784    }
1785
1786    /// The shape the conversation compiler lowers for `escalate_to`: `collect`
1787    /// completes on `info` or on its escalate flag; `handoff` follows, gated on
1788    /// the flag; `done` follows `collect` only when it completed properly.
1789    fn escalating_flow() -> CompiledFlow {
1790        Flow::new()
1791            .step("collect")
1792            .done(Guard::any(vec![
1793                Guard::is_true("info"),
1794                Guard::is_true(escalate_flag("collect")),
1795            ]))
1796            .step("handoff")
1797            .after("collect")
1798            .gate(Guard::is_true(escalate_flag("collect")))
1799            .done(Guard::is_true("handoff_complete"))
1800            .step("done")
1801            .after_when("collect", Guard::is_true("info"))
1802            .terminal()
1803            .build()
1804            .expect("valid")
1805            .compile()
1806            .expect("compiles")
1807    }
1808
1809    /// `escalate_to` lowers to an edge gated on the escalate signal. Gates are
1810    /// evaluated every turn, so the signal must stay latched once the step
1811    /// has completed by escalating, or the hand-off target is active for one
1812    /// turn and then vanishes.
1813    #[test]
1814    fn escalation_edge_stays_open_after_the_step_completes() {
1815        let state = State::new();
1816        let mut stack = FlowStack::new(escalating_flow(), Enforcement::Enforce)
1817            .with_repair("collect", RepairPolicy::new(1, 2).escalate_to("handoff"));
1818
1819        stack.on_turn(&state); // active 1 turn: reprompt
1820        stack.on_turn(&state); // active 2 turns: escalate -> collect done -> handoff
1821        assert!(
1822            stack
1823                .explain(&state)
1824                .active
1825                .contains(&"handoff".to_string())
1826        );
1827        // The turn after, collect has left the active set; handoff must stay.
1828        stack.on_turn(&state);
1829        assert!(
1830            stack
1831                .explain(&state)
1832                .active
1833                .contains(&"handoff".to_string())
1834        );
1835        assert_eq!(state.get::<bool>(&reprompt_flag("collect")), Some(false));
1836        assert_eq!(state.get::<bool>(&escalate_flag("collect")), Some(true));
1837    }
1838
1839    /// A `Constraint::Reset` that un-latches an escalated step must also shed
1840    /// its latched escalate signal, before the re-latch: the lowered
1841    /// completion guard references that signal and would complete the step
1842    /// again on the spot, routing straight back to the hand-off target.
1843    #[test]
1844    fn reset_clears_a_latched_escalation() {
1845        let state = State::new();
1846        let main = Flow::new()
1847            .step("collect")
1848            .done(Guard::any(vec![
1849                Guard::is_true("info"),
1850                Guard::is_true(escalate_flag("collect")),
1851            ]))
1852            .step("handoff")
1853            .after("collect")
1854            .gate(Guard::is_true(escalate_flag("collect")))
1855            .done(Guard::is_true("handoff_complete"))
1856            .step("done")
1857            .after_when("collect", Guard::is_true("info"))
1858            .terminal()
1859            .reset(["collect"])
1860            .when(Guard::is_true("retry"))
1861            .build()
1862            .expect("valid")
1863            .compile()
1864            .expect("compiles");
1865        let mut stack = FlowStack::new(main, Enforcement::Enforce)
1866            .with_repair("collect", RepairPolicy::new(1, 2).escalate_to("handoff"));
1867
1868        stack.on_turn(&state);
1869        stack.on_turn(&state); // escalated
1870        assert_eq!(stack.explain(&state).active, ["handoff"]);
1871
1872        // The reset fires: collect is back, handoff is gone, the count restarts.
1873        let _ = state.set("retry", true);
1874        stack.on_turn(&state);
1875        assert_eq!(stack.explain(&state).active, ["collect"]);
1876        assert_eq!(state.get::<bool>(&escalate_flag("collect")), Some(false));
1877        assert_eq!(state.get::<bool>(&reprompt_flag("collect")), Some(true));
1878
1879        // A second stall escalates again from that fresh count.
1880        stack.on_turn(&state);
1881        assert_eq!(stack.explain(&state).active, ["handoff"]);
1882        assert_eq!(state.get::<bool>(&escalate_flag("collect")), Some(true));
1883    }
1884
1885    #[test]
1886    fn state_only_reset_clears_repair_without_counting_a_turn() {
1887        let state = State::new();
1888        let main = Flow::new()
1889            .step("collect")
1890            .done(Guard::is_true(escalate_flag("collect")))
1891            .reset(["collect"])
1892            .when(Guard::is_true("retry"))
1893            .build()
1894            .unwrap()
1895            .compile()
1896            .unwrap();
1897        let mut stack = FlowStack::new(main, Enforcement::Enforce)
1898            .with_repair("collect", RepairPolicy::new(1, 2));
1899        stack.on_turn(&state);
1900        stack.on_turn(&state);
1901        assert!(stack.marking().done.contains("collect"));
1902
1903        let _ = state.set("retry", true);
1904        stack.relatch(&state);
1905        assert_eq!(stack.marking().turns, 2);
1906        assert!(!stack.marking().done.contains("collect"));
1907        assert_eq!(state.get::<bool>(&escalate_flag("collect")), Some(false));
1908        assert_eq!(state.get::<bool>(&reprompt_flag("collect")), Some(false));
1909        stack.on_turn(&state);
1910        assert!(!stack.marking().done.contains("collect"));
1911        stack.on_turn(&state);
1912        assert!(stack.marking().done.contains("collect"));
1913    }
1914
1915    /// A reset can be gated on a *tool*, not just a state flag — `reset(..)
1916    /// .when(called_ok("start_over"))` is the natural "start over" button. That
1917    /// edge fires inside `on_tool_ok`, not at a turn boundary, so the repair
1918    /// signals must be shed there too, or the escalated step re-completes on its
1919    /// own stale flag exactly as it would at a turn boundary.
1920    #[test]
1921    fn a_tool_triggered_reset_clears_a_latched_escalation() {
1922        let state = State::new();
1923        let main = Flow::new()
1924            .step("collect")
1925            .allow(["start_over"])
1926            .done(Guard::any(vec![
1927                Guard::is_true("info"),
1928                Guard::is_true(escalate_flag("collect")),
1929            ]))
1930            .step("handoff")
1931            .after("collect")
1932            .gate(Guard::is_true(escalate_flag("collect")))
1933            .done(Guard::is_true("handoff_complete"))
1934            .step("done")
1935            .after_when("collect", Guard::is_true("info"))
1936            .terminal()
1937            .reset(["collect"])
1938            .when(Guard::called_ok("start_over"))
1939            .build()
1940            .expect("valid")
1941            .compile()
1942            .expect("compiles");
1943        let mut stack = FlowStack::new(main, Enforcement::Enforce)
1944            .with_repair("collect", RepairPolicy::new(1, 2).escalate_to("handoff"));
1945
1946        stack.on_turn(&state);
1947        stack.on_turn(&state); // escalated
1948        assert_eq!(stack.explain(&state).active, ["handoff"]);
1949
1950        // The caller hits "start over" mid-turn.
1951        stack.on_tool_ok("start_over", &state);
1952        assert_eq!(state.get::<bool>(&escalate_flag("collect")), Some(false));
1953        assert_eq!(stack.explain(&state).active, ["collect"]);
1954
1955        // And it stays reset across the next boundary rather than snapping back.
1956        stack.on_turn(&state);
1957        assert_eq!(stack.explain(&state).active, ["collect"]);
1958    }
1959
1960    #[test]
1961    fn tool_admission_follows_the_active_layer() {
1962        let state = State::new();
1963        let main = Flow::new()
1964            .step("a")
1965            .allow(["main_tool"])
1966            .done(Guard::is_true("a_done"))
1967            .step("b")
1968            .after("a")
1969            .terminal()
1970            .build()
1971            .expect("valid")
1972            .compile()
1973            .expect("compiles");
1974        let ov = {
1975            let flow = Flow::new()
1976                .step("answer")
1977                .allow(["faq_tool"])
1978                .done(Guard::is_true("faq_answered"))
1979                .step("faq_end")
1980                .after("answer")
1981                .terminal()
1982                .require(["faq_end"])
1983                .build()
1984                .expect("valid")
1985                .compile()
1986                .expect("compiles");
1987            Overlay::new("faq", Guard::is_true("intent:faq"), flow, Resume::Previous)
1988        };
1989        let mut stack = FlowStack::new(main, Enforcement::Enforce).with_overlay(ov);
1990        assert!(stack.admits_tool("main_tool", &state).is_ok());
1991        assert!(stack.admits_tool("faq_tool", &state).is_err());
1992        let _ = state.set("intent:faq", true);
1993        stack.on_turn(&state);
1994        assert!(stack.admits_tool("faq_tool", &state).is_ok());
1995        assert!(stack.admits_tool("main_tool", &state).is_err());
1996    }
1997
1998    fn answer(state: &State, question: &str, outcome: crate::flow::Outcome, turn: &str) {
1999        let _ = state.set(crate::flow::DECISION_TURN_KEY, turn);
2000        let _ = state.set(
2001            crate::flow::decision_key(question),
2002            serde_json::to_value(crate::flow::DecisionRecord {
2003                outcome,
2004                value: serde_json::Value::Null,
2005                confidence: None,
2006                turn: turn.into(),
2007            })
2008            .unwrap(),
2009        );
2010    }
2011
2012    fn booking_with_handoff() -> FlowStack {
2013        let main = Flow::new()
2014            .step("collect")
2015            .done(Guard::captured(["slot"]))
2016            .step("confirm")
2017            .after("collect")
2018            .allow(["book"])
2019            .commit("book", Guard::decided("confirmed"))
2020            .done(Guard::called_ok("book"))
2021            .step("end")
2022            .after("confirm")
2023            .terminal()
2024            .build()
2025            .expect("valid")
2026            .compile()
2027            .expect("compiles");
2028        let handoff = Flow::new()
2029            .step("transfer")
2030            .allow(["transfer"])
2031            .done(Guard::called_ok("transfer"))
2032            .build()
2033            .expect("valid")
2034            .compile()
2035            .expect("compiles");
2036        FlowStack::new(main, Enforcement::Enforce).with_overlay(Overlay::new(
2037            "handoff",
2038            Guard::decided("wants_person"),
2039            handoff,
2040            Resume::Terminate,
2041        ))
2042    }
2043
2044    /// `ask` leads to `book` when the caller wants to book, and to `faq`
2045    /// behind a flag no decision sets.
2046    fn routed() -> FlowStack {
2047        let main = Flow::new()
2048            .step("ask")
2049            .allow(["lookup"])
2050            .done(Guard::any([
2051                Guard::decided("wants_booking"),
2052                Guard::is_true("faq_flag"),
2053            ]))
2054            .step("book")
2055            .after_when("ask", Guard::decided("wants_booking"))
2056            .allow(["book_table"])
2057            .done(Guard::called_ok("book_table"))
2058            .step("faq")
2059            .after_when("ask", Guard::is_true("faq_flag"))
2060            .allow(["answer"])
2061            .done(Guard::called_ok("answer"))
2062            .build()
2063            .expect("valid")
2064            .compile()
2065            .expect("compiles");
2066        FlowStack::new(main, Enforcement::Enforce)
2067    }
2068
2069    #[test]
2070    fn a_step_one_decision_away_is_offered_and_decided_at_the_gate() {
2071        let mut stack = routed();
2072        let state = State::new();
2073        stack.on_turn(&state);
2074        let monitor = stack.current();
2075        let frontier: Vec<_> = monitor
2076            .frontier_steps(&state)
2077            .iter()
2078            .map(|s| s.id.clone())
2079            .collect();
2080        assert_eq!(frontier, ["book"], "faq waits on a flag, not a decision");
2081        assert!(stack.offers_tool("lookup", &state), "the active step's");
2082        assert!(stack.offers_tool("book_table", &state), "one decision away");
2083        assert!(!stack.offers_tool("answer", &state));
2084        assert!(
2085            stack.admits_tool("book_table", &state).is_err(),
2086            "offered is not admitted"
2087        );
2088        assert_eq!(
2089            stack
2090                .gate_scope(&["book_table".to_string()], &state)
2091                .questions,
2092            ["wants_booking".to_string()].into()
2093        );
2094
2095        // The gate asks about the turn in progress; a yes opens the step.
2096        answer(&state, "wants_booking", crate::flow::Outcome::Yes, "2:a");
2097        stack.on_decisions(&state);
2098        assert!(stack.admits_tool("book_table", &state).is_ok());
2099        assert!(
2100            !stack.offers_tool("lookup", &state),
2101            "ask is done, and its tool goes with it"
2102        );
2103    }
2104
2105    #[test]
2106    fn a_no_keeps_the_step_closed() {
2107        let mut stack = routed();
2108        let state = State::new();
2109        stack.on_turn(&state);
2110        answer(&state, "wants_booking", crate::flow::Outcome::No, "2:a");
2111        stack.on_decisions(&state);
2112        assert!(stack.admits_tool("book_table", &state).is_err());
2113        // Still one decision away: the next turn may say yes.
2114        assert!(stack.offers_tool("book_table", &state));
2115    }
2116
2117    /// A main step that allows only `lookup`, with a handoff digression
2118    /// behind `trigger`.
2119    fn talk_with_handoff(trigger: Guard) -> FlowStack {
2120        let flow = |step: &str, tool: &str| {
2121            Flow::new()
2122                .step(step)
2123                .allow([tool])
2124                .done(Guard::called_ok(tool))
2125                .build()
2126                .expect("valid")
2127                .compile()
2128                .expect("compiles")
2129        };
2130        FlowStack::new(flow("talk", "lookup"), Enforcement::Enforce).with_overlay(Overlay::new(
2131            "handoff",
2132            trigger,
2133            flow("transfer", "transfer"),
2134            Resume::Terminate,
2135        ))
2136    }
2137
2138    #[test]
2139    fn a_digression_a_decision_opens_offers_its_entry_tools() {
2140        let state = State::new();
2141        let mut stack = talk_with_handoff(Guard::decided("wants_person"));
2142        stack.on_turn(&state);
2143        assert!(stack.offers_tool("transfer", &state));
2144        assert!(stack.admits_tool("transfer", &state).is_err());
2145        assert_eq!(
2146            stack
2147                .gate_scope(&["transfer".to_string()], &state)
2148                .questions,
2149            ["wants_person".to_string()].into()
2150        );
2151        answer(&state, "wants_person", crate::flow::Outcome::Yes, "2:a");
2152        stack.on_decisions(&state);
2153        assert!(stack.admits_tool("transfer", &state).is_ok());
2154
2155        // A trigger no decision sets is not offered ahead.
2156        let mut flagged = talk_with_handoff(Guard::is_true("intent:human_agent"));
2157        flagged.on_turn(&State::new());
2158        assert!(!flagged.offers_tool("transfer", &State::new()));
2159    }
2160
2161    #[test]
2162    fn the_decision_scope_names_guards_and_triggers_and_what_active_steps_read() {
2163        let stack = booking_with_handoff();
2164        let state = State::new();
2165        let scope = stack.decision_scope(&state);
2166        assert_eq!(
2167            scope.questions,
2168            ["confirmed", "wants_person"].map(String::from).into()
2169        );
2170        assert!(
2171            scope.reads.contains("slot"),
2172            "collect reads the slot: {scope:?}"
2173        );
2174    }
2175
2176    #[test]
2177    fn the_gate_scope_names_only_what_decides_these_calls() {
2178        let mut stack = booking_with_handoff();
2179        let state = State::new();
2180        let _ = state.set("slot", "19:00");
2181        stack.on_turn(&state);
2182        let scope = |tools: &[&str]| {
2183            stack
2184                .gate_scope(
2185                    &tools.iter().map(ToString::to_string).collect::<Vec<_>>(),
2186                    &state,
2187                )
2188                .questions
2189        };
2190        assert_eq!(scope(&["book"]), ["confirmed".to_string()].into());
2191        assert_eq!(scope(&["transfer"]), ["wants_person".to_string()].into());
2192        assert!(
2193            scope(&["check_availability"]).is_empty(),
2194            "a call no decision governs waits for none"
2195        );
2196    }
2197
2198    #[test]
2199    fn a_decided_commit_is_admitted_only_for_the_current_turn() {
2200        let mut stack = booking_with_handoff();
2201        let state = State::new();
2202        let _ = state.set("slot", "19:00");
2203        stack.on_turn(&state);
2204        assert!(
2205            stack.admits_tool("book", &state).is_err(),
2206            "not confirmed yet"
2207        );
2208        answer(&state, "confirmed", crate::flow::Outcome::Yes, "t1");
2209        assert!(stack.admits_tool("book", &state).is_ok());
2210        // The caller spoke again; the yes was about the previous turn.
2211        let _ = state.set(crate::flow::DECISION_TURN_KEY, "t2");
2212        let refusal = stack.admits_tool("book", &state).unwrap_err();
2213        assert!(refusal.contains("confirmed"), "{refusal}");
2214    }
2215
2216    #[test]
2217    fn fresh_decisions_open_a_digression_mid_turn() {
2218        let mut stack = booking_with_handoff();
2219        let state = State::new();
2220        stack.on_turn(&state);
2221        assert_eq!(stack.active_overlay(), None);
2222        answer(&state, "wants_person", crate::flow::Outcome::Yes, "t1");
2223        stack.on_decisions(&state);
2224        assert_eq!(stack.active_overlay(), Some("handoff"));
2225        assert!(stack.admits_tool("transfer", &state).is_ok());
2226        stack.on_tool_ok("transfer", &state);
2227        stack.on_turn(&state);
2228        stack.on_turn(&state);
2229        assert!(stack.is_terminated(), "one transfer completes the handoff");
2230    }
2231}