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: Arc<dyn Fn(State, Arc<dyn SessionWriter>) -> BoxFuture<()> + Send + Sync>,
160}
161
162pub struct Phase {
164 pub name: String,
166 pub instruction: PhaseInstruction,
168 pub tools_enabled: Option<Vec<String>>,
170 pub guard: Option<Arc<dyn Fn(&State) -> bool + Send + Sync>>,
172 pub on_enter: Option<Arc<dyn Fn(State, Arc<dyn SessionWriter>) -> BoxFuture<()> + Send + Sync>>,
174 pub on_exit: Option<Arc<dyn Fn(State, Arc<dyn SessionWriter>) -> BoxFuture<()> + Send + Sync>>,
176 pub transitions: Vec<Transition>,
178 pub terminal: bool,
180 pub modifiers: Vec<InstructionModifier>,
183 pub prompt_on_enter: bool,
186 pub on_enter_context: Option<
190 Arc<
191 dyn Fn(&State, &TranscriptWindow) -> Option<Vec<gemini_genai_rs::prelude::Content>>
192 + Send
193 + Sync,
194 >,
195 >,
196 pub needs: Vec<String>,
203 pub requires: Vec<String>,
210 pub preparations: Vec<PhasePreparation>,
213 pub presents: Vec<String>,
219 pub clear_on_enter: Vec<String>,
224}
225
226impl Phase {
227 pub fn new(name: &str, instruction: &str) -> Self {
229 Self {
230 name: name.to_string(),
231 instruction: PhaseInstruction::Static(instruction.to_string()),
232 tools_enabled: None,
233 guard: None,
234 on_enter: None,
235 on_exit: None,
236 transitions: Vec::new(),
237 terminal: false,
238 modifiers: Vec::new(),
239 prompt_on_enter: false,
240 on_enter_context: None,
241 needs: Vec::new(),
242 requires: Vec::new(),
243 preparations: Vec::new(),
244 presents: Vec::new(),
245 clear_on_enter: Vec::new(),
246 }
247 }
248
249 pub fn presented_key(concept: &str) -> String {
251 format!("presented:{concept}")
252 }
253
254 pub fn is_presented(state: &State, concept: &str) -> bool {
256 state
257 .get::<bool>(&Self::presented_key(concept))
258 .unwrap_or(false)
259 }
260
261 pub fn missing_requirements(&self, state: &State) -> Vec<String> {
263 self.requires
264 .iter()
265 .filter(|key| !state.contains(key))
266 .cloned()
267 .collect()
268 }
269}
270
271pub struct PhaseTransition {
273 pub from: String,
275 pub to: String,
277 pub turn: u32,
279 pub timestamp: Instant,
281 pub trigger: TransitionTrigger,
283 pub duration_in_phase: Duration,
285}
286
287pub struct TransitionResult {
290 pub instruction: String,
292 pub context: Option<Vec<gemini_genai_rs::prelude::Content>>,
294 pub prompt_on_enter: bool,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum TransitionEvaluation {
301 Ready {
303 target: String,
305 transition_index: usize,
307 },
308 Blocked {
311 target: String,
313 transition_index: usize,
315 missing: Vec<String>,
317 },
318}
319
320const MAX_PHASE_HISTORY: usize = 100;
324
325pub struct PhaseMachine {
327 phases: HashMap<String, Phase>,
328 current: String,
329 initial: String,
330 history: VecDeque<PhaseTransition>,
331 phase_entered_at: Instant,
332}
333
334impl PhaseMachine {
335 pub fn new(initial: &str) -> Self {
340 Self {
341 phases: HashMap::new(),
342 current: initial.to_string(),
343 initial: initial.to_string(),
344 history: VecDeque::new(),
345 phase_entered_at: Instant::now(),
346 }
347 }
348
349 pub fn add_phase(&mut self, phase: Phase) {
351 self.phases.insert(phase.name.clone(), phase);
352 }
353
354 pub fn current(&self) -> &str {
356 &self.current
357 }
358
359 pub fn current_phase(&self) -> Option<&Phase> {
361 self.phases.get(&self.current)
362 }
363
364 pub fn history(&self) -> &VecDeque<PhaseTransition> {
366 &self.history
367 }
368
369 #[cfg(test)]
371 pub(crate) fn history_mut(&mut self) -> &mut VecDeque<PhaseTransition> {
372 &mut self.history
373 }
374
375 pub fn describe_navigation(&self, state: &State) -> String {
381 let mut lines = Vec::new();
382 lines.push("[Navigation]".to_string());
383
384 if let Some(phase) = self.phases.get(&self.current) {
386 let resolved = phase.instruction.resolve(state);
387 let goal = resolved.split('.').next().unwrap_or(&resolved).trim();
388 lines.push(format!("Current phase: {} — {}", self.current, goal));
389
390 if !self.history.is_empty() {
392 let recent: Vec<String> = self
393 .history
394 .iter()
395 .rev()
396 .take(3)
397 .collect::<Vec<_>>()
398 .into_iter()
399 .rev()
400 .map(|h| format!("{} (turn {})", h.from, h.turn))
401 .collect();
402 lines.push(format!("Previous: {}", recent.join(", ")));
403 }
404
405 let missing: Vec<&str> = phase
407 .needs
408 .iter()
409 .filter(|key| !state.contains(key))
410 .map(|s| s.as_str())
411 .collect();
412 if !missing.is_empty() {
413 lines.push(format!("Still needed: {}", missing.join(", ")));
414 }
415
416 let missing_required: Vec<&str> = phase
418 .requires
419 .iter()
420 .filter(|key| !state.contains(key))
421 .map(|s| s.as_str())
422 .collect();
423 if !missing_required.is_empty() {
424 lines.push(format!(
425 "Blocked until required state is available: {}",
426 missing_required.join(", ")
427 ));
428 }
429 if !phase.preparations.is_empty() {
430 let preparations: Vec<&str> =
431 phase.preparations.iter().map(|p| p.name.as_str()).collect();
432 lines.push(format!("Preparers: {}", preparations.join(", ")));
433 }
434 if !phase.presents.is_empty() {
435 lines.push(format!("Presents: {}", phase.presents.join(", ")));
436 }
437
438 if phase.terminal {
440 lines.push("This is the final phase.".to_string());
441 } else if !phase.transitions.is_empty() {
442 lines.push("Possible next:".to_string());
443 for t in &phase.transitions {
444 if let Some(ref desc) = t.description {
445 lines.push(format!(" → {}: {}", t.target, desc));
446 } else {
447 lines.push(format!(" → {}", t.target));
448 }
449 }
450 }
451 }
452
453 lines.join("\n")
454 }
455
456 pub fn evaluate(&self, state: &State) -> Option<(&str, usize)> {
464 match self.evaluate_for_transition(state)? {
465 TransitionEvaluation::Ready {
466 transition_index, ..
467 } => {
468 let phase = self.phases.get(&self.current)?;
469 let target = phase.transitions.get(transition_index)?.target.as_str();
470 Some((target, transition_index))
471 }
472 TransitionEvaluation::Blocked { .. } => None,
473 }
474 }
475
476 pub fn evaluate_for_transition(&self, state: &State) -> Option<TransitionEvaluation> {
479 let phase = self.phases.get(&self.current)?;
480 if phase.terminal {
481 return None;
482 }
483 for (index, transition) in phase.transitions.iter().enumerate() {
484 if (transition.guard)(state) {
485 if let Some(target_phase) = self.phases.get(&transition.target) {
488 if let Some(ref phase_guard) = target_phase.guard {
489 if !phase_guard(state) {
490 continue;
491 }
492 }
493 let missing = target_phase.missing_requirements(state);
494 if missing.is_empty() {
495 return Some(TransitionEvaluation::Ready {
496 target: transition.target.clone(),
497 transition_index: index,
498 });
499 }
500 if !target_phase.preparations.is_empty() {
501 return Some(TransitionEvaluation::Blocked {
502 target: transition.target.clone(),
503 transition_index: index,
504 missing,
505 });
506 } else {
507 continue;
508 }
509 }
510 return Some(TransitionEvaluation::Ready {
511 target: transition.target.clone(),
512 transition_index: index,
513 });
514 }
515 }
516 None
517 }
518
519 pub async fn prepare_target(
522 &self,
523 target: &str,
524 state: &State,
525 writer: &Arc<dyn SessionWriter>,
526 ) -> bool {
527 let Some(phase) = self.phases.get(target) else {
528 return false;
529 };
530
531 for preparation in &phase.preparations {
532 let fut = (preparation.run)(state.clone(), Arc::clone(writer));
533 fut.await;
534 }
535
536 phase.missing_requirements(state).is_empty()
537 }
538
539 pub async fn transition(
545 &mut self,
546 target: &str,
547 state: &State,
548 writer: &Arc<dyn SessionWriter>,
549 turn: u32,
550 trigger: TransitionTrigger,
551 transcript_window: &TranscriptWindow,
552 ) -> Option<TransitionResult> {
553 if !self.phases.contains_key(target) {
555 return None;
556 }
557
558 let from = self.current.clone();
559 let duration_in_phase = self.phase_entered_at.elapsed();
560
561 if let Some(phase) = self.phases.get(&from) {
563 if let Some(ref on_exit) = phase.on_exit {
564 let fut = on_exit(state.clone(), Arc::clone(writer));
565 fut.await;
566 }
567 }
568
569 self.current = target.to_string();
571 self.phase_entered_at = Instant::now();
572
573 if let Some(phase) = self.phases.get(target) {
575 for key in &phase.clear_on_enter {
576 state.remove(key);
577 }
578 for concept in &phase.presents {
579 state.set(&Phase::presented_key(concept), true);
580 }
581 if let Some(ref on_enter) = phase.on_enter {
582 let fut = on_enter(state.clone(), Arc::clone(writer));
583 fut.await;
584 }
585 }
586
587 if self.history.len() >= MAX_PHASE_HISTORY {
589 self.history.pop_front();
590 }
591 self.history.push_back(PhaseTransition {
592 from,
593 to: target.to_string(),
594 turn,
595 timestamp: Instant::now(),
596 trigger,
597 duration_in_phase,
598 });
599
600 let phase = self.phases.get(target)?;
602 let instruction = phase
603 .instruction
604 .resolve_with_modifiers(state, &phase.modifiers);
605 let context = phase
606 .on_enter_context
607 .as_ref()
608 .and_then(|f| f(state, transcript_window));
609 let prompt_on_enter = phase.prompt_on_enter;
610
611 Some(TransitionResult {
612 instruction,
613 context,
614 prompt_on_enter,
615 })
616 }
617
618 pub fn current_phase_duration(&self) -> Duration {
620 self.phase_entered_at.elapsed()
621 }
622
623 pub fn active_tools(&self) -> Option<&[String]> {
628 self.phases
629 .get(&self.current)
630 .and_then(|p| p.tools_enabled.as_deref())
631 }
632
633 pub fn validate(&self) -> Result<(), String> {
640 if self.phases.is_empty() {
641 return Err("no phases registered".to_string());
642 }
643 if !self.phases.contains_key(&self.initial) {
644 return Err(format!(
645 "initial phase '{}' not found in registered phases",
646 self.initial
647 ));
648 }
649 for phase in self.phases.values() {
650 for transition in &phase.transitions {
651 if !self.phases.contains_key(&transition.target) {
652 return Err(format!(
653 "phase '{}' has transition to unknown target '{}'",
654 phase.name, transition.target
655 ));
656 }
657 }
658 }
659 Ok(())
660 }
661}
662
663#[cfg(test)]
666mod tests {
667 use super::*;
668
669 use super::super::transcript::TranscriptWindow;
670
671 fn simple_phase(name: &str, instruction: &str) -> Phase {
673 Phase {
674 name: name.to_string(),
675 instruction: PhaseInstruction::Static(instruction.to_string()),
676 tools_enabled: None,
677 guard: None,
678 on_enter: None,
679 on_exit: None,
680 transitions: Vec::new(),
681 terminal: false,
682 modifiers: Vec::new(),
683 prompt_on_enter: false,
684 on_enter_context: None,
685 needs: Vec::new(),
686 requires: Vec::new(),
687 preparations: Vec::new(),
688 presents: Vec::new(),
689 clear_on_enter: Vec::new(),
690 }
691 }
692
693 fn terminal_phase(name: &str, instruction: &str) -> Phase {
695 Phase {
696 name: name.to_string(),
697 instruction: PhaseInstruction::Static(instruction.to_string()),
698 tools_enabled: None,
699 guard: None,
700 on_enter: None,
701 on_exit: None,
702 transitions: Vec::new(),
703 terminal: true,
704 modifiers: Vec::new(),
705 prompt_on_enter: false,
706 on_enter_context: None,
707 needs: Vec::new(),
708 requires: Vec::new(),
709 preparations: Vec::new(),
710 presents: Vec::new(),
711 clear_on_enter: Vec::new(),
712 }
713 }
714
715 fn empty_tw() -> TranscriptWindow {
717 TranscriptWindow::new(vec![])
718 }
719
720 #[test]
723 fn new_and_add_phase_and_current() {
724 let mut machine = PhaseMachine::new("greeting");
725 machine.add_phase(simple_phase("greeting", "Say hello"));
726 assert_eq!(machine.current(), "greeting");
727 assert!(machine.current_phase().is_some());
728 assert!(machine.history().is_empty());
729 }
730
731 #[test]
734 fn evaluate_single_transition_fires() {
735 let state = State::new();
736 state.set("ready", true);
737
738 let mut greeting = simple_phase("greeting", "Say hello");
739 greeting.transitions.push(Transition {
740 target: "main".to_string(),
741 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
742 description: None,
743 });
744
745 let mut machine = PhaseMachine::new("greeting");
746 machine.add_phase(greeting);
747 machine.add_phase(simple_phase("main", "Main phase"));
748
749 assert_eq!(machine.evaluate(&state), Some(("main", 0)));
750 }
751
752 #[test]
755 fn evaluate_single_transition_does_not_fire() {
756 let state = State::new();
757 let mut greeting = simple_phase("greeting", "Say hello");
760 greeting.transitions.push(Transition {
761 target: "main".to_string(),
762 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
763 description: None,
764 });
765
766 let mut machine = PhaseMachine::new("greeting");
767 machine.add_phase(greeting);
768 machine.add_phase(simple_phase("main", "Main phase"));
769
770 assert_eq!(machine.evaluate(&state), None);
771 }
772
773 #[test]
776 fn evaluate_multiple_transitions_first_match_wins() {
777 let state = State::new();
778 state.set("escalate", true);
779 state.set("done", true);
780
781 let mut greeting = simple_phase("greeting", "Say hello");
782 greeting.transitions.push(Transition {
783 target: "escalated".to_string(),
784 guard: Arc::new(|s: &State| s.get::<bool>("escalate").unwrap_or(false)),
785 description: None,
786 });
787 greeting.transitions.push(Transition {
788 target: "farewell".to_string(),
789 guard: Arc::new(|s: &State| s.get::<bool>("done").unwrap_or(false)),
790 description: None,
791 });
792
793 let mut machine = PhaseMachine::new("greeting");
794 machine.add_phase(greeting);
795 machine.add_phase(simple_phase("escalated", "Escalated"));
796 machine.add_phase(simple_phase("farewell", "Farewell"));
797
798 assert_eq!(machine.evaluate(&state), Some(("escalated", 0)));
800 }
801
802 #[test]
805 fn evaluate_terminal_phase_returns_none() {
806 let state = State::new();
807 state.set("anything", true);
808
809 let mut term = terminal_phase("end", "Goodbye");
810 term.transitions.push(Transition {
812 target: "other".to_string(),
813 guard: Arc::new(|_| true),
814 description: None,
815 });
816
817 let mut machine = PhaseMachine::new("end");
818 machine.add_phase(term);
819 machine.add_phase(simple_phase("other", "Other"));
820
821 assert_eq!(machine.evaluate(&state), None);
822 }
823
824 #[tokio::test]
827 async fn transition_updates_current_and_records_history() {
828 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
829 let state = State::new();
830
831 let mut machine = PhaseMachine::new("greeting");
832 machine.add_phase(simple_phase("greeting", "Say hello"));
833 machine.add_phase(simple_phase("main", "Main phase instruction"));
834
835 let trigger = TransitionTrigger::Guard {
836 transition_index: 0,
837 };
838 let tw = empty_tw();
839 let result = machine
840 .transition("main", &state, &writer, 1, trigger, &tw)
841 .await;
842 assert_eq!(
843 result.as_ref().map(|r| r.instruction.as_str()),
844 Some("Main phase instruction")
845 );
846 assert_eq!(machine.current(), "main");
847 assert_eq!(machine.history().len(), 1);
848 assert_eq!(machine.history()[0].from, "greeting");
849 assert_eq!(machine.history()[0].to, "main");
850 assert_eq!(machine.history()[0].turn, 1);
851 assert!(matches!(
852 machine.history()[0].trigger,
853 TransitionTrigger::Guard {
854 transition_index: 0
855 }
856 ));
857 }
858
859 #[test]
862 fn active_tools_returns_filter() {
863 let mut phase = simple_phase("filtered", "Filtered phase");
864 phase.tools_enabled = Some(vec!["search".to_string(), "lookup".to_string()]);
865
866 let mut machine = PhaseMachine::new("filtered");
867 machine.add_phase(phase);
868
869 let tools = machine.active_tools().unwrap();
870 assert_eq!(tools.len(), 2);
871 assert!(tools.contains(&"search".to_string()));
872 assert!(tools.contains(&"lookup".to_string()));
873 }
874
875 #[test]
878 fn active_tools_returns_none_when_no_filter() {
879 let mut machine = PhaseMachine::new("open");
880 machine.add_phase(simple_phase("open", "All tools allowed"));
881
882 assert!(machine.active_tools().is_none());
883 }
884
885 #[test]
888 fn validate_catches_missing_initial_phase() {
889 let mut machine = PhaseMachine::new("nonexistent");
890 machine.add_phase(simple_phase("greeting", "Hi"));
891
892 let err = machine.validate().unwrap_err();
893 assert!(err.contains("initial phase 'nonexistent' not found"));
894 }
895
896 #[test]
899 fn validate_catches_invalid_transition_target() {
900 let mut greeting = simple_phase("greeting", "Hi");
901 greeting.transitions.push(Transition {
902 target: "missing_phase".to_string(),
903 guard: Arc::new(|_| true),
904 description: None,
905 });
906
907 let mut machine = PhaseMachine::new("greeting");
908 machine.add_phase(greeting);
909
910 let err = machine.validate().unwrap_err();
911 assert!(err.contains("unknown target 'missing_phase'"));
912 }
913
914 #[test]
917 fn validate_succeeds_on_valid_config() {
918 let mut greeting = simple_phase("greeting", "Hi");
919 greeting.transitions.push(Transition {
920 target: "main".to_string(),
921 guard: Arc::new(|_| true),
922 description: None,
923 });
924
925 let mut machine = PhaseMachine::new("greeting");
926 machine.add_phase(greeting);
927 machine.add_phase(simple_phase("main", "Main"));
928
929 assert!(machine.validate().is_ok());
930 }
931
932 #[test]
935 fn phase_instruction_static_resolves() {
936 let state = State::new();
937 let instr = PhaseInstruction::Static("You are a helpful assistant.".to_string());
938 assert_eq!(instr.resolve(&state), "You are a helpful assistant.");
939 }
940
941 #[test]
944 fn phase_instruction_dynamic_resolves() {
945 let state = State::new();
946 state.set("user_name", "Alice");
947
948 let instr = PhaseInstruction::Dynamic(Arc::new(|s: &State| {
949 let name: String = s.get("user_name").unwrap_or_default();
950 format!("Greet the user named {}.", name)
951 }));
952
953 assert_eq!(instr.resolve(&state), "Greet the user named Alice.");
954 }
955
956 #[test]
959 fn validate_catches_no_phases() {
960 let machine = PhaseMachine::new("greeting");
961 let err = machine.validate().unwrap_err();
962 assert!(err.contains("no phases registered"));
963 }
964
965 #[tokio::test]
968 async fn transition_to_nonexistent_target_returns_none() {
969 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
970 let state = State::new();
971
972 let mut machine = PhaseMachine::new("greeting");
973 machine.add_phase(simple_phase("greeting", "Hi"));
974
975 let trigger = TransitionTrigger::Programmatic { source: "test" };
976 let tw = empty_tw();
977 let result = machine
978 .transition("no_such_phase", &state, &writer, 0, trigger, &tw)
979 .await;
980 assert!(result.is_none());
981 assert_eq!(machine.current(), "greeting");
983 }
984
985 #[tokio::test]
988 async fn transition_runs_on_enter_and_on_exit_callbacks() {
989 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
990 let state = State::new();
991
992 let mut greeting = simple_phase("greeting", "Hi");
993 greeting.on_exit = Some(Arc::new(|s: State, _w: Arc<dyn SessionWriter>| {
994 Box::pin(async move {
995 s.set("exited_greeting", true);
996 })
997 }));
998
999 let mut main = simple_phase("main", "Main");
1000 main.on_enter = Some(Arc::new(|s: State, _w: Arc<dyn SessionWriter>| {
1001 Box::pin(async move {
1002 s.set("entered_main", true);
1003 })
1004 }));
1005
1006 let mut machine = PhaseMachine::new("greeting");
1007 machine.add_phase(greeting);
1008 machine.add_phase(main);
1009
1010 let trigger = TransitionTrigger::Programmatic { source: "test" };
1011 let tw = empty_tw();
1012 machine
1013 .transition("main", &state, &writer, 1, trigger, &tw)
1014 .await;
1015
1016 assert_eq!(state.get::<bool>("exited_greeting"), Some(true));
1017 assert_eq!(state.get::<bool>("entered_main"), Some(true));
1018 }
1019
1020 #[tokio::test]
1023 async fn multiple_transitions_accumulate_history() {
1024 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1025 let state = State::new();
1026
1027 let mut machine = PhaseMachine::new("a");
1028 machine.add_phase(simple_phase("a", "Phase A"));
1029 machine.add_phase(simple_phase("b", "Phase B"));
1030 machine.add_phase(simple_phase("c", "Phase C"));
1031
1032 let trigger1 = TransitionTrigger::Guard {
1033 transition_index: 0,
1034 };
1035 let tw = empty_tw();
1036 machine
1037 .transition("b", &state, &writer, 1, trigger1, &tw)
1038 .await;
1039 let trigger2 = TransitionTrigger::Programmatic { source: "test" };
1040 machine
1041 .transition("c", &state, &writer, 3, trigger2, &tw)
1042 .await;
1043
1044 assert_eq!(machine.current(), "c");
1045 assert_eq!(machine.history().len(), 2);
1046 assert_eq!(machine.history()[0].from, "a");
1047 assert_eq!(machine.history()[0].to, "b");
1048 assert_eq!(machine.history()[0].turn, 1);
1049 assert!(matches!(
1050 machine.history()[0].trigger,
1051 TransitionTrigger::Guard {
1052 transition_index: 0
1053 }
1054 ));
1055 assert_eq!(machine.history()[1].from, "b");
1056 assert_eq!(machine.history()[1].to, "c");
1057 assert_eq!(machine.history()[1].turn, 3);
1058 assert!(matches!(
1059 machine.history()[1].trigger,
1060 TransitionTrigger::Programmatic { source: "test" }
1061 ));
1062 }
1063
1064 #[tokio::test]
1067 async fn transition_resolves_dynamic_instruction() {
1068 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1069 let state = State::new();
1070 state.set("topic", "weather");
1071
1072 let dynamic_phase = Phase {
1073 name: "dynamic".to_string(),
1074 instruction: PhaseInstruction::Dynamic(Arc::new(|s: &State| {
1075 let topic: String = s.get("topic").unwrap_or_default();
1076 format!("Discuss {}.", topic)
1077 })),
1078 tools_enabled: None,
1079 guard: None,
1080 on_enter: None,
1081 on_exit: None,
1082 transitions: Vec::new(),
1083 terminal: false,
1084 modifiers: Vec::new(),
1085 prompt_on_enter: false,
1086 on_enter_context: None,
1087 needs: Vec::new(),
1088 requires: Vec::new(),
1089 preparations: Vec::new(),
1090 presents: Vec::new(),
1091 clear_on_enter: Vec::new(),
1092 };
1093
1094 let mut machine = PhaseMachine::new("start");
1095 machine.add_phase(simple_phase("start", "Begin"));
1096 machine.add_phase(dynamic_phase);
1097
1098 let trigger = TransitionTrigger::Programmatic { source: "test" };
1099 let tw = empty_tw();
1100 let result = machine
1101 .transition("dynamic", &state, &writer, 1, trigger, &tw)
1102 .await;
1103 assert_eq!(
1104 result.as_ref().map(|r| r.instruction.as_str()),
1105 Some("Discuss weather.")
1106 );
1107 }
1108
1109 #[test]
1112 fn phase_guard_blocks_transition() {
1113 let state = State::new();
1114 state.set("ready", true);
1115 let mut greeting = simple_phase("greeting", "Say hello");
1118 greeting.transitions.push(Transition {
1119 target: "secure".to_string(),
1120 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1121 description: None,
1122 });
1123
1124 let mut secure = simple_phase("secure", "Secure area");
1126 secure.guard = Some(Arc::new(|s: &State| {
1127 s.get::<bool>("verified").unwrap_or(false)
1128 }));
1129
1130 let mut machine = PhaseMachine::new("greeting");
1131 machine.add_phase(greeting);
1132 machine.add_phase(secure);
1133
1134 assert_eq!(machine.evaluate(&state), None);
1137 }
1138
1139 #[test]
1140 fn phase_guard_allows_transition_when_satisfied() {
1141 let state = State::new();
1142 state.set("ready", true);
1143 state.set("verified", true);
1144
1145 let mut greeting = simple_phase("greeting", "Say hello");
1146 greeting.transitions.push(Transition {
1147 target: "secure".to_string(),
1148 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1149 description: None,
1150 });
1151
1152 let mut secure = simple_phase("secure", "Secure area");
1154 secure.guard = Some(Arc::new(|s: &State| {
1155 s.get::<bool>("verified").unwrap_or(false)
1156 }));
1157
1158 let mut machine = PhaseMachine::new("greeting");
1159 machine.add_phase(greeting);
1160 machine.add_phase(secure);
1161
1162 assert_eq!(machine.evaluate(&state), Some(("secure", 0)));
1164 }
1165
1166 #[test]
1167 fn phase_guard_skips_to_next_transition() {
1168 let state = State::new();
1169 state.set("ready", true);
1170 let mut greeting = simple_phase("greeting", "Say hello");
1173 greeting.transitions.push(Transition {
1175 target: "secure".to_string(),
1176 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1177 description: None,
1178 });
1179 greeting.transitions.push(Transition {
1181 target: "fallback".to_string(),
1182 guard: Arc::new(|s: &State| s.get::<bool>("ready").unwrap_or(false)),
1183 description: None,
1184 });
1185
1186 let mut secure = simple_phase("secure", "Secure area");
1187 secure.guard = Some(Arc::new(|s: &State| {
1188 s.get::<bool>("verified").unwrap_or(false)
1189 }));
1190
1191 let mut machine = PhaseMachine::new("greeting");
1192 machine.add_phase(greeting);
1193 machine.add_phase(secure);
1194 machine.add_phase(simple_phase("fallback", "Fallback"));
1195
1196 assert_eq!(machine.evaluate(&state), Some(("fallback", 1)));
1199 }
1200
1201 #[test]
1204 fn instruction_modifier_state_append() {
1205 let state = State::new();
1206 state.set("emotion", "happy");
1207 state.set("score", 0.8f64);
1208
1209 let modifier =
1210 InstructionModifier::StateAppend(vec!["emotion".to_string(), "score".to_string()]);
1211 let mut base = "You are an assistant.".to_string();
1212 modifier.apply(&mut base, &state);
1213 assert!(base.contains("[Context: emotion=happy, score=0.8]"));
1214 }
1215
1216 #[test]
1217 fn instruction_modifier_conditional_true() {
1218 let state = State::new();
1219 state.set("risk", "high");
1220
1221 let modifier = InstructionModifier::Conditional {
1222 predicate: Arc::new(|s: &State| s.get::<String>("risk").unwrap_or_default() == "high"),
1223 text: "IMPORTANT: Use extra empathy.".to_string(),
1224 };
1225 let mut base = "Base instruction.".to_string();
1226 modifier.apply(&mut base, &state);
1227 assert!(base.contains("IMPORTANT: Use extra empathy."));
1228 }
1229
1230 #[test]
1231 fn instruction_modifier_conditional_false() {
1232 let state = State::new();
1233 state.set("risk", "low");
1234
1235 let modifier = InstructionModifier::Conditional {
1236 predicate: Arc::new(|s: &State| s.get::<String>("risk").unwrap_or_default() == "high"),
1237 text: "IMPORTANT: Use extra empathy.".to_string(),
1238 };
1239 let mut base = "Base instruction.".to_string();
1240 modifier.apply(&mut base, &state);
1241 assert!(!base.contains("IMPORTANT"));
1242 }
1243
1244 #[test]
1245 fn resolve_with_modifiers_composes() {
1246 let state = State::new();
1247 state.set("mood", "calm");
1248
1249 let instr = PhaseInstruction::Static("You are helpful.".to_string());
1250 let modifiers = vec![InstructionModifier::StateAppend(vec!["mood".to_string()])];
1251 let result = instr.resolve_with_modifiers(&state, &modifiers);
1252 assert!(result.starts_with("You are helpful."));
1253 assert!(result.contains("[Context: mood=calm]"));
1254 }
1255
1256 #[test]
1259 fn describe_navigation_basic() {
1260 let state = State::new();
1261 state.set("caller_name", "Vamsi");
1262
1263 let mut machine = PhaseMachine::new("greeting");
1264
1265 let mut greeting = Phase::new(
1266 "greeting",
1267 "Greet the caller warmly and ask who is calling.",
1268 );
1269 greeting.transitions.push(Transition {
1270 target: "identify".to_string(),
1271 guard: Arc::new(|_| false),
1272 description: Some("after initial greeting".into()),
1273 });
1274 machine.add_phase(greeting);
1275
1276 let mut identify = Phase::new("identify", "Get the caller's name.");
1277 identify.needs = vec!["caller_name".into(), "caller_org".into()];
1278 identify.transitions.push(Transition {
1279 target: "purpose".to_string(),
1280 guard: Arc::new(|_| false),
1281 description: Some("when caller is identified".into()),
1282 });
1283 machine.add_phase(identify);
1284
1285 let nav = machine.describe_navigation(&state);
1286 assert!(nav.contains("[Navigation]"));
1287 assert!(nav.contains("Current phase: greeting"));
1288 assert!(nav.contains("→ identify: after initial greeting"));
1289 }
1290
1291 #[test]
1292 fn describe_navigation_with_history_and_needs() {
1293 let state = State::new();
1294 state.set("caller_name", "Vamsi");
1296
1297 let mut machine = PhaseMachine::new("identify");
1298
1299 let greeting = Phase::new("greeting", "Greet caller.");
1300 machine.add_phase(greeting);
1301
1302 let mut identify = Phase::new("identify", "Get the caller's name and organization.");
1303 identify.needs = vec!["caller_name".into(), "caller_org".into()];
1304 identify.transitions.push(Transition {
1305 target: "purpose".to_string(),
1306 guard: Arc::new(|_| false),
1307 description: Some("when caller is identified".into()),
1308 });
1309 machine.add_phase(identify);
1310
1311 let purpose = Phase::new("purpose", "Ask why they are calling.");
1312 machine.add_phase(purpose);
1313
1314 machine.history_mut().push_back(PhaseTransition {
1316 from: "greeting".to_string(),
1317 to: "identify".to_string(),
1318 turn: 2,
1319 trigger: TransitionTrigger::Guard {
1320 transition_index: 0,
1321 },
1322 timestamp: std::time::Instant::now(),
1323 duration_in_phase: Duration::from_secs(5),
1324 });
1325
1326 let nav = machine.describe_navigation(&state);
1327 assert!(nav.contains("Previous:"), "Should show history");
1328 assert!(nav.contains("greeting"), "Should mention previous phase");
1329 assert!(
1330 nav.contains("Still needed: caller_org"),
1331 "caller_org should be listed as needed (caller_name is set)"
1332 );
1333 assert!(
1334 !nav.contains("caller_name"),
1335 "caller_name should NOT be in still-needed (it's set)"
1336 );
1337 }
1338
1339 #[test]
1340 fn evaluate_skips_target_when_required_state_missing() {
1341 let state = State::new();
1342 let mut machine = PhaseMachine::new("start");
1343
1344 let mut start = simple_phase("start", "Start.");
1345 start.transitions.push(Transition {
1346 target: "grounded".to_string(),
1347 guard: Arc::new(|_| true),
1348 description: Some("when ready".into()),
1349 });
1350 machine.add_phase(start);
1351
1352 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1353 grounded.requires = vec!["facts_loaded".into()];
1354 machine.add_phase(grounded);
1355
1356 assert!(machine.evaluate(&state).is_none());
1357
1358 state.set("facts_loaded", true);
1359 assert_eq!(
1360 machine.evaluate(&state).map(|(target, _)| target),
1361 Some("grounded")
1362 );
1363 }
1364
1365 #[test]
1366 fn evaluate_reports_blocked_target_with_preparation() {
1367 let state = State::new();
1368 let mut machine = PhaseMachine::new("start");
1369
1370 let mut start = simple_phase("start", "Start.");
1371 start.transitions.push(Transition {
1372 target: "grounded".to_string(),
1373 guard: Arc::new(|_| true),
1374 description: Some("when ready".into()),
1375 });
1376 machine.add_phase(start);
1377
1378 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1379 grounded.requires = vec!["facts_loaded".into()];
1380 grounded.preparations.push(PhasePreparation {
1381 name: "load_facts".into(),
1382 produces: vec!["facts_loaded".into()],
1383 run: Arc::new(|state, _writer| {
1384 Box::pin(async move {
1385 state.set("facts_loaded", true);
1386 })
1387 }),
1388 });
1389 machine.add_phase(grounded);
1390
1391 assert_eq!(
1392 machine.evaluate_for_transition(&state),
1393 Some(TransitionEvaluation::Blocked {
1394 target: "grounded".into(),
1395 transition_index: 0,
1396 missing: vec!["facts_loaded".into()],
1397 })
1398 );
1399 }
1400
1401 #[tokio::test]
1402 async fn prepare_target_materializes_required_state() {
1403 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1404 let state = State::new();
1405 let mut machine = PhaseMachine::new("start");
1406
1407 machine.add_phase(simple_phase("start", "Start."));
1408
1409 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1410 grounded.requires = vec!["facts_loaded".into()];
1411 grounded.preparations.push(PhasePreparation {
1412 name: "load_facts".into(),
1413 produces: vec!["facts_loaded".into()],
1414 run: Arc::new(|state, _writer| {
1415 Box::pin(async move {
1416 state.set("facts_loaded", true);
1417 })
1418 }),
1419 });
1420 machine.add_phase(grounded);
1421
1422 assert!(machine.prepare_target("grounded", &state, &writer).await);
1423 assert_eq!(state.get::<bool>("facts_loaded"), Some(true));
1424 }
1425
1426 #[tokio::test]
1427 async fn transition_marks_presented_concepts_and_clears_stale_keys() {
1428 let writer: Arc<dyn SessionWriter> = Arc::new(crate::test_helpers::MockWriter);
1429 let state = State::new();
1430 state.set("ack", true);
1431
1432 let mut machine = PhaseMachine::new("start");
1433 let mut start = simple_phase("start", "Start.");
1434 start.transitions.push(Transition {
1435 target: "present".to_string(),
1436 guard: Arc::new(|_| true),
1437 description: None,
1438 });
1439 machine.add_phase(start);
1440
1441 let mut present = simple_phase("present", "Present concept.");
1442 present.presents = vec!["terms".into()];
1443 present.clear_on_enter = vec!["ack".into()];
1444 machine.add_phase(present);
1445
1446 machine
1447 .transition(
1448 "present",
1449 &state,
1450 &writer,
1451 1,
1452 TransitionTrigger::Programmatic { source: "test" },
1453 &empty_tw(),
1454 )
1455 .await
1456 .expect("transition should succeed");
1457
1458 assert_eq!(
1459 state.get::<bool>(&Phase::presented_key("terms")),
1460 Some(true)
1461 );
1462 assert!(!state.contains("ack"));
1463 }
1464
1465 #[test]
1466 fn describe_navigation_lists_missing_required_state() {
1467 let state = State::new();
1468 let mut machine = PhaseMachine::new("grounded");
1469
1470 let mut grounded = simple_phase("grounded", "Use authoritative facts.");
1471 grounded.requires = vec!["facts_loaded".into(), "price".into()];
1472 machine.add_phase(grounded);
1473
1474 let nav = machine.describe_navigation(&state);
1475 assert!(nav.contains("Blocked until required state is available"));
1476 assert!(nav.contains("facts_loaded"));
1477 assert!(nav.contains("price"));
1478 }
1479
1480 #[test]
1481 fn describe_navigation_terminal_phase() {
1482 let state = State::new();
1483 let mut machine = PhaseMachine::new("farewell");
1484
1485 let mut farewell = Phase::new("farewell", "Say goodbye.");
1486 farewell.terminal = true;
1487 machine.add_phase(farewell);
1488
1489 let nav = machine.describe_navigation(&state);
1490 assert!(nav.contains("final phase"));
1491 }
1492}