1use 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#[derive(Debug, Clone)]
28pub enum TransitionTrigger {
29 Guard {
31 transition_index: usize,
33 },
34 Programmatic {
36 source: &'static str,
38 },
39}
40
41pub enum PhaseInstruction {
43 Static(String),
45 Dynamic(Arc<dyn Fn(&State) -> String + Send + Sync>),
47}
48
49impl PhaseInstruction {
50 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 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#[derive(Clone)]
77pub enum InstructionModifier {
78 StateAppend(Vec<String>),
80 CustomAppend(Arc<dyn Fn(&State) -> String + Send + Sync>),
82 Conditional {
84 predicate: Arc<dyn Fn(&State) -> bool + Send + Sync>,
86 text: String,
88 },
89}
90
91impl InstructionModifier {
92 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
140pub struct Transition {
142 pub target: String,
144 pub guard: Arc<dyn Fn(&State) -> bool + Send + Sync>,
146 pub description: Option<String>,
149}
150
151pub struct PhasePreparation {
158 pub name: String,
160 pub produces: Vec<String>,
162 pub run: SessionHook,
164}
165
166pub type EnterContextFn = Arc<
168 dyn Fn(&State, &TranscriptWindow) -> Option<Vec<gemini_genai_rs::prelude::Content>>
169 + Send
170 + Sync,
171>;
172
173pub struct Phase {
175 pub name: String,
177 pub instruction: PhaseInstruction,
179 pub tools_enabled: Option<Vec<String>>,
181 pub guard: Option<StatePredicate>,
183 pub on_enter: Option<SessionHook>,
185 pub on_exit: Option<SessionHook>,
187 pub transitions: Vec<Transition>,
189 pub terminal: bool,
191 pub modifiers: Vec<InstructionModifier>,
194 pub prompt_on_enter: bool,
197 pub on_enter_context: Option<EnterContextFn>,
201 pub needs: Vec<String>,
208 pub requires: Vec<String>,
215 pub preparations: Vec<PhasePreparation>,
218 pub presents: Vec<String>,
224 pub clear_on_enter: Vec<String>,
229}
230
231impl Phase {
232 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 pub fn presented_key(concept: &str) -> String {
256 format!("presented:{concept}")
257 }
258
259 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 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
276pub struct TransitionRecord {
279 pub from: String,
281 pub to: String,
283 pub turn: u32,
285 pub timestamp: Instant,
287 pub trigger: TransitionTrigger,
289 pub duration_in_phase: Duration,
291}
292
293pub struct TransitionResult {
296 pub instruction: String,
298 pub context: Option<Vec<gemini_genai_rs::prelude::Content>>,
300 pub prompt_on_enter: bool,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum TransitionEvaluation {
307 Ready {
309 target: String,
311 transition_index: usize,
313 },
314 Blocked {
317 target: String,
319 transition_index: usize,
321 missing: Vec<String>,
323 },
324}
325
326const MAX_PHASE_HISTORY: usize = 100;
330
331pub 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 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 pub fn set_clock(&mut self, clock: SharedClock) {
363 self.phase_entered_at = clock.now();
364 self.clock = clock;
365 }
366
367 pub fn add_phase(&mut self, phase: Phase) {
369 self.phases.insert(phase.name.clone(), phase);
370 }
371
372 pub fn current(&self) -> &str {
374 &self.current
375 }
376
377 pub fn current_phase(&self) -> Option<&Phase> {
379 self.phases.get(&self.current)
380 }
381
382 pub fn history(&self) -> &VecDeque<TransitionRecord> {
384 &self.history
385 }
386
387 #[cfg(test)]
389 pub(crate) fn history_mut(&mut self) -> &mut VecDeque<TransitionRecord> {
390 &mut self.history
391 }
392
393 pub fn describe_navigation(&self, state: &State) -> String {
399 let mut lines = Vec::new();
400 lines.push("[Navigation]".to_string());
401
402 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 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 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 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 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 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 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 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 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 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 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 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 self.current = target.to_string();
589 self.phase_entered_at = self.clock.now();
590
591 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 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 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 pub fn current_phase_duration(&self) -> Duration {
638 self.clock.since(self.phase_entered_at)
639 }
640
641 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 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#[cfg(test)]
684mod tests {
685 use super::*;
686
687 use super::super::transcript::TranscriptWindow;
688
689 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 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 fn empty_tw() -> TranscriptWindow {
735 TranscriptWindow::new(vec![])
736 }
737
738 #[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 #[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 #[test]
773 fn evaluate_single_transition_does_not_fire() {
774 let state = State::new();
775 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 #[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 assert_eq!(machine.evaluate(&state), Some(("escalated", 0)));
818 }
819
820 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(machine.current(), "greeting");
1004 }
1005
1006 #[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 #[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 #[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 #[test]
1133 fn phase_guard_blocks_transition() {
1134 let state = State::new();
1135 let _ = state.set("ready", true);
1136 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 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 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 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 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 let mut greeting = simple_phase("greeting", "Say hello");
1194 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 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 assert_eq!(machine.evaluate(&state), Some(("fallback", 1)));
1220 }
1221
1222 #[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 #[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 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 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}