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