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