1use std::collections::{HashMap, VecDeque};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use gemini_genai_rs::session::SessionWriter;
15
16use super::SessionHook;
17use super::transcript::TranscriptWindow;
18use crate::StatePredicate;
19use crate::error::ConfigError;
20use crate::state::State;
21
22#[derive(Debug, Clone)]
26pub enum TransitionTrigger {
27 Guard {
29 transition_index: usize,
31 },
32 Programmatic {
34 source: &'static str,
36 },
37}
38
39pub enum PhaseInstruction {
41 Static(String),
43 Dynamic(Arc<dyn Fn(&State) -> String + Send + Sync>),
45}
46
47impl PhaseInstruction {
48 pub fn resolve(&self, state: &State) -> String {
50 match self {
51 PhaseInstruction::Static(s) => s.clone(),
52 PhaseInstruction::Dynamic(f) => f(state),
53 }
54 }
55
56 pub fn resolve_with_modifiers(
58 &self,
59 state: &State,
60 modifiers: &[InstructionModifier],
61 ) -> String {
62 let mut instruction = self.resolve(state);
63 for modifier in modifiers {
64 modifier.apply(&mut instruction, state);
65 }
66 instruction
67 }
68}
69
70#[derive(Clone)]
75pub enum InstructionModifier {
76 StateAppend(Vec<String>),
78 CustomAppend(Arc<dyn Fn(&State) -> String + Send + Sync>),
80 Conditional {
82 predicate: Arc<dyn Fn(&State) -> bool + Send + Sync>,
84 text: String,
86 },
87}
88
89impl InstructionModifier {
90 pub fn apply(&self, base: &mut String, state: &State) {
92 match self {
93 InstructionModifier::StateAppend(keys) => {
94 let mut pairs = Vec::with_capacity(keys.len());
95 for key in keys {
96 let display_key = key
97 .strip_prefix("derived:")
98 .or_else(|| key.strip_prefix("session:"))
99 .or_else(|| key.strip_prefix("app:"))
100 .or_else(|| key.strip_prefix("user:"))
101 .unwrap_or(key);
102 if let Some(val) = state.get::<serde_json::Value>(key) {
103 match val {
104 serde_json::Value::String(s) => {
105 pairs.push(format!("{display_key}={s}"));
106 }
107 serde_json::Value::Number(n) => {
108 pairs.push(format!("{display_key}={n}"));
109 }
110 serde_json::Value::Bool(b) => pairs.push(format!("{display_key}={b}")),
111 other => pairs.push(format!("{display_key}={other}")),
112 }
113 }
114 }
115 if !pairs.is_empty() {
116 base.push_str("\n\n[Context: ");
117 base.push_str(&pairs.join(", "));
118 base.push(']');
119 }
120 }
121 InstructionModifier::CustomAppend(f) => {
122 let text = f(state);
123 if !text.is_empty() {
124 base.push_str("\n\n");
125 base.push_str(&text);
126 }
127 }
128 InstructionModifier::Conditional { predicate, text } => {
129 if predicate(state) {
130 base.push_str("\n\n");
131 base.push_str(text);
132 }
133 }
134 }
135 }
136}
137
138pub struct Transition {
140 pub target: String,
142 pub guard: Arc<dyn Fn(&State) -> bool + Send + Sync>,
144 pub description: Option<String>,
147}
148
149pub struct PhasePreparation {
156 pub name: String,
158 pub produces: Vec<String>,
160 pub run: SessionHook,
162}
163
164pub type EnterContextFn = Arc<
166 dyn Fn(&State, &TranscriptWindow) -> Option<Vec<gemini_genai_rs::prelude::Content>>
167 + Send
168 + Sync,
169>;
170
171pub struct Phase {
173 pub name: String,
175 pub instruction: PhaseInstruction,
177 pub tools_enabled: Option<Vec<String>>,
179 pub guard: Option<StatePredicate>,
181 pub on_enter: Option<SessionHook>,
183 pub on_exit: Option<SessionHook>,
185 pub transitions: Vec<Transition>,
187 pub terminal: bool,
189 pub modifiers: Vec<InstructionModifier>,
192 pub prompt_on_enter: bool,
195 pub on_enter_context: Option<EnterContextFn>,
199 pub needs: Vec<String>,
206 pub requires: Vec<String>,
213 pub preparations: Vec<PhasePreparation>,
216 pub presents: Vec<String>,
222 pub clear_on_enter: Vec<String>,
227}
228
229impl Phase {
230 pub fn new(name: &str, instruction: &str) -> Self {
232 Self {
233 name: name.to_string(),
234 instruction: PhaseInstruction::Static(instruction.to_string()),
235 tools_enabled: None,
236 guard: None,
237 on_enter: None,
238 on_exit: None,
239 transitions: Vec::new(),
240 terminal: false,
241 modifiers: Vec::new(),
242 prompt_on_enter: false,
243 on_enter_context: None,
244 needs: Vec::new(),
245 requires: Vec::new(),
246 preparations: Vec::new(),
247 presents: Vec::new(),
248 clear_on_enter: Vec::new(),
249 }
250 }
251
252 pub fn presented_key(concept: &str) -> String {
254 format!("presented:{concept}")
255 }
256
257 pub fn is_presented(state: &State, concept: &str) -> bool {
259 state
260 .get::<bool>(&Self::presented_key(concept))
261 .unwrap_or(false)
262 }
263
264 pub fn missing_requirements(&self, state: &State) -> Vec<String> {
266 self.requires
267 .iter()
268 .filter(|key| !state.contains(key))
269 .cloned()
270 .collect()
271 }
272}
273
274pub struct TransitionRecord {
277 pub from: String,
279 pub to: String,
281 pub turn: u32,
283 pub timestamp: Instant,
285 pub trigger: TransitionTrigger,
287 pub duration_in_phase: Duration,
289}
290
291pub struct TransitionResult {
294 pub instruction: String,
296 pub context: Option<Vec<gemini_genai_rs::prelude::Content>>,
298 pub prompt_on_enter: bool,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum TransitionEvaluation {
305 Ready {
307 target: String,
309 transition_index: usize,
311 },
312 Blocked {
315 target: String,
317 transition_index: usize,
319 missing: Vec<String>,
321 },
322}
323
324const MAX_PHASE_HISTORY: usize = 100;
328
329pub struct PhaseMachine {
331 phases: HashMap<String, Phase>,
332 current: String,
333 initial: String,
334 history: VecDeque<TransitionRecord>,
335 phase_entered_at: Instant,
336}
337
338impl PhaseMachine {
339 pub fn new(initial: &str) -> Self {
344 Self {
345 phases: HashMap::new(),
346 current: initial.to_string(),
347 initial: initial.to_string(),
348 history: VecDeque::new(),
349 phase_entered_at: Instant::now(),
350 }
351 }
352
353 pub fn add_phase(&mut self, phase: Phase) {
355 self.phases.insert(phase.name.clone(), phase);
356 }
357
358 pub fn current(&self) -> &str {
360 &self.current
361 }
362
363 pub fn current_phase(&self) -> Option<&Phase> {
365 self.phases.get(&self.current)
366 }
367
368 pub fn history(&self) -> &VecDeque<TransitionRecord> {
370 &self.history
371 }
372
373 #[cfg(test)]
375 pub(crate) fn history_mut(&mut self) -> &mut VecDeque<TransitionRecord> {
376 &mut self.history
377 }
378
379 pub fn describe_navigation(&self, state: &State) -> String {
385 let mut lines = Vec::new();
386 lines.push("[Navigation]".to_string());
387
388 if let Some(phase) = self.phases.get(&self.current) {
390 let resolved = phase.instruction.resolve(state);
391 let goal = resolved.split('.').next().unwrap_or(&resolved).trim();
392 lines.push(format!("Current phase: {} — {}", self.current, goal));
393
394 if !self.history.is_empty() {
396 let recent: Vec<String> = self
397 .history
398 .iter()
399 .rev()
400 .take(3)
401 .collect::<Vec<_>>()
402 .into_iter()
403 .rev()
404 .map(|h| format!("{} (turn {})", h.from, h.turn))
405 .collect();
406 lines.push(format!("Previous: {}", recent.join(", ")));
407 }
408
409 let missing: Vec<&str> = phase
411 .needs
412 .iter()
413 .filter(|key| !state.contains(key))
414 .map(std::string::String::as_str)
415 .collect();
416 if !missing.is_empty() {
417 lines.push(format!("Still needed: {}", missing.join(", ")));
418 }
419
420 let missing_required: Vec<&str> = phase
422 .requires
423 .iter()
424 .filter(|key| !state.contains(key))
425 .map(std::string::String::as_str)
426 .collect();
427 if !missing_required.is_empty() {
428 lines.push(format!(
429 "Blocked until required state is available: {}",
430 missing_required.join(", ")
431 ));
432 }
433 if !phase.preparations.is_empty() {
434 let preparations: Vec<&str> =
435 phase.preparations.iter().map(|p| p.name.as_str()).collect();
436 lines.push(format!("Preparers: {}", preparations.join(", ")));
437 }
438 if !phase.presents.is_empty() {
439 lines.push(format!("Presents: {}", phase.presents.join(", ")));
440 }
441
442 if phase.terminal {
444 lines.push("This is the final phase.".to_string());
445 } else if !phase.transitions.is_empty() {
446 lines.push("Possible next:".to_string());
447 for t in &phase.transitions {
448 if let Some(ref desc) = t.description {
449 lines.push(format!(" → {}: {}", t.target, desc));
450 } else {
451 lines.push(format!(" → {}", t.target));
452 }
453 }
454 }
455 }
456
457 lines.join("\n")
458 }
459
460 pub fn evaluate(&self, state: &State) -> Option<(&str, usize)> {
468 match self.evaluate_for_transition(state)? {
469 TransitionEvaluation::Ready {
470 transition_index, ..
471 } => {
472 let phase = self.phases.get(&self.current)?;
473 let target = phase.transitions.get(transition_index)?.target.as_str();
474 Some((target, transition_index))
475 }
476 TransitionEvaluation::Blocked { .. } => None,
477 }
478 }
479
480 pub fn evaluate_for_transition(&self, state: &State) -> Option<TransitionEvaluation> {
483 let phase = self.phases.get(&self.current)?;
484 if phase.terminal {
485 return None;
486 }
487 for (index, transition) in phase.transitions.iter().enumerate() {
488 if (transition.guard)(state) {
489 if let Some(target_phase) = self.phases.get(&transition.target) {
492 if let Some(ref phase_guard) = target_phase.guard
493 && !phase_guard(state)
494 {
495 continue;
496 }
497 let missing = target_phase.missing_requirements(state);
498 if missing.is_empty() {
499 return Some(TransitionEvaluation::Ready {
500 target: transition.target.clone(),
501 transition_index: index,
502 });
503 }
504 if !target_phase.preparations.is_empty() {
505 return Some(TransitionEvaluation::Blocked {
506 target: transition.target.clone(),
507 transition_index: index,
508 missing,
509 });
510 } else {
511 continue;
512 }
513 }
514 return Some(TransitionEvaluation::Ready {
515 target: transition.target.clone(),
516 transition_index: index,
517 });
518 }
519 }
520 None
521 }
522
523 pub async fn prepare_target(
526 &self,
527 target: &str,
528 state: &State,
529 writer: &Arc<dyn SessionWriter>,
530 ) -> bool {
531 let Some(phase) = self.phases.get(target) else {
532 return false;
533 };
534
535 for preparation in &phase.preparations {
536 let fut = (preparation.run)(state.clone(), Arc::clone(writer));
537 fut.await;
538 }
539
540 phase.missing_requirements(state).is_empty()
541 }
542
543 pub async fn transition(
549 &mut self,
550 target: &str,
551 state: &State,
552 writer: &Arc<dyn SessionWriter>,
553 turn: u32,
554 trigger: TransitionTrigger,
555 transcript_window: &TranscriptWindow,
556 ) -> Option<TransitionResult> {
557 if !self.phases.contains_key(target) {
559 return None;
560 }
561
562 let from = self.current.clone();
563 let duration_in_phase = self.phase_entered_at.elapsed();
564
565 if let Some(phase) = self.phases.get(&from)
567 && let Some(ref on_exit) = phase.on_exit
568 {
569 let fut = on_exit(state.clone(), Arc::clone(writer));
570 fut.await;
571 }
572
573 self.current = target.to_string();
575 self.phase_entered_at = Instant::now();
576
577 if let Some(phase) = self.phases.get(target) {
579 for key in &phase.clear_on_enter {
580 state.remove(key);
581 }
582 for concept in &phase.presents {
583 let _ = state.set(Phase::presented_key(concept), true);
584 }
585 if let Some(ref on_enter) = phase.on_enter {
586 let fut = on_enter(state.clone(), Arc::clone(writer));
587 fut.await;
588 }
589 }
590
591 if self.history.len() >= MAX_PHASE_HISTORY {
593 self.history.pop_front();
594 }
595 self.history.push_back(TransitionRecord {
596 from,
597 to: target.to_string(),
598 turn,
599 timestamp: Instant::now(),
600 trigger,
601 duration_in_phase,
602 });
603
604 let phase = self.phases.get(target)?;
606 let instruction = phase
607 .instruction
608 .resolve_with_modifiers(state, &phase.modifiers);
609 let context = phase
610 .on_enter_context
611 .as_ref()
612 .and_then(|f| f(state, transcript_window));
613 let prompt_on_enter = phase.prompt_on_enter;
614
615 Some(TransitionResult {
616 instruction,
617 context,
618 prompt_on_enter,
619 })
620 }
621
622 pub fn current_phase_duration(&self) -> Duration {
624 self.phase_entered_at.elapsed()
625 }
626
627 pub fn active_tools(&self) -> Option<&[String]> {
632 self.phases
633 .get(&self.current)
634 .and_then(|p| p.tools_enabled.as_deref())
635 }
636
637 pub fn validate(&self) -> Result<(), ConfigError> {
644 if self.phases.is_empty() {
645 return Err(ConfigError::new("no phases registered"));
646 }
647 if !self.phases.contains_key(&self.initial) {
648 return Err(ConfigError::new(format!(
649 "initial phase '{}' not found in registered phases",
650 self.initial
651 )));
652 }
653 for phase in self.phases.values() {
654 for transition in &phase.transitions {
655 if !self.phases.contains_key(&transition.target) {
656 return Err(ConfigError::new(format!(
657 "phase '{}' has transition to unknown target '{}'",
658 phase.name, transition.target
659 )));
660 }
661 }
662 }
663 Ok(())
664 }
665}
666
667#[cfg(test)]
670mod tests {
671 use super::*;
672
673 use super::super::transcript::TranscriptWindow;
674
675 fn simple_phase(name: &str, instruction: &str) -> Phase {
677 Phase {
678 name: name.to_string(),
679 instruction: PhaseInstruction::Static(instruction.to_string()),
680 tools_enabled: None,
681 guard: None,
682 on_enter: None,
683 on_exit: None,
684 transitions: Vec::new(),
685 terminal: false,
686 modifiers: Vec::new(),
687 prompt_on_enter: false,
688 on_enter_context: None,
689 needs: Vec::new(),
690 requires: Vec::new(),
691 preparations: Vec::new(),
692 presents: Vec::new(),
693 clear_on_enter: Vec::new(),
694 }
695 }
696
697 fn terminal_phase(name: &str, instruction: &str) -> Phase {
699 Phase {
700 name: name.to_string(),
701 instruction: PhaseInstruction::Static(instruction.to_string()),
702 tools_enabled: None,
703 guard: None,
704 on_enter: None,
705 on_exit: None,
706 transitions: Vec::new(),
707 terminal: true,
708 modifiers: Vec::new(),
709 prompt_on_enter: false,
710 on_enter_context: None,
711 needs: Vec::new(),
712 requires: Vec::new(),
713 preparations: Vec::new(),
714 presents: Vec::new(),
715 clear_on_enter: Vec::new(),
716 }
717 }
718
719 fn empty_tw() -> TranscriptWindow {
721 TranscriptWindow::new(vec![])
722 }
723
724 #[test]
727 fn new_and_add_phase_and_current() {
728 let mut machine = PhaseMachine::new("greeting");
729 machine.add_phase(simple_phase("greeting", "Say hello"));
730 assert_eq!(machine.current(), "greeting");
731 assert!(machine.current_phase().is_some());
732 assert!(machine.history().is_empty());
733 }
734
735 #[test]
738 fn evaluate_single_transition_fires() {
739 let state = State::new();
740 let _ = state.set("ready", true);
741
742 let mut greeting = simple_phase("greeting", "Say hello");
743 greeting.transitions.push(Transition {
744 target: "main".to_string(),
745 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
746 description: None,
747 });
748
749 let mut machine = PhaseMachine::new("greeting");
750 machine.add_phase(greeting);
751 machine.add_phase(simple_phase("main", "Main phase"));
752
753 assert_eq!(machine.evaluate(&state), Some(("main", 0)));
754 }
755
756 #[test]
759 fn evaluate_single_transition_does_not_fire() {
760 let state = State::new();
761 let mut greeting = simple_phase("greeting", "Say hello");
764 greeting.transitions.push(Transition {
765 target: "main".to_string(),
766 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
767 description: None,
768 });
769
770 let mut machine = PhaseMachine::new("greeting");
771 machine.add_phase(greeting);
772 machine.add_phase(simple_phase("main", "Main phase"));
773
774 assert_eq!(machine.evaluate(&state), None);
775 }
776
777 #[test]
780 fn evaluate_multiple_transitions_first_match_wins() {
781 let state = State::new();
782 let _ = state.set("escalate", true);
783 let _ = state.set("done", true);
784
785 let mut greeting = simple_phase("greeting", "Say hello");
786 greeting.transitions.push(Transition {
787 target: "escalated".to_string(),
788 guard: Arc::new(|s: &State| s.get::<bool>("escalate").unwrap_or(false)),
789 description: None,
790 });
791 greeting.transitions.push(Transition {
792 target: "farewell".to_string(),
793 guard: Arc::new(|s: &State| s.get::<bool>("done").unwrap_or(false)),
794 description: None,
795 });
796
797 let mut machine = PhaseMachine::new("greeting");
798 machine.add_phase(greeting);
799 machine.add_phase(simple_phase("escalated", "Escalated"));
800 machine.add_phase(simple_phase("farewell", "Farewell"));
801
802 assert_eq!(machine.evaluate(&state), Some(("escalated", 0)));
804 }
805
806 #[test]
809 fn evaluate_terminal_phase_returns_none() {
810 let state = State::new();
811 let _ = state.set("anything", true);
812
813 let mut term = terminal_phase("end", "Goodbye");
814 term.transitions.push(Transition {
816 target: "other".to_string(),
817 guard: Arc::new(|_| true),
818 description: None,
819 });
820
821 let mut machine = PhaseMachine::new("end");
822 machine.add_phase(term);
823 machine.add_phase(simple_phase("other", "Other"));
824
825 assert_eq!(machine.evaluate(&state), None);
826 }
827
828 #[tokio::test]
831 async fn transition_updates_current_and_records_history() {
832 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
833 let state = State::new();
834
835 let mut machine = PhaseMachine::new("greeting");
836 machine.add_phase(simple_phase("greeting", "Say hello"));
837 machine.add_phase(simple_phase("main", "Main phase instruction"));
838
839 let trigger = TransitionTrigger::Guard {
840 transition_index: 0,
841 };
842 let tw = empty_tw();
843 let result = machine
844 .transition("main", &state, &writer, 1, trigger, &tw)
845 .await;
846 assert_eq!(
847 result.as_ref().map(|r| r.instruction.as_str()),
848 Some("Main phase instruction")
849 );
850 assert_eq!(machine.current(), "main");
851 assert_eq!(machine.history().len(), 1);
852 assert_eq!(machine.history()[0].from, "greeting");
853 assert_eq!(machine.history()[0].to, "main");
854 assert_eq!(machine.history()[0].turn, 1);
855 assert!(matches!(
856 machine.history()[0].trigger,
857 TransitionTrigger::Guard {
858 transition_index: 0
859 }
860 ));
861 }
862
863 #[test]
866 fn active_tools_returns_filter() {
867 let mut phase = simple_phase("filtered", "Filtered phase");
868 phase.tools_enabled = Some(vec!["search".to_string(), "lookup".to_string()]);
869
870 let mut machine = PhaseMachine::new("filtered");
871 machine.add_phase(phase);
872
873 let tools = machine.active_tools().unwrap();
874 assert_eq!(tools.len(), 2);
875 assert!(tools.contains(&"search".to_string()));
876 assert!(tools.contains(&"lookup".to_string()));
877 }
878
879 #[test]
882 fn active_tools_returns_none_when_no_filter() {
883 let mut machine = PhaseMachine::new("open");
884 machine.add_phase(simple_phase("open", "All tools allowed"));
885
886 assert!(machine.active_tools().is_none());
887 }
888
889 #[test]
892 fn validate_catches_missing_initial_phase() {
893 let mut machine = PhaseMachine::new("nonexistent");
894 machine.add_phase(simple_phase("greeting", "Hi"));
895
896 let err = machine.validate().unwrap_err();
897 assert!(
898 err.to_string()
899 .contains("initial phase 'nonexistent' not found")
900 );
901 }
902
903 #[test]
906 fn validate_catches_invalid_transition_target() {
907 let mut greeting = simple_phase("greeting", "Hi");
908 greeting.transitions.push(Transition {
909 target: "missing_phase".to_string(),
910 guard: Arc::new(|_| true),
911 description: None,
912 });
913
914 let mut machine = PhaseMachine::new("greeting");
915 machine.add_phase(greeting);
916
917 let err = machine.validate().unwrap_err();
918 assert!(err.to_string().contains("unknown target 'missing_phase'"));
919 }
920
921 #[test]
924 fn validate_succeeds_on_valid_config() {
925 let mut greeting = simple_phase("greeting", "Hi");
926 greeting.transitions.push(Transition {
927 target: "main".to_string(),
928 guard: Arc::new(|_| true),
929 description: None,
930 });
931
932 let mut machine = PhaseMachine::new("greeting");
933 machine.add_phase(greeting);
934 machine.add_phase(simple_phase("main", "Main"));
935
936 assert!(machine.validate().is_ok());
937 }
938
939 #[test]
942 fn phase_instruction_static_resolves() {
943 let state = State::new();
944 let instr = PhaseInstruction::Static("You are a helpful assistant.".to_string());
945 assert_eq!(instr.resolve(&state), "You are a helpful assistant.");
946 }
947
948 #[test]
951 fn phase_instruction_dynamic_resolves() {
952 let state = State::new();
953 let _ = state.set("user_name", "Alice");
954
955 let instr = PhaseInstruction::Dynamic(Arc::new(|s: &State| {
956 let name: String = s.get("user_name").unwrap_or_default();
957 format!("Greet the user named {name}.")
958 }));
959
960 assert_eq!(instr.resolve(&state), "Greet the user named Alice.");
961 }
962
963 #[test]
966 fn validate_catches_no_phases() {
967 let machine = PhaseMachine::new("greeting");
968 let err = machine.validate().unwrap_err();
969 assert!(err.to_string().contains("no phases registered"));
970 }
971
972 #[tokio::test]
975 async fn transition_to_nonexistent_target_returns_none() {
976 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
977 let state = State::new();
978
979 let mut machine = PhaseMachine::new("greeting");
980 machine.add_phase(simple_phase("greeting", "Hi"));
981
982 let trigger = TransitionTrigger::Programmatic { source: "test" };
983 let tw = empty_tw();
984 let result = machine
985 .transition("no_such_phase", &state, &writer, 0, trigger, &tw)
986 .await;
987 assert!(result.is_none());
988 assert_eq!(machine.current(), "greeting");
990 }
991
992 #[tokio::test]
995 async fn transition_runs_on_enter_and_on_exit_callbacks() {
996 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
997 let state = State::new();
998
999 let mut greeting = simple_phase("greeting", "Hi");
1000 greeting.on_exit = Some(Arc::new(|s: State, _w: Arc<dyn SessionWriter>| {
1001 Box::pin(async move {
1002 let _ = s.set("exited_greeting", true);
1003 })
1004 }));
1005
1006 let mut main = simple_phase("main", "Main");
1007 main.on_enter = Some(Arc::new(|s: State, _w: Arc<dyn SessionWriter>| {
1008 Box::pin(async move {
1009 let _ = s.set("entered_main", true);
1010 })
1011 }));
1012
1013 let mut machine = PhaseMachine::new("greeting");
1014 machine.add_phase(greeting);
1015 machine.add_phase(main);
1016
1017 let trigger = TransitionTrigger::Programmatic { source: "test" };
1018 let tw = empty_tw();
1019 machine
1020 .transition("main", &state, &writer, 1, trigger, &tw)
1021 .await;
1022
1023 assert_eq!(state.get::<bool>("exited_greeting"), Some(true));
1024 assert_eq!(state.get::<bool>("entered_main"), Some(true));
1025 }
1026
1027 #[tokio::test]
1030 async fn multiple_transitions_accumulate_history() {
1031 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1032 let state = State::new();
1033
1034 let mut machine = PhaseMachine::new("a");
1035 machine.add_phase(simple_phase("a", "Phase A"));
1036 machine.add_phase(simple_phase("b", "Phase B"));
1037 machine.add_phase(simple_phase("c", "Phase C"));
1038
1039 let trigger1 = TransitionTrigger::Guard {
1040 transition_index: 0,
1041 };
1042 let tw = empty_tw();
1043 machine
1044 .transition("b", &state, &writer, 1, trigger1, &tw)
1045 .await;
1046 let trigger2 = TransitionTrigger::Programmatic { source: "test" };
1047 machine
1048 .transition("c", &state, &writer, 3, trigger2, &tw)
1049 .await;
1050
1051 assert_eq!(machine.current(), "c");
1052 assert_eq!(machine.history().len(), 2);
1053 assert_eq!(machine.history()[0].from, "a");
1054 assert_eq!(machine.history()[0].to, "b");
1055 assert_eq!(machine.history()[0].turn, 1);
1056 assert!(matches!(
1057 machine.history()[0].trigger,
1058 TransitionTrigger::Guard {
1059 transition_index: 0
1060 }
1061 ));
1062 assert_eq!(machine.history()[1].from, "b");
1063 assert_eq!(machine.history()[1].to, "c");
1064 assert_eq!(machine.history()[1].turn, 3);
1065 assert!(matches!(
1066 machine.history()[1].trigger,
1067 TransitionTrigger::Programmatic { source: "test" }
1068 ));
1069 }
1070
1071 #[tokio::test]
1074 async fn transition_resolves_dynamic_instruction() {
1075 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1076 let state = State::new();
1077 let _ = state.set("topic", "weather");
1078
1079 let dynamic_phase = Phase {
1080 name: "dynamic".to_string(),
1081 instruction: PhaseInstruction::Dynamic(Arc::new(|s: &State| {
1082 let topic: String = s.get("topic").unwrap_or_default();
1083 format!("Discuss {topic}.")
1084 })),
1085 tools_enabled: None,
1086 guard: None,
1087 on_enter: None,
1088 on_exit: None,
1089 transitions: Vec::new(),
1090 terminal: false,
1091 modifiers: Vec::new(),
1092 prompt_on_enter: false,
1093 on_enter_context: None,
1094 needs: Vec::new(),
1095 requires: Vec::new(),
1096 preparations: Vec::new(),
1097 presents: Vec::new(),
1098 clear_on_enter: Vec::new(),
1099 };
1100
1101 let mut machine = PhaseMachine::new("start");
1102 machine.add_phase(simple_phase("start", "Begin"));
1103 machine.add_phase(dynamic_phase);
1104
1105 let trigger = TransitionTrigger::Programmatic { source: "test" };
1106 let tw = empty_tw();
1107 let result = machine
1108 .transition("dynamic", &state, &writer, 1, trigger, &tw)
1109 .await;
1110 assert_eq!(
1111 result.as_ref().map(|r| r.instruction.as_str()),
1112 Some("Discuss weather.")
1113 );
1114 }
1115
1116 #[test]
1119 fn phase_guard_blocks_transition() {
1120 let state = State::new();
1121 let _ = state.set("ready", true);
1122 let mut greeting = simple_phase("greeting", "Say hello");
1125 greeting.transitions.push(Transition {
1126 target: "secure".to_string(),
1127 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1128 description: None,
1129 });
1130
1131 let mut secure = simple_phase("secure", "Secure area");
1133 secure.guard = Some(Arc::new(|s: &State| {
1134 s.get::<bool>("verified").unwrap_or(false)
1135 }));
1136
1137 let mut machine = PhaseMachine::new("greeting");
1138 machine.add_phase(greeting);
1139 machine.add_phase(secure);
1140
1141 assert_eq!(machine.evaluate(&state), None);
1144 }
1145
1146 #[test]
1147 fn phase_guard_allows_transition_when_satisfied() {
1148 let state = State::new();
1149 let _ = state.set("ready", true);
1150 let _ = state.set("verified", true);
1151
1152 let mut greeting = simple_phase("greeting", "Say hello");
1153 greeting.transitions.push(Transition {
1154 target: "secure".to_string(),
1155 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1156 description: None,
1157 });
1158
1159 let mut secure = simple_phase("secure", "Secure area");
1161 secure.guard = Some(Arc::new(|s: &State| {
1162 s.get::<bool>("verified").unwrap_or(false)
1163 }));
1164
1165 let mut machine = PhaseMachine::new("greeting");
1166 machine.add_phase(greeting);
1167 machine.add_phase(secure);
1168
1169 assert_eq!(machine.evaluate(&state), Some(("secure", 0)));
1171 }
1172
1173 #[test]
1174 fn phase_guard_skips_to_next_transition() {
1175 let state = State::new();
1176 let _ = state.set("ready", true);
1177 let mut greeting = simple_phase("greeting", "Say hello");
1180 greeting.transitions.push(Transition {
1182 target: "secure".to_string(),
1183 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1184 description: None,
1185 });
1186 greeting.transitions.push(Transition {
1188 target: "fallback".to_string(),
1189 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1190 description: None,
1191 });
1192
1193 let mut secure = simple_phase("secure", "Secure area");
1194 secure.guard = Some(Arc::new(|s: &State| {
1195 s.get::<bool>("verified").unwrap_or(false)
1196 }));
1197
1198 let mut machine = PhaseMachine::new("greeting");
1199 machine.add_phase(greeting);
1200 machine.add_phase(secure);
1201 machine.add_phase(simple_phase("fallback", "Fallback"));
1202
1203 assert_eq!(machine.evaluate(&state), Some(("fallback", 1)));
1206 }
1207
1208 #[test]
1211 fn instruction_modifier_state_append() {
1212 let state = State::new();
1213 let _ = state.set("emotion", "happy");
1214 let _ = state.set("score", 0.8f64);
1215
1216 let modifier =
1217 InstructionModifier::StateAppend(vec!["emotion".to_string(), "score".to_string()]);
1218 let mut base = "You are an assistant.".to_string();
1219 modifier.apply(&mut base, &state);
1220 assert!(base.contains("[Context: emotion=happy, score=0.8]"));
1221 }
1222
1223 #[test]
1224 fn instruction_modifier_conditional_true() {
1225 let state = State::new();
1226 let _ = state.set("risk", "high");
1227
1228 let modifier = InstructionModifier::Conditional {
1229 predicate: Arc::new(|s: &State| s.get::<String>("risk").unwrap_or_default() == "high"),
1230 text: "IMPORTANT: Use extra empathy.".to_string(),
1231 };
1232 let mut base = "Base instruction.".to_string();
1233 modifier.apply(&mut base, &state);
1234 assert!(base.contains("IMPORTANT: Use extra empathy."));
1235 }
1236
1237 #[test]
1238 fn instruction_modifier_conditional_false() {
1239 let state = State::new();
1240 let _ = state.set("risk", "low");
1241
1242 let modifier = InstructionModifier::Conditional {
1243 predicate: Arc::new(|s: &State| s.get::<String>("risk").unwrap_or_default() == "high"),
1244 text: "IMPORTANT: Use extra empathy.".to_string(),
1245 };
1246 let mut base = "Base instruction.".to_string();
1247 modifier.apply(&mut base, &state);
1248 assert!(!base.contains("IMPORTANT"));
1249 }
1250
1251 #[test]
1252 fn resolve_with_modifiers_composes() {
1253 let state = State::new();
1254 let _ = state.set("mood", "calm");
1255
1256 let instr = PhaseInstruction::Static("You are helpful.".to_string());
1257 let modifiers = vec![InstructionModifier::StateAppend(vec!["mood".to_string()])];
1258 let result = instr.resolve_with_modifiers(&state, &modifiers);
1259 assert!(result.starts_with("You are helpful."));
1260 assert!(result.contains("[Context: mood=calm]"));
1261 }
1262
1263 #[test]
1266 fn describe_navigation_basic() {
1267 let state = State::new();
1268 let _ = state.set("caller_name", "Vamsi");
1269
1270 let mut machine = PhaseMachine::new("greeting");
1271
1272 let mut greeting = Phase::new(
1273 "greeting",
1274 "Greet the caller warmly and ask who is calling.",
1275 );
1276 greeting.transitions.push(Transition {
1277 target: "identify".to_string(),
1278 guard: Arc::new(|_| false),
1279 description: Some("after initial greeting".into()),
1280 });
1281 machine.add_phase(greeting);
1282
1283 let mut identify = Phase::new("identify", "Get the caller's name.");
1284 identify.needs = vec!["caller_name".into(), "caller_org".into()];
1285 identify.transitions.push(Transition {
1286 target: "purpose".to_string(),
1287 guard: Arc::new(|_| false),
1288 description: Some("when caller is identified".into()),
1289 });
1290 machine.add_phase(identify);
1291
1292 let nav = machine.describe_navigation(&state);
1293 assert!(nav.contains("[Navigation]"));
1294 assert!(nav.contains("Current phase: greeting"));
1295 assert!(nav.contains("→ identify: after initial greeting"));
1296 }
1297
1298 #[test]
1299 fn describe_navigation_with_history_and_needs() {
1300 let state = State::new();
1301 let _ = state.set("caller_name", "Vamsi");
1303
1304 let mut machine = PhaseMachine::new("identify");
1305
1306 let greeting = Phase::new("greeting", "Greet caller.");
1307 machine.add_phase(greeting);
1308
1309 let mut identify = Phase::new("identify", "Get the caller's name and organization.");
1310 identify.needs = vec!["caller_name".into(), "caller_org".into()];
1311 identify.transitions.push(Transition {
1312 target: "purpose".to_string(),
1313 guard: Arc::new(|_| false),
1314 description: Some("when caller is identified".into()),
1315 });
1316 machine.add_phase(identify);
1317
1318 let purpose = Phase::new("purpose", "Ask why they are calling.");
1319 machine.add_phase(purpose);
1320
1321 machine.history_mut().push_back(TransitionRecord {
1323 from: "greeting".to_string(),
1324 to: "identify".to_string(),
1325 turn: 2,
1326 trigger: TransitionTrigger::Guard {
1327 transition_index: 0,
1328 },
1329 timestamp: std::time::Instant::now(),
1330 duration_in_phase: Duration::from_secs(5),
1331 });
1332
1333 let nav = machine.describe_navigation(&state);
1334 assert!(nav.contains("Previous:"), "Should show history");
1335 assert!(nav.contains("greeting"), "Should mention previous phase");
1336 assert!(
1337 nav.contains("Still needed: caller_org"),
1338 "caller_org should be listed as needed (caller_name is set)"
1339 );
1340 assert!(
1341 !nav.contains("caller_name"),
1342 "caller_name should NOT be in still-needed (it's set)"
1343 );
1344 }
1345
1346 #[test]
1347 fn evaluate_skips_target_when_required_state_missing() {
1348 let state = State::new();
1349 let mut machine = PhaseMachine::new("start");
1350
1351 let mut start = simple_phase("start", "Start.");
1352 start.transitions.push(Transition {
1353 target: "grounded".to_string(),
1354 guard: Arc::new(|_| true),
1355 description: Some("when ready".into()),
1356 });
1357 machine.add_phase(start);
1358
1359 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1360 grounded.requires = vec!["facts_loaded".into()];
1361 machine.add_phase(grounded);
1362
1363 assert!(machine.evaluate(&state).is_none());
1364
1365 let _ = state.set("facts_loaded", true);
1366 assert_eq!(
1367 machine.evaluate(&state).map(|(target, _)| target),
1368 Some("grounded")
1369 );
1370 }
1371
1372 #[test]
1373 fn evaluate_reports_blocked_target_with_preparation() {
1374 let state = State::new();
1375 let mut machine = PhaseMachine::new("start");
1376
1377 let mut start = simple_phase("start", "Start.");
1378 start.transitions.push(Transition {
1379 target: "grounded".to_string(),
1380 guard: Arc::new(|_| true),
1381 description: Some("when ready".into()),
1382 });
1383 machine.add_phase(start);
1384
1385 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1386 grounded.requires = vec!["facts_loaded".into()];
1387 grounded.preparations.push(PhasePreparation {
1388 name: "load_facts".into(),
1389 produces: vec!["facts_loaded".into()],
1390 run: Arc::new(|state, _writer| {
1391 Box::pin(async move {
1392 let _ = state.set("facts_loaded", true);
1393 })
1394 }),
1395 });
1396 machine.add_phase(grounded);
1397
1398 assert_eq!(
1399 machine.evaluate_for_transition(&state),
1400 Some(TransitionEvaluation::Blocked {
1401 target: "grounded".into(),
1402 transition_index: 0,
1403 missing: vec!["facts_loaded".into()],
1404 })
1405 );
1406 }
1407
1408 #[tokio::test]
1409 async fn prepare_target_materializes_required_state() {
1410 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1411 let state = State::new();
1412 let mut machine = PhaseMachine::new("start");
1413
1414 machine.add_phase(simple_phase("start", "Start."));
1415
1416 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1417 grounded.requires = vec!["facts_loaded".into()];
1418 grounded.preparations.push(PhasePreparation {
1419 name: "load_facts".into(),
1420 produces: vec!["facts_loaded".into()],
1421 run: Arc::new(|state, _writer| {
1422 Box::pin(async move {
1423 let _ = state.set("facts_loaded", true);
1424 })
1425 }),
1426 });
1427 machine.add_phase(grounded);
1428
1429 assert!(machine.prepare_target("grounded", &state, &writer).await);
1430 assert_eq!(state.get::<bool>("facts_loaded"), Some(true));
1431 }
1432
1433 #[tokio::test]
1434 async fn transition_marks_presented_concepts_and_clears_stale_keys() {
1435 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1436 let state = State::new();
1437 let _ = state.set("ack", true);
1438
1439 let mut machine = PhaseMachine::new("start");
1440 let mut start = simple_phase("start", "Start.");
1441 start.transitions.push(Transition {
1442 target: "present".to_string(),
1443 guard: Arc::new(|_| true),
1444 description: None,
1445 });
1446 machine.add_phase(start);
1447
1448 let mut present = simple_phase("present", "Present concept.");
1449 present.presents = vec!["terms".into()];
1450 present.clear_on_enter = vec!["ack".into()];
1451 machine.add_phase(present);
1452
1453 machine
1454 .transition(
1455 "present",
1456 &state,
1457 &writer,
1458 1,
1459 TransitionTrigger::Programmatic { source: "test" },
1460 &empty_tw(),
1461 )
1462 .await
1463 .expect("transition should succeed");
1464
1465 assert_eq!(
1466 state.get::<bool>(&Phase::presented_key("terms")),
1467 Some(true)
1468 );
1469 assert!(!state.contains("ack"));
1470 }
1471
1472 #[test]
1473 fn describe_navigation_lists_missing_required_state() {
1474 let state = State::new();
1475 let mut machine = PhaseMachine::new("grounded");
1476
1477 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1478 grounded.requires = vec!["facts_loaded".into(), "price".into()];
1479 machine.add_phase(grounded);
1480
1481 let nav = machine.describe_navigation(&state);
1482 assert!(nav.contains("Blocked until required state is available"));
1483 assert!(nav.contains("facts_loaded"));
1484 assert!(nav.contains("price"));
1485 }
1486
1487 #[test]
1488 fn describe_navigation_terminal_phase() {
1489 let state = State::new();
1490 let mut machine = PhaseMachine::new("farewell");
1491
1492 let mut farewell = Phase::new("farewell", "Say goodbye.");
1493 farewell.terminal = true;
1494 machine.add_phase(farewell);
1495
1496 let nav = machine.describe_navigation(&state);
1497 assert!(nav.contains("final phase"));
1498 }
1499}