gemini_adk_rs/live/
phase.rs

1//! Declarative conversation phase management.
2//!
3//! A [`PhaseMachine`] holds named [`Phase`]s and evaluates guard-based
4//! transitions. Each phase carries an instruction (static or dynamic),
5//! optional tool filters, and async entry/exit callbacks.
6//!
7//! The machine is owned by the control-lane task; no internal locking is
8//! required — `&self` for reads, `&mut self` for mutations.
9
10use std::collections::{HashMap, VecDeque};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use gemini_genai_rs::session::SessionWriter;
15
16use super::transcript::TranscriptWindow;
17use super::BoxFuture;
18use crate::state::State;
19
20// ── Core types ──────────────────────────────────────────────────────────────
21
22/// What caused a phase transition.
23#[derive(Debug, Clone)]
24pub enum TransitionTrigger {
25    /// A named transition guard returned true during evaluate()
26    Guard {
27        /// Index of the transition guard that triggered.
28        transition_index: usize,
29    },
30    /// Explicit programmatic transition
31    Programmatic {
32        /// Source identifier for debugging (e.g., "tool_call", "watcher").
33        source: &'static str,
34    },
35}
36
37/// Instruction source for a phase — either a fixed string or a closure over state.
38pub enum PhaseInstruction {
39    /// A fixed instruction string.
40    Static(String),
41    /// A dynamic instruction derived from current state.
42    Dynamic(Arc<dyn Fn(&State) -> String + Send + Sync>),
43}
44
45impl PhaseInstruction {
46    /// Resolve the instruction to a concrete string.
47    pub fn resolve(&self, state: &State) -> String {
48        match self {
49            PhaseInstruction::Static(s) => s.clone(),
50            PhaseInstruction::Dynamic(f) => f(state),
51        }
52    }
53
54    /// Resolve the instruction and apply modifiers, returning the composed instruction.
55    pub fn resolve_with_modifiers(
56        &self,
57        state: &State,
58        modifiers: &[InstructionModifier],
59    ) -> String {
60        let mut instruction = self.resolve(state);
61        for modifier in modifiers {
62            modifier.apply(&mut instruction, state);
63        }
64        instruction
65    }
66}
67
68/// A modifier that transforms a phase instruction based on runtime state.
69///
70/// Modifiers are evaluated in order during instruction composition.
71/// They compose additively — each appends to the instruction built so far.
72#[derive(Clone)]
73pub enum InstructionModifier {
74    /// Append formatted state values: `[Context: key1=val1, key2=val2, ...]`
75    StateAppend(Vec<String>),
76    /// Append the result of a custom formatter function.
77    CustomAppend(Arc<dyn Fn(&State) -> String + Send + Sync>),
78    /// Conditionally append text when a predicate is true.
79    Conditional {
80        /// Predicate that determines whether to append the text.
81        predicate: Arc<dyn Fn(&State) -> bool + Send + Sync>,
82        /// Text to append when the predicate is true.
83        text: String,
84    },
85}
86
87impl InstructionModifier {
88    /// Apply this modifier to a base instruction string, mutating it in place.
89    pub fn apply(&self, base: &mut String, state: &State) {
90        match self {
91            InstructionModifier::StateAppend(keys) => {
92                let mut pairs = Vec::with_capacity(keys.len());
93                for key in keys {
94                    let display_key = key
95                        .strip_prefix("derived:")
96                        .or_else(|| key.strip_prefix("session:"))
97                        .or_else(|| key.strip_prefix("app:"))
98                        .or_else(|| key.strip_prefix("user:"))
99                        .unwrap_or(key);
100                    if let Some(val) = state.get::<serde_json::Value>(key) {
101                        match val {
102                            serde_json::Value::String(s) => {
103                                pairs.push(format!("{display_key}={s}"))
104                            }
105                            serde_json::Value::Number(n) => {
106                                pairs.push(format!("{display_key}={n}"))
107                            }
108                            serde_json::Value::Bool(b) => pairs.push(format!("{display_key}={b}")),
109                            other => pairs.push(format!("{display_key}={other}")),
110                        }
111                    }
112                }
113                if !pairs.is_empty() {
114                    base.push_str("\n\n[Context: ");
115                    base.push_str(&pairs.join(", "));
116                    base.push(']');
117                }
118            }
119            InstructionModifier::CustomAppend(f) => {
120                let text = f(state);
121                if !text.is_empty() {
122                    base.push_str("\n\n");
123                    base.push_str(&text);
124                }
125            }
126            InstructionModifier::Conditional { predicate, text } => {
127                if predicate(state) {
128                    base.push_str("\n\n");
129                    base.push_str(text);
130                }
131            }
132        }
133    }
134}
135
136/// A guard-based transition to a named target phase.
137pub struct Transition {
138    /// Name of the target phase.
139    pub target: String,
140    /// Guard function — transition fires when this returns `true`.
141    pub guard: Arc<dyn Fn(&State) -> bool + Send + Sync>,
142    /// Optional human-readable description of when/why this transition fires.
143    /// Used by `describe_navigation()` to tell the model what paths are available.
144    pub description: Option<String>,
145}
146
147/// A preparation effect that can materialize state before a phase is entered.
148///
149/// Preparations run after an outbound transition guard selects a target phase
150/// but before the machine commits to entering that target. They are intended
151/// for authoritative preconditions such as loading records, retrieving catalog
152/// facts, fetching policy, or hydrating state from durable storage.
153pub struct PhasePreparation {
154    /// Stable name for diagnostics.
155    pub name: String,
156    /// State keys this preparation is expected to produce.
157    pub produces: Vec<String>,
158    /// Async effect that can mutate state and/or write context.
159    pub run: Arc<dyn Fn(State, Arc<dyn SessionWriter>) -> BoxFuture<()> + Send + Sync>,
160}
161
162/// A conversation phase with instruction, tools, and transitions.
163pub struct Phase {
164    /// Unique name identifying this phase.
165    pub name: String,
166    /// The instruction (system prompt fragment) for this phase.
167    pub instruction: PhaseInstruction,
168    /// Tool filter — `None` means all tools are allowed.
169    pub tools_enabled: Option<Vec<String>>,
170    /// Optional guard: phase can only be entered when this returns `true`.
171    pub guard: Option<Arc<dyn Fn(&State) -> bool + Send + Sync>>,
172    /// Async callback executed when entering this phase.
173    pub on_enter: Option<Arc<dyn Fn(State, Arc<dyn SessionWriter>) -> BoxFuture<()> + Send + Sync>>,
174    /// Async callback executed when leaving this phase.
175    pub on_exit: Option<Arc<dyn Fn(State, Arc<dyn SessionWriter>) -> BoxFuture<()> + Send + Sync>>,
176    /// Ordered list of outbound transitions evaluated by the machine.
177    pub transitions: Vec<Transition>,
178    /// If `true`, `evaluate()` always returns `None` — no transitions out.
179    pub terminal: bool,
180    /// Instruction modifiers applied during instruction composition.
181    /// Evaluated in order, each appends to the resolved instruction.
182    pub modifiers: Vec<InstructionModifier>,
183    /// If `true`, send `turnComplete: true` after instruction + context on phase entry,
184    /// causing the model to generate a response immediately.
185    pub prompt_on_enter: bool,
186    /// Optional context injection on phase entry.
187    /// Returns Content to send as `client_content` (turnComplete: false).
188    /// Gives the model conversational continuity across phase transitions.
189    pub on_enter_context: Option<
190        Arc<
191            dyn Fn(&State, &TranscriptWindow) -> Option<Vec<gemini_genai_rs::prelude::Content>>
192                + Send
193                + Sync,
194        >,
195    >,
196    /// State keys this phase is responsible for gathering.
197    ///
198    /// Purely informational — does not affect transitions or enforcement.
199    /// The [`ContextBuilder`](super::context_builder::ContextBuilder) reads
200    /// these from `session:phase_needs` to append a "\[Gathering\] key1, key2"
201    /// line to the instruction, so the model knows what to focus on.
202    pub needs: Vec<String>,
203    /// State keys that must exist before this phase can be entered.
204    ///
205    /// Unlike [`needs`](Self::needs), these are enforced by the phase machine.
206    /// A transition targeting this phase is skipped until every required key is
207    /// present in state. Use this for authoritative facts that must be
208    /// materialized before the model is allowed to operate in the phase.
209    pub requires: Vec<String>,
210    /// Effects that can run before this phase is entered to satisfy
211    /// [`requires`](Self::requires).
212    pub preparations: Vec<PhasePreparation>,
213    /// Semantic concepts this phase presents to the user.
214    ///
215    /// On phase entry the machine writes `presented:<concept> = true` to state.
216    /// This lets flows distinguish "the model has collected a yes" from "the
217    /// user acknowledged this specific concept after it was presented".
218    pub presents: Vec<String>,
219    /// State keys to clear when this phase is entered.
220    ///
221    /// Useful for removing stale acknowledgements or intents gathered before
222    /// the phase's presented concepts are valid.
223    pub clear_on_enter: Vec<String>,
224}
225
226impl Phase {
227    /// Create a minimal non-terminal phase with a static instruction and defaults.
228    pub fn new(name: &str, instruction: &str) -> Self {
229        Self {
230            name: name.to_string(),
231            instruction: PhaseInstruction::Static(instruction.to_string()),
232            tools_enabled: None,
233            guard: None,
234            on_enter: None,
235            on_exit: None,
236            transitions: Vec::new(),
237            terminal: false,
238            modifiers: Vec::new(),
239            prompt_on_enter: false,
240            on_enter_context: None,
241            needs: Vec::new(),
242            requires: Vec::new(),
243            preparations: Vec::new(),
244            presents: Vec::new(),
245            clear_on_enter: Vec::new(),
246        }
247    }
248
249    /// State key used to mark that a concept has been presented.
250    pub fn presented_key(concept: &str) -> String {
251        format!("presented:{concept}")
252    }
253
254    /// Whether a semantic concept has been presented in this conversation.
255    pub fn is_presented(state: &State, concept: &str) -> bool {
256        state
257            .get::<bool>(&Self::presented_key(concept))
258            .unwrap_or(false)
259    }
260
261    /// Required state keys that are not currently present.
262    pub fn missing_requirements(&self, state: &State) -> Vec<String> {
263        self.requires
264            .iter()
265            .filter(|key| !state.contains(key))
266            .cloned()
267            .collect()
268    }
269}
270
271/// Record of a single phase transition for history/debugging.
272pub struct PhaseTransition {
273    /// Phase we left.
274    pub from: String,
275    /// Phase we entered.
276    pub to: String,
277    /// Turn number at the time of transition.
278    pub turn: u32,
279    /// Wall-clock instant of the transition.
280    pub timestamp: Instant,
281    /// What caused this transition.
282    pub trigger: TransitionTrigger,
283    /// How long the machine spent in the source phase before transitioning.
284    pub duration_in_phase: Duration,
285}
286
287/// Result of a phase transition, carrying the resolved instruction
288/// and any context to inject.
289pub struct TransitionResult {
290    /// The resolved instruction for the new phase (with modifiers applied).
291    pub instruction: String,
292    /// Optional context content to inject via `send_client_content`.
293    pub context: Option<Vec<gemini_genai_rs::prelude::Content>>,
294    /// Whether to send `turnComplete: true` after instruction + context.
295    pub prompt_on_enter: bool,
296}
297
298/// Result of evaluating outbound transitions.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum TransitionEvaluation {
301    /// A transition can be committed immediately.
302    Ready {
303        /// Target phase name.
304        target: String,
305        /// Index of the selected transition in the source phase.
306        transition_index: usize,
307    },
308    /// A transition guard matched, but the target phase is missing required
309    /// state that its preparations may be able to materialize.
310    Blocked {
311        /// Target phase name.
312        target: String,
313        /// Index of the selected transition in the source phase.
314        transition_index: usize,
315        /// Missing required state keys.
316        missing: Vec<String>,
317    },
318}
319
320// ── PhaseMachine ────────────────────────────────────────────────────────────
321
322/// Maximum phase transitions retained in history ring buffer.
323const MAX_PHASE_HISTORY: usize = 100;
324
325/// Evaluates transitions and manages phase entry/exit lifecycle.
326pub struct PhaseMachine {
327    phases: HashMap<String, Phase>,
328    current: String,
329    initial: String,
330    history: VecDeque<PhaseTransition>,
331    phase_entered_at: Instant,
332}
333
334impl PhaseMachine {
335    /// Create a new machine with the given initial phase name.
336    ///
337    /// The initial phase must be registered via [`add_phase`](Self::add_phase)
338    /// before calling [`validate`](Self::validate).
339    pub fn new(initial: &str) -> Self {
340        Self {
341            phases: HashMap::new(),
342            current: initial.to_string(),
343            initial: initial.to_string(),
344            history: VecDeque::new(),
345            phase_entered_at: Instant::now(),
346        }
347    }
348
349    /// Register a phase. Overwrites any existing phase with the same name.
350    pub fn add_phase(&mut self, phase: Phase) {
351        self.phases.insert(phase.name.clone(), phase);
352    }
353
354    /// The name of the current phase.
355    pub fn current(&self) -> &str {
356        &self.current
357    }
358
359    /// A reference to the current [`Phase`], if it exists in the registry.
360    pub fn current_phase(&self) -> Option<&Phase> {
361        self.phases.get(&self.current)
362    }
363
364    /// The transition history (oldest first, capped at 100 entries).
365    pub fn history(&self) -> &VecDeque<PhaseTransition> {
366        &self.history
367    }
368
369    /// Mutable access to the transition history (for testing).
370    #[cfg(test)]
371    pub(crate) fn history_mut(&mut self) -> &mut VecDeque<PhaseTransition> {
372        &mut self.history
373    }
374
375    /// Generate a structured navigation context block giving the model
376    /// awareness of where it is in the conversation flow.
377    ///
378    /// The output includes the current phase and its goal, recent phase
379    /// history, any state keys still needed, and possible transitions.
380    pub fn describe_navigation(&self, state: &State) -> String {
381        let mut lines = Vec::new();
382        lines.push("[Navigation]".to_string());
383
384        // 1. Current phase + goal (first sentence of resolved instruction)
385        if let Some(phase) = self.phases.get(&self.current) {
386            let resolved = phase.instruction.resolve(state);
387            let goal = resolved.split('.').next().unwrap_or(&resolved).trim();
388            lines.push(format!("Current phase: {} — {}", self.current, goal));
389
390            // 2. Phase history (last 3 entries)
391            if !self.history.is_empty() {
392                let recent: Vec<String> = self
393                    .history
394                    .iter()
395                    .rev()
396                    .take(3)
397                    .collect::<Vec<_>>()
398                    .into_iter()
399                    .rev()
400                    .map(|h| format!("{} (turn {})", h.from, h.turn))
401                    .collect();
402                lines.push(format!("Previous: {}", recent.join(", ")));
403            }
404
405            // 3. Still needed keys (from phase.needs, filtered by state)
406            let missing: Vec<&str> = phase
407                .needs
408                .iter()
409                .filter(|key| !state.contains(key))
410                .map(|s| s.as_str())
411                .collect();
412            if !missing.is_empty() {
413                lines.push(format!("Still needed: {}", missing.join(", ")));
414            }
415
416            // 4. Hard requirements for the current phase.
417            let missing_required: Vec<&str> = phase
418                .requires
419                .iter()
420                .filter(|key| !state.contains(key))
421                .map(|s| s.as_str())
422                .collect();
423            if !missing_required.is_empty() {
424                lines.push(format!(
425                    "Blocked until required state is available: {}",
426                    missing_required.join(", ")
427                ));
428            }
429            if !phase.preparations.is_empty() {
430                let preparations: Vec<&str> =
431                    phase.preparations.iter().map(|p| p.name.as_str()).collect();
432                lines.push(format!("Preparers: {}", preparations.join(", ")));
433            }
434            if !phase.presents.is_empty() {
435                lines.push(format!("Presents: {}", phase.presents.join(", ")));
436            }
437
438            // 5. Possible transitions or terminal
439            if phase.terminal {
440                lines.push("This is the final phase.".to_string());
441            } else if !phase.transitions.is_empty() {
442                lines.push("Possible next:".to_string());
443                for t in &phase.transitions {
444                    if let Some(ref desc) = t.description {
445                        lines.push(format!("  → {}: {}", t.target, desc));
446                    } else {
447                        lines.push(format!("  → {}", t.target));
448                    }
449                }
450            }
451        }
452
453        lines.join("\n")
454    }
455
456    /// Evaluate transitions from the current phase.
457    ///
458    /// Returns the target phase name and transition index of the first
459    /// transition whose guard returns `true`, or `None` if no transition
460    /// fires (or the current phase is terminal / missing).
461    ///
462    /// This method is **pure** — it does not modify state or execute callbacks.
463    pub fn evaluate(&self, state: &State) -> Option<(&str, usize)> {
464        match self.evaluate_for_transition(state)? {
465            TransitionEvaluation::Ready {
466                transition_index, ..
467            } => {
468                let phase = self.phases.get(&self.current)?;
469                let target = phase.transitions.get(transition_index)?.target.as_str();
470                Some((target, transition_index))
471            }
472            TransitionEvaluation::Blocked { .. } => None,
473        }
474    }
475
476    /// Evaluate transitions from the current phase, preserving blocked targets
477    /// that declare preparations.
478    pub fn evaluate_for_transition(&self, state: &State) -> Option<TransitionEvaluation> {
479        let phase = self.phases.get(&self.current)?;
480        if phase.terminal {
481            return None;
482        }
483        for (index, transition) in phase.transitions.iter().enumerate() {
484            if (transition.guard)(state) {
485                // Check target phase guard — if the target phase has a guard
486                // that returns false, skip this transition and try the next one.
487                if let Some(target_phase) = self.phases.get(&transition.target) {
488                    if let Some(ref phase_guard) = target_phase.guard {
489                        if !phase_guard(state) {
490                            continue;
491                        }
492                    }
493                    let missing = target_phase.missing_requirements(state);
494                    if missing.is_empty() {
495                        return Some(TransitionEvaluation::Ready {
496                            target: transition.target.clone(),
497                            transition_index: index,
498                        });
499                    }
500                    if !target_phase.preparations.is_empty() {
501                        return Some(TransitionEvaluation::Blocked {
502                            target: transition.target.clone(),
503                            transition_index: index,
504                            missing,
505                        });
506                    } else {
507                        continue;
508                    }
509                }
510                return Some(TransitionEvaluation::Ready {
511                    target: transition.target.clone(),
512                    transition_index: index,
513                });
514            }
515        }
516        None
517    }
518
519    /// Run preparation effects declared by a target phase, then report whether
520    /// all target requirements are now satisfied.
521    pub async fn prepare_target(
522        &self,
523        target: &str,
524        state: &State,
525        writer: &Arc<dyn SessionWriter>,
526    ) -> bool {
527        let Some(phase) = self.phases.get(target) else {
528            return false;
529        };
530
531        for preparation in &phase.preparations {
532            let fut = (preparation.run)(state.clone(), Arc::clone(writer));
533            fut.await;
534        }
535
536        phase.missing_requirements(state).is_empty()
537    }
538
539    /// Execute a transition: run `on_exit` for the current phase, update
540    /// `current`, run `on_enter` for the new phase, record history, and
541    /// return the `TransitionResult` for the new phase.
542    ///
543    /// Returns `None` if the target phase does not exist.
544    pub async fn transition(
545        &mut self,
546        target: &str,
547        state: &State,
548        writer: &Arc<dyn SessionWriter>,
549        turn: u32,
550        trigger: TransitionTrigger,
551        transcript_window: &TranscriptWindow,
552    ) -> Option<TransitionResult> {
553        // Target must exist.
554        if !self.phases.contains_key(target) {
555            return None;
556        }
557
558        let from = self.current.clone();
559        let duration_in_phase = self.phase_entered_at.elapsed();
560
561        // Run on_exit for the current phase (if it exists and has callback).
562        if let Some(phase) = self.phases.get(&from) {
563            if let Some(ref on_exit) = phase.on_exit {
564                let fut = on_exit(state.clone(), Arc::clone(writer));
565                fut.await;
566            }
567        }
568
569        // Update current phase.
570        self.current = target.to_string();
571        self.phase_entered_at = Instant::now();
572
573        // Run on_enter for the new phase.
574        if let Some(phase) = self.phases.get(target) {
575            for key in &phase.clear_on_enter {
576                state.remove(key);
577            }
578            for concept in &phase.presents {
579                state.set(&Phase::presented_key(concept), true);
580            }
581            if let Some(ref on_enter) = phase.on_enter {
582                let fut = on_enter(state.clone(), Arc::clone(writer));
583                fut.await;
584            }
585        }
586
587        // Record history (ring buffer — evict oldest if at capacity).
588        if self.history.len() >= MAX_PHASE_HISTORY {
589            self.history.pop_front();
590        }
591        self.history.push_back(PhaseTransition {
592            from,
593            to: target.to_string(),
594            turn,
595            timestamp: Instant::now(),
596            trigger,
597            duration_in_phase,
598        });
599
600        // Build transition result from the new phase.
601        let phase = self.phases.get(target)?;
602        let instruction = phase
603            .instruction
604            .resolve_with_modifiers(state, &phase.modifiers);
605        let context = phase
606            .on_enter_context
607            .as_ref()
608            .and_then(|f| f(state, transcript_window));
609        let prompt_on_enter = phase.prompt_on_enter;
610
611        Some(TransitionResult {
612            instruction,
613            context,
614            prompt_on_enter,
615        })
616    }
617
618    /// Returns how long the machine has been in the current phase.
619    pub fn current_phase_duration(&self) -> Duration {
620        self.phase_entered_at.elapsed()
621    }
622
623    /// Active tools filter for the current phase.
624    ///
625    /// Returns `None` when all tools are allowed, or `Some(slice)` with
626    /// the explicitly enabled tool names.
627    pub fn active_tools(&self) -> Option<&[String]> {
628        self.phases
629            .get(&self.current)
630            .and_then(|p| p.tools_enabled.as_deref())
631    }
632
633    /// Validate the machine configuration.
634    ///
635    /// Checks:
636    /// - At least one phase is registered.
637    /// - The initial phase exists.
638    /// - Every transition target references an existing phase.
639    pub fn validate(&self) -> Result<(), String> {
640        if self.phases.is_empty() {
641            return Err("no phases registered".to_string());
642        }
643        if !self.phases.contains_key(&self.initial) {
644            return Err(format!(
645                "initial phase '{}' not found in registered phases",
646                self.initial
647            ));
648        }
649        for phase in self.phases.values() {
650            for transition in &phase.transitions {
651                if !self.phases.contains_key(&transition.target) {
652                    return Err(format!(
653                        "phase '{}' has transition to unknown target '{}'",
654                        phase.name, transition.target
655                    ));
656                }
657            }
658        }
659        Ok(())
660    }
661}
662
663// ── Tests ───────────────────────────────────────────────────────────────────
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    use super::super::transcript::TranscriptWindow;
670
671    /// Helper: create a minimal non-terminal phase with no callbacks.
672    fn simple_phase(name: &str, instruction: &str) -> Phase {
673        Phase {
674            name: name.to_string(),
675            instruction: PhaseInstruction::Static(instruction.to_string()),
676            tools_enabled: None,
677            guard: None,
678            on_enter: None,
679            on_exit: None,
680            transitions: Vec::new(),
681            terminal: false,
682            modifiers: Vec::new(),
683            prompt_on_enter: false,
684            on_enter_context: None,
685            needs: Vec::new(),
686            requires: Vec::new(),
687            preparations: Vec::new(),
688            presents: Vec::new(),
689            clear_on_enter: Vec::new(),
690        }
691    }
692
693    /// Helper: create a terminal phase.
694    fn terminal_phase(name: &str, instruction: &str) -> Phase {
695        Phase {
696            name: name.to_string(),
697            instruction: PhaseInstruction::Static(instruction.to_string()),
698            tools_enabled: None,
699            guard: None,
700            on_enter: None,
701            on_exit: None,
702            transitions: Vec::new(),
703            terminal: true,
704            modifiers: Vec::new(),
705            prompt_on_enter: false,
706            on_enter_context: None,
707            needs: Vec::new(),
708            requires: Vec::new(),
709            preparations: Vec::new(),
710            presents: Vec::new(),
711            clear_on_enter: Vec::new(),
712        }
713    }
714
715    /// Helper: empty transcript window for tests.
716    fn empty_tw() -> TranscriptWindow {
717        TranscriptWindow::new(vec![])
718    }
719
720    // ── 1. new + add_phase + current ────────────────────────────────────
721
722    #[test]
723    fn new_and_add_phase_and_current() {
724        let mut machine = PhaseMachine::new("greeting");
725        machine.add_phase(simple_phase("greeting", "Say hello"));
726        assert_eq!(machine.current(), "greeting");
727        assert!(machine.current_phase().is_some());
728        assert!(machine.history().is_empty());
729    }
730
731    // ── 2. evaluate with single transition that fires ───────────────────
732
733    #[test]
734    fn evaluate_single_transition_fires() {
735        let state = State::new();
736        state.set("ready", true);
737
738        let mut greeting = simple_phase("greeting", "Say hello");
739        greeting.transitions.push(Transition {
740            target: "main".to_string(),
741            guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
742            description: None,
743        });
744
745        let mut machine = PhaseMachine::new("greeting");
746        machine.add_phase(greeting);
747        machine.add_phase(simple_phase("main", "Main phase"));
748
749        assert_eq!(machine.evaluate(&state), Some(("main", 0)));
750    }
751
752    // ── 3. evaluate with single transition that does not fire ───────────
753
754    #[test]
755    fn evaluate_single_transition_does_not_fire() {
756        let state = State::new();
757        // "ready" is not set → guard returns false
758
759        let mut greeting = simple_phase("greeting", "Say hello");
760        greeting.transitions.push(Transition {
761            target: "main".to_string(),
762            guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
763            description: None,
764        });
765
766        let mut machine = PhaseMachine::new("greeting");
767        machine.add_phase(greeting);
768        machine.add_phase(simple_phase("main", "Main phase"));
769
770        assert_eq!(machine.evaluate(&state), None);
771    }
772
773    // ── 4. evaluate with multiple transitions (first match wins) ────────
774
775    #[test]
776    fn evaluate_multiple_transitions_first_match_wins() {
777        let state = State::new();
778        state.set("escalate", true);
779        state.set("done", true);
780
781        let mut greeting = simple_phase("greeting", "Say hello");
782        greeting.transitions.push(Transition {
783            target: "escalated".to_string(),
784            guard: Arc::new(|s: &State| s.get::<bool>("escalate").unwrap_or(false)),
785            description: None,
786        });
787        greeting.transitions.push(Transition {
788            target: "farewell".to_string(),
789            guard: Arc::new(|s: &State| s.get::<bool>("done").unwrap_or(false)),
790            description: None,
791        });
792
793        let mut machine = PhaseMachine::new("greeting");
794        machine.add_phase(greeting);
795        machine.add_phase(simple_phase("escalated", "Escalated"));
796        machine.add_phase(simple_phase("farewell", "Farewell"));
797
798        // Both guards are true, but "escalated" is declared first (index 0).
799        assert_eq!(machine.evaluate(&state), Some(("escalated", 0)));
800    }
801
802    // ── 5. evaluate on terminal phase returns None ──────────────────────
803
804    #[test]
805    fn evaluate_terminal_phase_returns_none() {
806        let state = State::new();
807        state.set("anything", true);
808
809        let mut term = terminal_phase("end", "Goodbye");
810        // Even if we add a transition, terminal should short-circuit.
811        term.transitions.push(Transition {
812            target: "other".to_string(),
813            guard: Arc::new(|_| true),
814            description: None,
815        });
816
817        let mut machine = PhaseMachine::new("end");
818        machine.add_phase(term);
819        machine.add_phase(simple_phase("other", "Other"));
820
821        assert_eq!(machine.evaluate(&state), None);
822    }
823
824    // ── 6. transition updates current and records history ───────────────
825
826    #[tokio::test]
827    async fn transition_updates_current_and_records_history() {
828        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
829        let state = State::new();
830
831        let mut machine = PhaseMachine::new("greeting");
832        machine.add_phase(simple_phase("greeting", "Say hello"));
833        machine.add_phase(simple_phase("main", "Main phase instruction"));
834
835        let trigger = TransitionTrigger::Guard {
836            transition_index: 0,
837        };
838        let tw = empty_tw();
839        let result = machine
840            .transition("main", &state, &writer, 1, trigger, &tw)
841            .await;
842        assert_eq!(
843            result.as_ref().map(|r| r.instruction.as_str()),
844            Some("Main phase instruction")
845        );
846        assert_eq!(machine.current(), "main");
847        assert_eq!(machine.history().len(), 1);
848        assert_eq!(machine.history()[0].from, "greeting");
849        assert_eq!(machine.history()[0].to, "main");
850        assert_eq!(machine.history()[0].turn, 1);
851        assert!(matches!(
852            machine.history()[0].trigger,
853            TransitionTrigger::Guard {
854                transition_index: 0
855            }
856        ));
857    }
858
859    // ── 7. active_tools returns correct filter ──────────────────────────
860
861    #[test]
862    fn active_tools_returns_filter() {
863        let mut phase = simple_phase("filtered", "Filtered phase");
864        phase.tools_enabled = Some(vec!["search".to_string(), "lookup".to_string()]);
865
866        let mut machine = PhaseMachine::new("filtered");
867        machine.add_phase(phase);
868
869        let tools = machine.active_tools().unwrap();
870        assert_eq!(tools.len(), 2);
871        assert!(tools.contains(&"search".to_string()));
872        assert!(tools.contains(&"lookup".to_string()));
873    }
874
875    // ── 8. active_tools returns None when no filter set ─────────────────
876
877    #[test]
878    fn active_tools_returns_none_when_no_filter() {
879        let mut machine = PhaseMachine::new("open");
880        machine.add_phase(simple_phase("open", "All tools allowed"));
881
882        assert!(machine.active_tools().is_none());
883    }
884
885    // ── 9. validate catches missing initial phase ───────────────────────
886
887    #[test]
888    fn validate_catches_missing_initial_phase() {
889        let mut machine = PhaseMachine::new("nonexistent");
890        machine.add_phase(simple_phase("greeting", "Hi"));
891
892        let err = machine.validate().unwrap_err();
893        assert!(err.contains("initial phase 'nonexistent' not found"));
894    }
895
896    // ── 10. validate catches invalid transition target ──────────────────
897
898    #[test]
899    fn validate_catches_invalid_transition_target() {
900        let mut greeting = simple_phase("greeting", "Hi");
901        greeting.transitions.push(Transition {
902            target: "missing_phase".to_string(),
903            guard: Arc::new(|_| true),
904            description: None,
905        });
906
907        let mut machine = PhaseMachine::new("greeting");
908        machine.add_phase(greeting);
909
910        let err = machine.validate().unwrap_err();
911        assert!(err.contains("unknown target 'missing_phase'"));
912    }
913
914    // ── 11. validate succeeds on valid config ───────────────────────────
915
916    #[test]
917    fn validate_succeeds_on_valid_config() {
918        let mut greeting = simple_phase("greeting", "Hi");
919        greeting.transitions.push(Transition {
920            target: "main".to_string(),
921            guard: Arc::new(|_| true),
922            description: None,
923        });
924
925        let mut machine = PhaseMachine::new("greeting");
926        machine.add_phase(greeting);
927        machine.add_phase(simple_phase("main", "Main"));
928
929        assert!(machine.validate().is_ok());
930    }
931
932    // ── 12. PhaseInstruction::Static resolves correctly ─────────────────
933
934    #[test]
935    fn phase_instruction_static_resolves() {
936        let state = State::new();
937        let instr = PhaseInstruction::Static("You are a helpful assistant.".to_string());
938        assert_eq!(instr.resolve(&state), "You are a helpful assistant.");
939    }
940
941    // ── 13. PhaseInstruction::Dynamic resolves correctly ────────────────
942
943    #[test]
944    fn phase_instruction_dynamic_resolves() {
945        let state = State::new();
946        state.set("user_name", "Alice");
947
948        let instr = PhaseInstruction::Dynamic(Arc::new(|s: &State| {
949            let name: String = s.get("user_name").unwrap_or_default();
950            format!("Greet the user named {}.", name)
951        }));
952
953        assert_eq!(instr.resolve(&state), "Greet the user named Alice.");
954    }
955
956    // ── validate catches empty phases ───────────────────────────────────
957
958    #[test]
959    fn validate_catches_no_phases() {
960        let machine = PhaseMachine::new("greeting");
961        let err = machine.validate().unwrap_err();
962        assert!(err.contains("no phases registered"));
963    }
964
965    // ── transition to nonexistent target returns None ────────────────────
966
967    #[tokio::test]
968    async fn transition_to_nonexistent_target_returns_none() {
969        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
970        let state = State::new();
971
972        let mut machine = PhaseMachine::new("greeting");
973        machine.add_phase(simple_phase("greeting", "Hi"));
974
975        let trigger = TransitionTrigger::Programmatic { source: "test" };
976        let tw = empty_tw();
977        let result = machine
978            .transition("no_such_phase", &state, &writer, 0, trigger, &tw)
979            .await;
980        assert!(result.is_none());
981        // Current phase should remain unchanged.
982        assert_eq!(machine.current(), "greeting");
983    }
984
985    // ── transition runs on_enter and on_exit callbacks ────────────────────
986
987    #[tokio::test]
988    async fn transition_runs_on_enter_and_on_exit_callbacks() {
989        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
990        let state = State::new();
991
992        let mut greeting = simple_phase("greeting", "Hi");
993        greeting.on_exit = Some(Arc::new(|s: State, _w: Arc<dyn SessionWriter>| {
994            Box::pin(async move {
995                s.set("exited_greeting", true);
996            })
997        }));
998
999        let mut main = simple_phase("main", "Main");
1000        main.on_enter = Some(Arc::new(|s: State, _w: Arc<dyn SessionWriter>| {
1001            Box::pin(async move {
1002                s.set("entered_main", true);
1003            })
1004        }));
1005
1006        let mut machine = PhaseMachine::new("greeting");
1007        machine.add_phase(greeting);
1008        machine.add_phase(main);
1009
1010        let trigger = TransitionTrigger::Programmatic { source: "test" };
1011        let tw = empty_tw();
1012        machine
1013            .transition("main", &state, &writer, 1, trigger, &tw)
1014            .await;
1015
1016        assert_eq!(state.get::<bool>("exited_greeting"), Some(true));
1017        assert_eq!(state.get::<bool>("entered_main"), Some(true));
1018    }
1019
1020    // ── multiple transitions accumulate history ──────────────────────────
1021
1022    #[tokio::test]
1023    async fn multiple_transitions_accumulate_history() {
1024        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1025        let state = State::new();
1026
1027        let mut machine = PhaseMachine::new("a");
1028        machine.add_phase(simple_phase("a", "Phase A"));
1029        machine.add_phase(simple_phase("b", "Phase B"));
1030        machine.add_phase(simple_phase("c", "Phase C"));
1031
1032        let trigger1 = TransitionTrigger::Guard {
1033            transition_index: 0,
1034        };
1035        let tw = empty_tw();
1036        machine
1037            .transition("b", &state, &writer, 1, trigger1, &tw)
1038            .await;
1039        let trigger2 = TransitionTrigger::Programmatic { source: "test" };
1040        machine
1041            .transition("c", &state, &writer, 3, trigger2, &tw)
1042            .await;
1043
1044        assert_eq!(machine.current(), "c");
1045        assert_eq!(machine.history().len(), 2);
1046        assert_eq!(machine.history()[0].from, "a");
1047        assert_eq!(machine.history()[0].to, "b");
1048        assert_eq!(machine.history()[0].turn, 1);
1049        assert!(matches!(
1050            machine.history()[0].trigger,
1051            TransitionTrigger::Guard {
1052                transition_index: 0
1053            }
1054        ));
1055        assert_eq!(machine.history()[1].from, "b");
1056        assert_eq!(machine.history()[1].to, "c");
1057        assert_eq!(machine.history()[1].turn, 3);
1058        assert!(matches!(
1059            machine.history()[1].trigger,
1060            TransitionTrigger::Programmatic { source: "test" }
1061        ));
1062    }
1063
1064    // ── dynamic instruction resolved during transition ──────────────────
1065
1066    #[tokio::test]
1067    async fn transition_resolves_dynamic_instruction() {
1068        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1069        let state = State::new();
1070        state.set("topic", "weather");
1071
1072        let dynamic_phase = Phase {
1073            name: "dynamic".to_string(),
1074            instruction: PhaseInstruction::Dynamic(Arc::new(|s: &State| {
1075                let topic: String = s.get("topic").unwrap_or_default();
1076                format!("Discuss {}.", topic)
1077            })),
1078            tools_enabled: None,
1079            guard: None,
1080            on_enter: None,
1081            on_exit: None,
1082            transitions: Vec::new(),
1083            terminal: false,
1084            modifiers: Vec::new(),
1085            prompt_on_enter: false,
1086            on_enter_context: None,
1087            needs: Vec::new(),
1088            requires: Vec::new(),
1089            preparations: Vec::new(),
1090            presents: Vec::new(),
1091            clear_on_enter: Vec::new(),
1092        };
1093
1094        let mut machine = PhaseMachine::new("start");
1095        machine.add_phase(simple_phase("start", "Begin"));
1096        machine.add_phase(dynamic_phase);
1097
1098        let trigger = TransitionTrigger::Programmatic { source: "test" };
1099        let tw = empty_tw();
1100        let result = machine
1101            .transition("dynamic", &state, &writer, 1, trigger, &tw)
1102            .await;
1103        assert_eq!(
1104            result.as_ref().map(|r| r.instruction.as_str()),
1105            Some("Discuss weather.")
1106        );
1107    }
1108
1109    // ── phase-level guard blocks transition into guarded phase ─────────
1110
1111    #[test]
1112    fn phase_guard_blocks_transition() {
1113        let state = State::new();
1114        state.set("ready", true);
1115        // "verified" is NOT set, so the target phase guard will reject entry.
1116
1117        let mut greeting = simple_phase("greeting", "Say hello");
1118        greeting.transitions.push(Transition {
1119            target: "secure".to_string(),
1120            guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1121            description: None,
1122        });
1123
1124        // Target phase has a guard that requires "verified" to be true.
1125        let mut secure = simple_phase("secure", "Secure area");
1126        secure.guard = Some(Arc::new(|s: &State| {
1127            s.get::<bool>("verified").unwrap_or(false)
1128        }));
1129
1130        let mut machine = PhaseMachine::new("greeting");
1131        machine.add_phase(greeting);
1132        machine.add_phase(secure);
1133
1134        // Transition guard fires (ready=true), but target phase guard blocks
1135        // (verified is not set), so evaluate returns None.
1136        assert_eq!(machine.evaluate(&state), None);
1137    }
1138
1139    #[test]
1140    fn phase_guard_allows_transition_when_satisfied() {
1141        let state = State::new();
1142        state.set("ready", true);
1143        state.set("verified", true);
1144
1145        let mut greeting = simple_phase("greeting", "Say hello");
1146        greeting.transitions.push(Transition {
1147            target: "secure".to_string(),
1148            guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1149            description: None,
1150        });
1151
1152        // Target phase guard requires "verified" — which IS set.
1153        let mut secure = simple_phase("secure", "Secure area");
1154        secure.guard = Some(Arc::new(|s: &State| {
1155            s.get::<bool>("verified").unwrap_or(false)
1156        }));
1157
1158        let mut machine = PhaseMachine::new("greeting");
1159        machine.add_phase(greeting);
1160        machine.add_phase(secure);
1161
1162        // Both transition guard and phase guard pass (index 0).
1163        assert_eq!(machine.evaluate(&state), Some(("secure", 0)));
1164    }
1165
1166    #[test]
1167    fn phase_guard_skips_to_next_transition() {
1168        let state = State::new();
1169        state.set("ready", true);
1170        // "verified" is NOT set — first target's phase guard will block.
1171
1172        let mut greeting = simple_phase("greeting", "Say hello");
1173        // First transition → "secure" (phase guard will block)
1174        greeting.transitions.push(Transition {
1175            target: "secure".to_string(),
1176            guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1177            description: None,
1178        });
1179        // Second transition → "fallback" (no phase guard)
1180        greeting.transitions.push(Transition {
1181            target: "fallback".to_string(),
1182            guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1183            description: None,
1184        });
1185
1186        let mut secure = simple_phase("secure", "Secure area");
1187        secure.guard = Some(Arc::new(|s: &State| {
1188            s.get::<bool>("verified").unwrap_or(false)
1189        }));
1190
1191        let mut machine = PhaseMachine::new("greeting");
1192        machine.add_phase(greeting);
1193        machine.add_phase(secure);
1194        machine.add_phase(simple_phase("fallback", "Fallback"));
1195
1196        // First transition fires but phase guard blocks → falls through to
1197        // second transition (index 1) which has no phase guard → returns "fallback".
1198        assert_eq!(machine.evaluate(&state), Some(("fallback", 1)));
1199    }
1200
1201    // ── InstructionModifier tests ──────────────────────────────────────
1202
1203    #[test]
1204    fn instruction_modifier_state_append() {
1205        let state = State::new();
1206        state.set("emotion", "happy");
1207        state.set("score", 0.8f64);
1208
1209        let modifier =
1210            InstructionModifier::StateAppend(vec!["emotion".to_string(), "score".to_string()]);
1211        let mut base = "You are an assistant.".to_string();
1212        modifier.apply(&mut base, &state);
1213        assert!(base.contains("[Context: emotion=happy, score=0.8]"));
1214    }
1215
1216    #[test]
1217    fn instruction_modifier_conditional_true() {
1218        let state = State::new();
1219        state.set("risk", "high");
1220
1221        let modifier = InstructionModifier::Conditional {
1222            predicate: Arc::new(|s: &State| s.get::<String>("risk").unwrap_or_default() == "high"),
1223            text: "IMPORTANT: Use extra empathy.".to_string(),
1224        };
1225        let mut base = "Base instruction.".to_string();
1226        modifier.apply(&mut base, &state);
1227        assert!(base.contains("IMPORTANT: Use extra empathy."));
1228    }
1229
1230    #[test]
1231    fn instruction_modifier_conditional_false() {
1232        let state = State::new();
1233        state.set("risk", "low");
1234
1235        let modifier = InstructionModifier::Conditional {
1236            predicate: Arc::new(|s: &State| s.get::<String>("risk").unwrap_or_default() == "high"),
1237            text: "IMPORTANT: Use extra empathy.".to_string(),
1238        };
1239        let mut base = "Base instruction.".to_string();
1240        modifier.apply(&mut base, &state);
1241        assert!(!base.contains("IMPORTANT"));
1242    }
1243
1244    #[test]
1245    fn resolve_with_modifiers_composes() {
1246        let state = State::new();
1247        state.set("mood", "calm");
1248
1249        let instr = PhaseInstruction::Static("You are helpful.".to_string());
1250        let modifiers = vec![InstructionModifier::StateAppend(vec!["mood".to_string()])];
1251        let result = instr.resolve_with_modifiers(&state, &modifiers);
1252        assert!(result.starts_with("You are helpful."));
1253        assert!(result.contains("[Context: mood=calm]"));
1254    }
1255
1256    // ── describe_navigation tests ──────────────────────────────────────
1257
1258    #[test]
1259    fn describe_navigation_basic() {
1260        let state = State::new();
1261        state.set("caller_name", "Vamsi");
1262
1263        let mut machine = PhaseMachine::new("greeting");
1264
1265        let mut greeting = Phase::new(
1266            "greeting",
1267            "Greet the caller warmly and ask who is calling.",
1268        );
1269        greeting.transitions.push(Transition {
1270            target: "identify".to_string(),
1271            guard: Arc::new(|_| false),
1272            description: Some("after initial greeting".into()),
1273        });
1274        machine.add_phase(greeting);
1275
1276        let mut identify = Phase::new("identify", "Get the caller's name.");
1277        identify.needs = vec!["caller_name".into(), "caller_org".into()];
1278        identify.transitions.push(Transition {
1279            target: "purpose".to_string(),
1280            guard: Arc::new(|_| false),
1281            description: Some("when caller is identified".into()),
1282        });
1283        machine.add_phase(identify);
1284
1285        let nav = machine.describe_navigation(&state);
1286        assert!(nav.contains("[Navigation]"));
1287        assert!(nav.contains("Current phase: greeting"));
1288        assert!(nav.contains("→ identify: after initial greeting"));
1289    }
1290
1291    #[test]
1292    fn describe_navigation_with_history_and_needs() {
1293        let state = State::new();
1294        // caller_name is set, caller_org is NOT set
1295        state.set("caller_name", "Vamsi");
1296
1297        let mut machine = PhaseMachine::new("identify");
1298
1299        let greeting = Phase::new("greeting", "Greet caller.");
1300        machine.add_phase(greeting);
1301
1302        let mut identify = Phase::new("identify", "Get the caller's name and organization.");
1303        identify.needs = vec!["caller_name".into(), "caller_org".into()];
1304        identify.transitions.push(Transition {
1305            target: "purpose".to_string(),
1306            guard: Arc::new(|_| false),
1307            description: Some("when caller is identified".into()),
1308        });
1309        machine.add_phase(identify);
1310
1311        let purpose = Phase::new("purpose", "Ask why they are calling.");
1312        machine.add_phase(purpose);
1313
1314        // Simulate history: greeting -> identify at turn 2
1315        machine.history_mut().push_back(PhaseTransition {
1316            from: "greeting".to_string(),
1317            to: "identify".to_string(),
1318            turn: 2,
1319            trigger: TransitionTrigger::Guard {
1320                transition_index: 0,
1321            },
1322            timestamp: std::time::Instant::now(),
1323            duration_in_phase: Duration::from_secs(5),
1324        });
1325
1326        let nav = machine.describe_navigation(&state);
1327        assert!(nav.contains("Previous:"), "Should show history");
1328        assert!(nav.contains("greeting"), "Should mention previous phase");
1329        assert!(
1330            nav.contains("Still needed: caller_org"),
1331            "caller_org should be listed as needed (caller_name is set)"
1332        );
1333        assert!(
1334            !nav.contains("caller_name"),
1335            "caller_name should NOT be in still-needed (it's set)"
1336        );
1337    }
1338
1339    #[test]
1340    fn evaluate_skips_target_when_required_state_missing() {
1341        let state = State::new();
1342        let mut machine = PhaseMachine::new("start");
1343
1344        let mut start = simple_phase("start", "Start.");
1345        start.transitions.push(Transition {
1346            target: "grounded".to_string(),
1347            guard: Arc::new(|_| true),
1348            description: Some("when ready".into()),
1349        });
1350        machine.add_phase(start);
1351
1352        let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1353        grounded.requires = vec!["facts_loaded".into()];
1354        machine.add_phase(grounded);
1355
1356        assert!(machine.evaluate(&state).is_none());
1357
1358        state.set("facts_loaded", true);
1359        assert_eq!(
1360            machine.evaluate(&state).map(|(target, _)| target),
1361            Some("grounded")
1362        );
1363    }
1364
1365    #[test]
1366    fn evaluate_reports_blocked_target_with_preparation() {
1367        let state = State::new();
1368        let mut machine = PhaseMachine::new("start");
1369
1370        let mut start = simple_phase("start", "Start.");
1371        start.transitions.push(Transition {
1372            target: "grounded".to_string(),
1373            guard: Arc::new(|_| true),
1374            description: Some("when ready".into()),
1375        });
1376        machine.add_phase(start);
1377
1378        let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1379        grounded.requires = vec!["facts_loaded".into()];
1380        grounded.preparations.push(PhasePreparation {
1381            name: "load_facts".into(),
1382            produces: vec!["facts_loaded".into()],
1383            run: Arc::new(|state, _writer| {
1384                Box::pin(async move {
1385                    state.set("facts_loaded", true);
1386                })
1387            }),
1388        });
1389        machine.add_phase(grounded);
1390
1391        assert_eq!(
1392            machine.evaluate_for_transition(&state),
1393            Some(TransitionEvaluation::Blocked {
1394                target: "grounded".into(),
1395                transition_index: 0,
1396                missing: vec!["facts_loaded".into()],
1397            })
1398        );
1399    }
1400
1401    #[tokio::test]
1402    async fn prepare_target_materializes_required_state() {
1403        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1404        let state = State::new();
1405        let mut machine = PhaseMachine::new("start");
1406
1407        machine.add_phase(simple_phase("start", "Start."));
1408
1409        let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1410        grounded.requires = vec!["facts_loaded".into()];
1411        grounded.preparations.push(PhasePreparation {
1412            name: "load_facts".into(),
1413            produces: vec!["facts_loaded".into()],
1414            run: Arc::new(|state, _writer| {
1415                Box::pin(async move {
1416                    state.set("facts_loaded", true);
1417                })
1418            }),
1419        });
1420        machine.add_phase(grounded);
1421
1422        assert!(machine.prepare_target("grounded", &state, &writer).await);
1423        assert_eq!(state.get::<bool>("facts_loaded"), Some(true));
1424    }
1425
1426    #[tokio::test]
1427    async fn transition_marks_presented_concepts_and_clears_stale_keys() {
1428        let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1429        let state = State::new();
1430        state.set("ack", true);
1431
1432        let mut machine = PhaseMachine::new("start");
1433        let mut start = simple_phase("start", "Start.");
1434        start.transitions.push(Transition {
1435            target: "present".to_string(),
1436            guard: Arc::new(|_| true),
1437            description: None,
1438        });
1439        machine.add_phase(start);
1440
1441        let mut present = simple_phase("present", "Present concept.");
1442        present.presents = vec!["terms".into()];
1443        present.clear_on_enter = vec!["ack".into()];
1444        machine.add_phase(present);
1445
1446        machine
1447            .transition(
1448                "present",
1449                &state,
1450                &writer,
1451                1,
1452                TransitionTrigger::Programmatic { source: "test" },
1453                &empty_tw(),
1454            )
1455            .await
1456            .expect("transition should succeed");
1457
1458        assert_eq!(
1459            state.get::<bool>(&Phase::presented_key("terms")),
1460            Some(true)
1461        );
1462        assert!(!state.contains("ack"));
1463    }
1464
1465    #[test]
1466    fn describe_navigation_lists_missing_required_state() {
1467        let state = State::new();
1468        let mut machine = PhaseMachine::new("grounded");
1469
1470        let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1471        grounded.requires = vec!["facts_loaded".into(), "price".into()];
1472        machine.add_phase(grounded);
1473
1474        let nav = machine.describe_navigation(&state);
1475        assert!(nav.contains("Blocked until required state is available"));
1476        assert!(nav.contains("facts_loaded"));
1477        assert!(nav.contains("price"));
1478    }
1479
1480    #[test]
1481    fn describe_navigation_terminal_phase() {
1482        let state = State::new();
1483        let mut machine = PhaseMachine::new("farewell");
1484
1485        let mut farewell = Phase::new("farewell", "Say goodbye.");
1486        farewell.terminal = true;
1487        machine.add_phase(farewell);
1488
1489        let nav = machine.describe_navigation(&state);
1490        assert!(nav.contains("final phase"));
1491    }
1492}