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