1use std::time::Duration;
22
23use async_trait::async_trait;
24use time::format_description::well_known::Rfc3339;
25use time::OffsetDateTime;
26
27use crate::book;
28use crate::hours::{self, HoursError};
29use crate::model::{Flow, HoursException, MessageTone, Node, Prompt, WeeklySchedule};
30use crate::trace::{FlowOutcome, StepDetail, Trace};
31use crate::NodeId;
32
33const MAX_STEPS: u32 = 100;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Digit {
42 D0,
43 D1,
44 D2,
45 D3,
46 D4,
47 D5,
48 D6,
49 D7,
50 D8,
51 D9,
52 Star,
53 Hash,
54}
55
56impl Digit {
57 pub fn as_key(self) -> &'static str {
60 match self {
61 Digit::D0 => "0",
62 Digit::D1 => "1",
63 Digit::D2 => "2",
64 Digit::D3 => "3",
65 Digit::D4 => "4",
66 Digit::D5 => "5",
67 Digit::D6 => "6",
68 Digit::D7 => "7",
69 Digit::D8 => "8",
70 Digit::D9 => "9",
71 Digit::Star => "*",
72 Digit::Hash => "#",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy)]
82pub struct SlotQuery<'a> {
83 pub schedule: &'a WeeklySchedule,
84 pub timezone: &'a str,
85 pub exceptions: &'a [HoursException],
86 pub duration_mins: u64,
87 pub buffer_mins: u64,
88 pub lead_mins: u64,
89 pub horizon_days: u64,
90 pub max_offers: u64,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Slot {
99 pub start: String,
100 pub end: String,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct SlotOffer {
106 pub slots: Vec<Slot>,
107 pub timezone: String,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum BookOutcome {
116 Booked,
118 SlotTaken,
122 Unavailable,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum RingOutcome {
130 Answered,
132 NoAnswer,
134}
135
136#[async_trait]
142pub trait FlowEffects: Send {
143 async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()>;
146
147 async fn collect_digit(&mut self, timeout: Duration) -> anyhow::Result<Option<Digit>>;
150
151 async fn ring_human(&mut self, timeout: Duration) -> anyhow::Result<RingOutcome>;
153
154 async fn record_message(&mut self, tone: MessageTone, max: Duration) -> anyhow::Result<u32>;
159
160 async fn transfer(&mut self, target: &str) -> anyhow::Result<()>;
162
163 async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()>;
165
166 async fn fetch_slots(&mut self, query: &SlotQuery<'_>) -> anyhow::Result<SlotOffer>;
177
178 async fn book_slot(&mut self, slot: &Slot) -> anyhow::Result<BookOutcome>;
184
185 fn now(&self) -> OffsetDateTime;
188
189 fn on_enter(&mut self, _node: &NodeId, _kind: &'static str) {}
200}
201
202#[derive(Debug, thiserror::Error)]
206enum EngineError {
207 #[error("effect failed: {0}")]
208 Effect(anyhow::Error),
209 #[error("node {0:?} not found")]
210 UnknownNode(NodeId),
211 #[error("node {node:?} has no {exit:?} exit")]
212 MissingExit { node: NodeId, exit: String },
213 #[error("hours evaluation at {node:?} failed: {source}")]
214 Hours { node: NodeId, source: HoursError },
215}
216
217impl EngineError {
218 fn outcome(&self) -> FlowOutcome {
221 match self {
222 EngineError::Effect(_) => FlowOutcome::Aborted,
223 _ => FlowOutcome::Defect,
224 }
225 }
226}
227
228enum Step {
230 Goto(NodeId),
231 End(FlowOutcome),
232}
233
234pub async fn run<E: FlowEffects>(flow: &Flow, fx: &mut E, trace: &mut Trace) {
248 let mut current = flow.entry.clone();
249
250 for _ in 0..MAX_STEPS {
251 let node = match flow.nodes.get(¤t) {
252 Some(n) => n,
253 None => return abort(trace, EngineError::UnknownNode(current)),
254 };
255 match run_node(¤t, node, fx, trace).await {
256 Ok(Step::Goto(next)) => current = next,
257 Ok(Step::End(outcome)) => {
258 trace.outcome = outcome;
259 return;
260 }
261 Err(err) => return abort(trace, err),
262 }
263 }
264
265 trace.outcome = FlowOutcome::Defect;
267 trace.error = Some(format!(
268 "step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
269 ));
270}
271
272fn abort(trace: &mut Trace, err: EngineError) {
274 trace.outcome = err.outcome();
275 trace.error = Some(err.to_string());
276}
277
278async fn run_node<E: FlowEffects>(
279 id: &NodeId,
280 node: &Node,
281 fx: &mut E,
282 trace: &mut Trace,
283) -> Result<Step, EngineError> {
284 let kind = node.kind();
285 fx.on_enter(id, kind);
289 match node {
290 Node::Greeting { prompt, .. } => {
291 fx.speak(prompt).await.map_err(EngineError::Effect)?;
292 trace.push(id, kind, StepDetail::Spoke);
293 goto(id, node, "next")
294 }
295
296 Node::Hours { .. } => {
297 let result = hours::evaluate(node, fx.now()).map_err(|source| EngineError::Hours {
298 node: id.clone(),
299 source,
300 })?;
301 let open = result == hours::HoursResult::Open;
302 trace.push(id, kind, StepDetail::Hours { open });
303 goto(id, node, result.exit())
304 }
305
306 Node::Menu {
307 prompt,
308 options,
309 retries,
310 timeout_secs,
311 ..
312 } => {
313 run_menu(
314 id,
315 node,
316 fx,
317 trace,
318 prompt,
319 options,
320 *retries,
321 *timeout_secs,
322 )
323 .await
324 }
325
326 Node::Ring { timeout_secs, .. } => {
327 let outcome = fx
328 .ring_human(Duration::from_secs(*timeout_secs))
329 .await
330 .map_err(EngineError::Effect)?;
331 match outcome {
332 RingOutcome::Answered => {
333 trace.push(id, kind, StepDetail::Ring { answered: true });
334 Ok(Step::End(FlowOutcome::Answered))
335 }
336 RingOutcome::NoAnswer => {
337 trace.push(id, kind, StepDetail::Ring { answered: false });
338 goto(id, node, "no_answer")
339 }
340 }
341 }
342
343 Node::Message {
344 prompt,
345 max_secs,
346 tone,
347 ..
348 } => {
349 fx.speak(prompt).await.map_err(EngineError::Effect)?;
353 trace.push(id, kind, StepDetail::Spoke);
354 let secs = fx
355 .record_message(*tone, Duration::from_secs(*max_secs))
356 .await
357 .map_err(EngineError::Effect)?;
358 trace.push(id, kind, StepDetail::MessageRecorded { secs });
359 Ok(Step::End(FlowOutcome::MessageLeft))
360 }
361
362 Node::Transfer { target, .. } => {
363 fx.transfer(target).await.map_err(EngineError::Effect)?;
364 trace.push(
365 id,
366 kind,
367 StepDetail::Transferred {
368 target: target.clone(),
369 },
370 );
371 Ok(Step::End(FlowOutcome::Transferred))
372 }
373
374 Node::Hangup { prompt, .. } => {
375 fx.hangup(prompt.as_ref())
376 .await
377 .map_err(EngineError::Effect)?;
378 trace.push(id, kind, StepDetail::HungUp);
379 Ok(Step::End(FlowOutcome::HungUp))
380 }
381
382 Node::Book { .. } => run_book(id, node, fx, trace).await,
383 }
384}
385
386async fn run_book<E: FlowEffects>(
393 id: &NodeId,
394 node: &Node,
395 fx: &mut E,
396 trace: &mut Trace,
397) -> Result<Step, EngineError> {
398 let Node::Book {
399 prompt,
400 confirm_prompt,
401 schedule,
402 timezone,
403 exceptions,
404 duration_mins,
405 buffer_mins,
406 lead_mins,
407 horizon_days,
408 max_offers,
409 retries,
410 timeout_secs,
411 ..
412 } = node
413 else {
414 return Err(EngineError::UnknownNode(id.clone()));
416 };
417
418 let query = SlotQuery {
419 schedule,
420 timezone,
421 exceptions,
422 duration_mins: *duration_mins,
423 buffer_mins: *buffer_mins,
424 lead_mins: *lead_mins,
425 horizon_days: *horizon_days,
426 max_offers: *max_offers,
427 };
428
429 let sayable = book::vocabulary_refs(node);
436
437 for round in 0..2u8 {
442 let offer = match fx.fetch_slots(&query).await {
443 Ok(offer) => offer,
444 Err(_) => {
445 trace.push(id, "book", StepDetail::BookUnavailable);
446 return goto(id, node, "unavailable");
447 }
448 };
449
450 let tz = match crate::hours::resolve_tz(&offer.timezone) {
451 Ok(tz) => tz,
452 Err(_) => {
456 trace.push(id, "book", StepDetail::BookUnavailable);
457 return goto(id, node, "unavailable");
458 }
459 };
460
461 let now = fx.now();
462 let offered: Vec<(Slot, Vec<String>)> = offer
463 .slots
464 .iter()
465 .filter_map(|slot| {
466 let start = OffsetDateTime::parse(&slot.start, &Rfc3339).ok()?;
467 Some((slot.clone(), book::time_refs(start, now, tz)))
468 })
469 .filter(|(_, refs)| refs.iter().all(|r| sayable.contains(r)))
470 .take(usize::try_from(*max_offers).unwrap_or(usize::MAX))
471 .collect();
472
473 if offered.is_empty() {
474 trace.push(id, "book", StepDetail::BookNoSlots);
475 return goto(id, node, "no_slots");
476 }
477 trace.push(
478 id,
479 "book",
480 StepDetail::BookOffered {
481 count: offered.len() as u64,
482 },
483 );
484
485 let chosen = match collect_offer_choice(
486 fx,
487 prompt,
488 &offered,
489 *retries,
490 Duration::from_secs(*timeout_secs),
491 )
492 .await?
493 {
494 Some(index) => &offered[index].0,
495 None => {
496 trace.push(id, "book", StepDetail::BookNoInput);
497 return goto(id, node, "no_input");
498 }
499 };
500
501 match fx.book_slot(chosen).await {
502 Ok(BookOutcome::Booked) => {
503 fx.speak(confirm_prompt)
507 .await
508 .map_err(EngineError::Effect)?;
509 let start = OffsetDateTime::parse(&chosen.start, &Rfc3339)
510 .map_err(|_| EngineError::UnknownNode(id.clone()))?;
511 for reference in book::time_refs(start, now, tz) {
512 fx.speak(&Prompt::Audio {
513 audio: reference,
514 transcript: None,
515 })
516 .await
517 .map_err(EngineError::Effect)?;
518 }
519 trace.push(
520 id,
521 "book",
522 StepDetail::Booked {
523 start: chosen.start.clone(),
524 },
525 );
526 return goto(id, node, "booked");
527 }
528 Ok(BookOutcome::SlotTaken) => {
529 trace.push(id, "book", StepDetail::BookSlotTaken);
530 fx.speak(&Prompt::Audio {
531 audio: book::taken_ref(),
532 transcript: None,
533 })
534 .await
535 .map_err(EngineError::Effect)?;
536 if round == 1 {
537 trace.push(id, "book", StepDetail::BookNoSlots);
538 return goto(id, node, "no_slots");
539 }
540 }
544 Ok(BookOutcome::Unavailable) | Err(_) => {
545 trace.push(id, "book", StepDetail::BookUnavailable);
546 return goto(id, node, "unavailable");
547 }
548 }
549 }
550
551 trace.push(id, "book", StepDetail::BookNoSlots);
552 goto(id, node, "no_slots")
553}
554
555async fn collect_offer_choice<E: FlowEffects>(
561 fx: &mut E,
562 prompt: &Prompt,
563 offered: &[(Slot, Vec<String>)],
564 retries: u64,
565 timeout: Duration,
566) -> Result<Option<usize>, EngineError> {
567 for _ in 0..retries.saturating_add(1) {
568 fx.speak(prompt).await.map_err(EngineError::Effect)?;
569 for (index, (_, refs)) in offered.iter().enumerate() {
570 for reference in refs
571 .iter()
572 .cloned()
573 .chain(std::iter::once(book::press_ref(index as u64 + 1)))
574 {
575 fx.speak(&Prompt::Audio {
576 audio: reference,
577 transcript: None,
578 })
579 .await
580 .map_err(EngineError::Effect)?;
581 }
582 }
583
584 let pressed = fx
585 .collect_digit(timeout)
586 .await
587 .map_err(EngineError::Effect)?;
588 if let Some(digit) = pressed {
589 if let Some(index) = digit
590 .as_key()
591 .parse::<usize>()
592 .ok()
593 .filter(|d| *d >= 1 && *d <= offered.len())
594 {
595 return Ok(Some(index - 1));
596 }
597 }
598 }
599 Ok(None)
600}
601
602#[allow(clippy::too_many_arguments)]
607async fn run_menu<E: FlowEffects>(
608 id: &NodeId,
609 node: &Node,
610 fx: &mut E,
611 trace: &mut Trace,
612 prompt: &Prompt,
613 options: &std::collections::HashMap<String, String>,
614 retries: u64,
615 timeout_secs: u64,
616) -> Result<Step, EngineError> {
617 let attempts = retries.saturating_add(1);
618 let mut heard_any_key = false;
619
620 for _ in 0..attempts {
621 fx.speak(prompt).await.map_err(EngineError::Effect)?;
622 let pressed = fx
623 .collect_digit(Duration::from_secs(timeout_secs))
624 .await
625 .map_err(EngineError::Effect)?;
626 match pressed {
627 Some(digit) if options.contains_key(digit.as_key()) => {
628 trace.push(
629 id,
630 "menu",
631 StepDetail::MenuChoice {
632 digit: digit.as_key().to_string(),
633 },
634 );
635 return goto(id, node, digit.as_key());
636 }
637 Some(_) => heard_any_key = true, None => {} }
640 }
641
642 if heard_any_key {
643 trace.push(id, "menu", StepDetail::MenuInvalid);
644 goto(id, node, "invalid")
645 } else {
646 trace.push(id, "menu", StepDetail::MenuNoInput);
647 goto(id, node, "no_input")
648 }
649}
650
651fn goto(id: &NodeId, node: &Node, exit: &str) -> Result<Step, EngineError> {
654 node.exits()
655 .and_then(|exits| exits.get(exit))
656 .map(|target| Step::Goto(target.clone()))
657 .ok_or_else(|| EngineError::MissingExit {
658 node: id.clone(),
659 exit: exit.to_string(),
660 })
661}
662
663#[cfg(test)]
664mod tests {
665 use std::collections::VecDeque;
666
667 use time::macros::datetime;
668
669 use super::*;
670 use crate::trace::FlowOutcome;
671 use crate::validate::validate;
672
673 struct MockEffects {
678 now: OffsetDateTime,
679 digits: VecDeque<Option<Digit>>,
680 ring: RingOutcome,
681 message_secs: u32,
682 spoken: Vec<String>,
684 transferred: Option<String>,
685 recorded: bool,
686 record_tone: Option<MessageTone>,
688 hung_up: bool,
689 fail_speak: bool,
690 entered: Vec<(String, &'static str)>,
693 slot_answers: VecDeque<anyhow::Result<SlotOffer>>,
697 book_answers: VecDeque<anyhow::Result<BookOutcome>>,
698 slot_queries: u32,
700 booked: Vec<Slot>,
701 }
702
703 impl MockEffects {
704 fn new(now: OffsetDateTime) -> Self {
705 MockEffects {
706 now,
707 digits: VecDeque::new(),
708 ring: RingOutcome::NoAnswer,
709 message_secs: 0,
710 spoken: Vec::new(),
711 transferred: None,
712 recorded: false,
713 record_tone: None,
714 hung_up: false,
715 fail_speak: false,
716 entered: Vec::new(),
717 slot_answers: VecDeque::new(),
718 book_answers: VecDeque::new(),
719 slot_queries: 0,
720 booked: Vec::new(),
721 }
722 }
723 fn slot_answers(
724 mut self,
725 seq: impl IntoIterator<Item = anyhow::Result<SlotOffer>>,
726 ) -> Self {
727 self.slot_answers = seq.into_iter().collect();
728 self
729 }
730 fn book_answers(
731 mut self,
732 seq: impl IntoIterator<Item = anyhow::Result<BookOutcome>>,
733 ) -> Self {
734 self.book_answers = seq.into_iter().collect();
735 self
736 }
737 fn digits(mut self, seq: impl IntoIterator<Item = Option<Digit>>) -> Self {
738 self.digits = seq.into_iter().collect();
739 self
740 }
741 fn ring(mut self, r: RingOutcome) -> Self {
742 self.ring = r;
743 self
744 }
745 fn message_secs(mut self, s: u32) -> Self {
746 self.message_secs = s;
747 self
748 }
749 }
750
751 fn prompt_label(p: &Prompt) -> String {
755 match p {
756 Prompt::Text(t) => t.clone(),
757 Prompt::Audio { audio, .. } => audio.clone(),
758 }
759 }
760
761 #[async_trait]
762 impl FlowEffects for MockEffects {
763 async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()> {
764 if self.fail_speak {
765 anyhow::bail!("caller hung up");
766 }
767 self.spoken.push(prompt_label(prompt));
768 Ok(())
769 }
770 async fn collect_digit(&mut self, _timeout: Duration) -> anyhow::Result<Option<Digit>> {
771 Ok(self.digits.pop_front().flatten())
773 }
774 async fn ring_human(&mut self, _timeout: Duration) -> anyhow::Result<RingOutcome> {
775 Ok(self.ring)
776 }
777 async fn record_message(
778 &mut self,
779 tone: MessageTone,
780 _max: Duration,
781 ) -> anyhow::Result<u32> {
782 self.recorded = true;
783 self.record_tone = Some(tone);
784 Ok(self.message_secs)
785 }
786 async fn transfer(&mut self, target: &str) -> anyhow::Result<()> {
787 self.transferred = Some(target.to_string());
788 Ok(())
789 }
790 async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()> {
791 if let Some(p) = prompt {
792 self.spoken.push(prompt_label(p));
793 }
794 self.hung_up = true;
795 Ok(())
796 }
797 async fn fetch_slots(&mut self, _query: &SlotQuery<'_>) -> anyhow::Result<SlotOffer> {
798 self.slot_queries += 1;
799 self.slot_answers
800 .pop_front()
801 .unwrap_or_else(|| anyhow::bail!("no slot answer scripted"))
802 }
803 async fn book_slot(&mut self, slot: &Slot) -> anyhow::Result<BookOutcome> {
804 self.booked.push(slot.clone());
805 self.book_answers
806 .pop_front()
807 .unwrap_or_else(|| anyhow::bail!("no book answer scripted"))
808 }
809 fn now(&self) -> OffsetDateTime {
810 self.now
811 }
812 fn on_enter(&mut self, node: &NodeId, kind: &'static str) {
813 self.entered.push((node.clone(), kind));
814 }
815 }
816
817 const LUIGIS: &str = r#"
818schema_version: 1
819id: flow_luigi
820name: Luigi's — after hours
821version: 3
822entry: welcome
823nodes:
824 welcome:
825 kind: greeting
826 prompt: Thanks for calling Luigi's!
827 exits: { next: check_hours }
828 check_hours:
829 kind: hours
830 timezone: America/New_York
831 schedule:
832 tue: [{ open: "11:00", close: "22:00" }]
833 exits: { open: front_desk, closed: night_menu }
834 front_desk:
835 kind: ring
836 timeout_secs: 25
837 exits: { no_answer: take_message }
838 night_menu:
839 kind: menu
840 prompt: We're closed. Press 1 for hours, or hold for a message.
841 options: { "1": Hours }
842 retries: 1
843 exits: { "1": say_hours, no_input: take_message, invalid: take_message }
844 say_hours:
845 kind: greeting
846 prompt: We're open Tuesday to Sunday, eleven to ten.
847 exits: { next: take_message }
848 take_message:
849 kind: message
850 prompt: Please leave your name and number after the tone.
851"#;
852
853 fn luigis() -> Flow {
854 let flow = Flow::from_yaml(LUIGIS).expect("parses");
855 validate(&flow).expect("the scenario flow must be valid");
856 flow
857 }
858
859 fn open_time() -> OffsetDateTime {
861 datetime!(2026-07-07 19:00 UTC)
862 }
863 fn closed_time() -> OffsetDateTime {
865 datetime!(2026-07-08 03:00 UTC)
866 }
867
868 fn kinds(trace: &Trace) -> Vec<&str> {
869 trace.steps.iter().map(|s| s.kind).collect()
870 }
871
872 async fn run_trace(flow: &Flow, fx: &mut MockEffects) -> Trace {
875 let mut trace = Trace::new(&flow.id, flow.version);
876 run(flow, fx, &mut trace).await;
877 trace
878 }
879
880 #[tokio::test]
881 async fn open_hours_human_answers() {
882 let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
883 let trace = run_trace(&luigis(), &mut fx).await;
884
885 assert_eq!(trace.outcome, FlowOutcome::Answered);
886 assert!(trace.is_clean());
887 assert_eq!(kinds(&trace), vec!["greeting", "hours", "ring"]);
888 assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: true });
890 assert!(!fx.recorded, "a human answered — no voicemail");
891 }
892
893 #[tokio::test]
894 async fn on_enter_reports_each_node_as_the_engine_reaches_it() {
895 let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
899 let _ = run_trace(&luigis(), &mut fx).await;
900 assert_eq!(
901 fx.entered,
902 vec![
903 ("welcome".to_string(), "greeting"),
904 ("check_hours".to_string(), "hours"),
905 ("front_desk".to_string(), "ring"),
906 ],
907 "on_enter fires once per visited node, in path order"
908 );
909
910 let mut fx = MockEffects::new(closed_time())
913 .digits([Some(Digit::D1)])
914 .message_secs(12);
915 let trace = run_trace(&luigis(), &mut fx).await;
916 let entered_ids: Vec<&str> = fx.entered.iter().map(|(id, _)| id.as_str()).collect();
917 assert_eq!(
918 entered_ids,
919 vec![
920 "welcome",
921 "check_hours",
922 "night_menu",
923 "say_hours",
924 "take_message"
925 ],
926 );
927 assert_eq!(
930 fx.entered
931 .iter()
932 .filter(|(id, _)| id == "night_menu")
933 .count(),
934 1,
935 );
936 let mut trace_nodes: Vec<&str> = trace.steps.iter().map(|s| s.node.as_str()).collect();
940 trace_nodes.dedup();
941 assert_eq!(entered_ids, trace_nodes);
942 }
943
944 #[tokio::test]
945 async fn open_hours_no_answer_falls_to_voicemail() {
946 let mut fx = MockEffects::new(open_time())
947 .ring(RingOutcome::NoAnswer)
948 .message_secs(40);
949 let trace = run_trace(&luigis(), &mut fx).await;
950
951 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
952 assert_eq!(
953 kinds(&trace),
954 vec!["greeting", "hours", "ring", "message", "message"]
955 );
956 assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
960 assert_eq!(
961 trace.steps[4].detail,
962 StepDetail::MessageRecorded { secs: 40 }
963 );
964 assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
966 assert!(fx.recorded);
967 assert_eq!(fx.record_tone, Some(MessageTone::Beep));
969 }
970
971 #[tokio::test]
972 async fn closed_press_one_hears_hours_then_leaves_message() {
973 let mut fx = MockEffects::new(closed_time())
974 .digits([Some(Digit::D1)])
975 .message_secs(12);
976 let trace = run_trace(&luigis(), &mut fx).await;
977
978 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
979 assert_eq!(
980 kinds(&trace),
981 vec!["greeting", "hours", "menu", "greeting", "message", "message"]
982 );
983 assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: false });
984 assert_eq!(
985 trace.steps[2].detail,
986 StepDetail::MenuChoice { digit: "1".into() }
987 );
988 assert!(fx
990 .spoken
991 .iter()
992 .any(|s| s.contains("open Tuesday to Sunday")));
993 }
994
995 #[tokio::test]
996 async fn closed_silence_takes_no_input_exit() {
997 let mut fx = MockEffects::new(closed_time());
999 let trace = run_trace(&luigis(), &mut fx).await;
1000
1001 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1002 assert_eq!(
1003 kinds(&trace),
1004 vec!["greeting", "hours", "menu", "message", "message"]
1005 );
1006 assert_eq!(trace.steps[2].detail, StepDetail::MenuNoInput);
1007 }
1008
1009 #[tokio::test]
1010 async fn closed_wrong_keys_take_invalid_exit_after_retry() {
1011 let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D9), Some(Digit::D7)]);
1013 let trace = run_trace(&luigis(), &mut fx).await;
1014
1015 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1016 assert_eq!(trace.steps[2].detail, StepDetail::MenuInvalid);
1017 let menu_prompts = fx
1019 .spoken
1020 .iter()
1021 .filter(|s| s.contains("Press 1 for hours"))
1022 .count();
1023 assert_eq!(menu_prompts, 2);
1024 }
1025
1026 #[tokio::test]
1027 async fn wrong_key_then_valid_digit_still_routes() {
1028 let mut fx = MockEffects::new(closed_time())
1030 .digits([Some(Digit::D9), Some(Digit::D1)])
1031 .message_secs(5);
1032 let trace = run_trace(&luigis(), &mut fx).await;
1033
1034 assert_eq!(
1035 trace.steps[2].detail,
1036 StepDetail::MenuChoice { digit: "1".into() }
1037 );
1038 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1039 }
1040
1041 #[tokio::test]
1042 async fn steps_carry_monotonic_timeline_offsets() {
1043 let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D1)]);
1048 let trace = run_trace(&luigis(), &mut fx).await;
1049
1050 assert!(trace.steps.len() >= 3);
1051 let offsets: Vec<u64> = trace.steps.iter().map(|s| s.at_ms).collect();
1052 assert!(
1053 offsets.windows(2).all(|w| w[0] <= w[1]),
1054 "offsets must be non-decreasing: {offsets:?}"
1055 );
1056 }
1057
1058 #[tokio::test]
1059 async fn effect_failure_aborts_with_partial_trace() {
1060 let mut fx = MockEffects::new(open_time());
1061 fx.fail_speak = true; let trace = run_trace(&luigis(), &mut fx).await;
1063
1064 assert_eq!(trace.outcome, FlowOutcome::Aborted);
1065 assert!(!trace.is_clean());
1066 assert!(trace.error.as_deref().unwrap().contains("caller hung up"));
1067 assert!(trace.steps.is_empty());
1069 }
1070
1071 const CLINIC: &str = r#"
1079schema_version: 2
1080id: flow_clinic
1081name: The clinic
1082entry: take_booking
1083nodes:
1084 take_booking:
1085 kind: book
1086 prompt: I can book you in. Here are the next available times.
1087 confirm_prompt: You're booked for
1088 timezone: UTC
1089 schedule:
1090 tue: [{ open: "09:00", close: "12:00" }]
1091 duration_mins: 30
1092 max_offers: 2
1093 retries: 1
1094 timeout_secs: 5
1095 exits:
1096 booked: goodbye
1097 no_slots: voicemail
1098 no_input: voicemail
1099 unavailable: voicemail
1100 goodbye:
1101 kind: hangup
1102 prompt: See you then.
1103 voicemail:
1104 kind: message
1105 prompt: Leave your name and number.
1106"#;
1107
1108 fn clinic() -> Flow {
1109 let flow = Flow::from_yaml(CLINIC).expect("parses");
1110 validate(&flow).expect("the scenario flow must be valid");
1111 flow
1112 }
1113
1114 fn slot(start: &str, end: &str) -> Slot {
1115 Slot {
1116 start: start.to_string(),
1117 end: end.to_string(),
1118 }
1119 }
1120
1121 fn two_slots() -> SlotOffer {
1124 SlotOffer {
1125 slots: vec![
1126 slot("2026-07-07T09:00:00Z", "2026-07-07T09:30:00Z"),
1127 slot("2026-07-07T10:30:00Z", "2026-07-07T11:00:00Z"),
1128 ],
1129 timezone: "UTC".to_string(),
1130 }
1131 }
1132
1133 fn day_before() -> OffsetDateTime {
1135 datetime!(2026-07-06 12:00 UTC)
1136 }
1137
1138 #[tokio::test]
1139 async fn book_offers_times_and_confirms_the_one_taken() {
1140 let mut fx = MockEffects::new(day_before())
1141 .slot_answers([Ok(two_slots())])
1142 .book_answers([Ok(BookOutcome::Booked)])
1143 .digits([Some(Digit::D2)]);
1144 let trace = run_trace(&clinic(), &mut fx).await;
1145
1146 assert_eq!(
1149 fx.spoken,
1150 vec![
1151 "I can book you in. Here are the next available times.",
1152 "bkday_tomorrow",
1153 "bktime_0900",
1154 "bkpress_1",
1155 "bkday_tomorrow",
1156 "bktime_1030",
1157 "bkpress_2",
1158 "You're booked for",
1159 "bkday_tomorrow",
1160 "bktime_1030",
1161 "See you then.",
1162 ],
1163 );
1164 assert_eq!(fx.booked, vec![two_slots().slots[1].clone()]);
1167 assert_eq!(trace.steps[0].detail, StepDetail::BookOffered { count: 2 });
1168 assert_eq!(
1169 trace.steps[1].detail,
1170 StepDetail::Booked {
1171 start: "2026-07-07T10:30:00Z".into()
1172 }
1173 );
1174 assert_eq!(trace.outcome, FlowOutcome::HungUp);
1175 assert!(trace.is_clean());
1176 }
1177
1178 #[tokio::test]
1179 async fn an_empty_calendar_takes_the_no_slots_exit() {
1180 let mut fx = MockEffects::new(day_before()).slot_answers([Ok(SlotOffer {
1181 slots: Vec::new(),
1182 timezone: "UTC".to_string(),
1183 })]);
1184 let trace = run_trace(&clinic(), &mut fx).await;
1185
1186 assert_eq!(trace.steps[0].detail, StepDetail::BookNoSlots);
1187 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1188 assert!(fx.booked.is_empty());
1191 }
1192
1193 #[tokio::test]
1194 async fn an_unreachable_platform_takes_the_unavailable_exit() {
1195 let mut fx = MockEffects::new(day_before())
1199 .slot_answers([Err(anyhow::anyhow!("connect timed out"))]);
1200 let trace = run_trace(&clinic(), &mut fx).await;
1201
1202 assert_eq!(trace.steps[0].detail, StepDetail::BookUnavailable);
1203 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1204 assert!(trace.is_clean(), "a calendar outage is not an aborted call");
1205 }
1206
1207 #[tokio::test]
1208 async fn a_slot_taken_mid_call_is_re_read_and_re_offered_once() {
1209 let second_read = SlotOffer {
1213 slots: vec![slot("2026-07-07T11:00:00Z", "2026-07-07T11:30:00Z")],
1214 timezone: "UTC".to_string(),
1215 };
1216 let mut fx = MockEffects::new(day_before())
1217 .slot_answers([Ok(two_slots()), Ok(second_read)])
1218 .book_answers([Ok(BookOutcome::SlotTaken), Ok(BookOutcome::Booked)])
1219 .digits([Some(Digit::D1), Some(Digit::D1)]);
1220 let trace = run_trace(&clinic(), &mut fx).await;
1221
1222 assert_eq!(fx.slot_queries, 2, "the calendar is re-read, not replayed");
1223 assert!(fx.spoken.contains(&"bktaken".to_string()));
1224 assert_eq!(trace.steps[1].detail, StepDetail::BookSlotTaken);
1225 assert_eq!(
1226 trace.steps[3].detail,
1227 StepDetail::Booked {
1228 start: "2026-07-07T11:00:00Z".into()
1229 }
1230 );
1231 assert_eq!(trace.outcome, FlowOutcome::HungUp);
1232 }
1233
1234 #[tokio::test]
1235 async fn losing_the_race_twice_gives_up_rather_than_looping() {
1236 let mut fx = MockEffects::new(day_before())
1237 .slot_answers([Ok(two_slots()), Ok(two_slots())])
1238 .book_answers([Ok(BookOutcome::SlotTaken), Ok(BookOutcome::SlotTaken)])
1239 .digits([Some(Digit::D1), Some(Digit::D1)]);
1240 let trace = run_trace(&clinic(), &mut fx).await;
1241
1242 assert_eq!(fx.slot_queries, 2, "two rounds, then the exit");
1243 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1244 assert!(trace
1245 .steps
1246 .iter()
1247 .any(|s| s.detail == StepDetail::BookNoSlots));
1248 }
1249
1250 #[tokio::test]
1251 async fn silence_and_unmapped_keys_both_end_at_no_input() {
1252 let mut fx = MockEffects::new(day_before())
1256 .slot_answers([Ok(two_slots())])
1257 .digits([Some(Digit::D7), None]);
1258 let trace = run_trace(&clinic(), &mut fx).await;
1259
1260 assert_eq!(trace.steps[1].detail, StepDetail::BookNoInput);
1261 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
1262 assert!(fx.booked.is_empty());
1263 assert_eq!(fx.spoken.iter().filter(|s| *s == "bktime_0900").count(), 2,);
1265 }
1266
1267 #[tokio::test]
1268 async fn a_time_the_vocabulary_cannot_say_is_never_offered() {
1269 let mut fx = MockEffects::new(day_before())
1273 .slot_answers([Ok(SlotOffer {
1274 slots: vec![
1275 slot("2026-07-07T13:00:00Z", "2026-07-07T13:30:00Z"),
1276 slot("2026-07-07T09:00:00Z", "2026-07-07T09:30:00Z"),
1277 ],
1278 timezone: "UTC".to_string(),
1279 })])
1280 .book_answers([Ok(BookOutcome::Booked)])
1281 .digits([Some(Digit::D1)]);
1282 let trace = run_trace(&clinic(), &mut fx).await;
1283
1284 assert_eq!(
1285 trace.steps[0].detail,
1286 StepDetail::BookOffered { count: 1 },
1287 "only the sayable slot survived"
1288 );
1289 assert!(!fx.spoken.iter().any(|s| s == "bktime_1300"));
1290 assert_eq!(fx.booked, vec![two_slots().slots[0].clone()]);
1291 }
1292
1293 #[tokio::test]
1294 async fn transfer_and_hangup_terminals() {
1295 let src = r#"
1296schema_version: 1
1297id: f
1298name: n
1299entry: g
1300nodes:
1301 g:
1302 kind: greeting
1303 prompt: one moment
1304 exits: { next: t }
1305 t:
1306 kind: transfer
1307 target: sip:desk@example.com
1308"#;
1309 let flow = Flow::from_yaml(src).unwrap();
1310 validate(&flow).unwrap();
1311 let mut fx = MockEffects::new(open_time());
1312 let trace = run_trace(&flow, &mut fx).await;
1313 assert_eq!(trace.outcome, FlowOutcome::Transferred);
1314 assert_eq!(fx.transferred.as_deref(), Some("sip:desk@example.com"));
1315 }
1316}