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