Skip to main content

wavekat_flow/
engine.rs

1//! The flow interpreter — doc 48's "the daemon runs the flow".
2//!
3//! The loop is deliberately tiny: run the current node, get either the next
4//! node id or a terminal, append a trace step, repeat. All the side effects a
5//! component needs (speak, collect a DTMF digit, ring the human, record,
6//! transfer, hang up) sit behind the [`FlowEffects`] trait — the
7//! "component-implementation seam" doc 48 keeps the engine generic over. The
8//! daemon supplies the real impl (wiring `wavekat-tts` for prompts, RFC 4733
9//! receive for DTMF, the normal incoming-call path for `ring`, the recording
10//! pipeline for `message`, REFER for `transfer`); tests supply a scripted
11//! mock. Nothing here touches SQLite, the renderer, sync, or cpal — the whole
12//! module stays extractable.
13//!
14//! Control-flow logic (menu retry counting, hours branching) lives here and
15//! is pure; only the actual audio/telephony effects cross the trait. That is
16//! what makes a full call testable with no call.
17//!
18//! This crate owns the [`FlowEffects`] trait *definition*; the daemon keeps
19//! its live `CallFlowEffects` *impl* in its own codebase.
20
21use std::time::Duration;
22
23use async_trait::async_trait;
24use time::OffsetDateTime;
25
26use crate::hours::{self, HoursError};
27use crate::model::{Flow, MessageTone, Node, Prompt};
28use crate::trace::{FlowOutcome, StepDetail, Trace};
29use crate::NodeId;
30
31/// Backstop against a cycle in a flow that somehow reached the engine
32/// unvalidated (validation's trap check should make this unreachable). A real
33/// flow visits a handful of nodes; 100 is far above any genuine path and far
34/// below an infinite loop.
35const MAX_STEPS: u32 = 100;
36
37/// A DTMF key a caller can press.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Digit {
40    D0,
41    D1,
42    D2,
43    D3,
44    D4,
45    D5,
46    D6,
47    D7,
48    D8,
49    D9,
50    Star,
51    Hash,
52}
53
54impl Digit {
55    /// The document key for this digit (`"1"`, `"*"`, …) — matches the keys
56    /// of a `menu`'s `options` / `exits`.
57    pub fn as_key(self) -> &'static str {
58        match self {
59            Digit::D0 => "0",
60            Digit::D1 => "1",
61            Digit::D2 => "2",
62            Digit::D3 => "3",
63            Digit::D4 => "4",
64            Digit::D5 => "5",
65            Digit::D6 => "6",
66            Digit::D7 => "7",
67            Digit::D8 => "8",
68            Digit::D9 => "9",
69            Digit::Star => "*",
70            Digit::Hash => "#",
71        }
72    }
73}
74
75/// Result of a `ring` node.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum RingOutcome {
78    /// A human answered — they own the call now and the engine steps out.
79    Answered,
80    /// The ring window elapsed with no answer.
81    NoAnswer,
82}
83
84/// The side effects the components need. The engine calls these; the impl
85/// performs the telephony/audio. `&mut self` because a real impl holds the
86/// live call. Methods return `anyhow::Result` so the daemon impl can surface
87/// call-dropped / device errors uniformly; the engine treats any `Err` as
88/// "the call is gone" and aborts with a trace.
89#[async_trait]
90pub trait FlowEffects: Send {
91    /// Play a prompt (TTS text or an audio asset) to the caller, returning
92    /// when playback finishes.
93    async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()>;
94
95    /// Wait up to `timeout` for one DTMF digit. `Ok(None)` means the window
96    /// elapsed with no press (distinct from an error).
97    async fn collect_digit(&mut self, timeout: Duration) -> anyhow::Result<Option<Digit>>;
98
99    /// Ring the human for up to `timeout`.
100    async fn ring_human(&mut self, timeout: Duration) -> anyhow::Result<RingOutcome>;
101
102    /// Record a voicemail: play `tone` (the caller's record-start cue), then
103    /// capture up to `max`. The node's prompt has already been spoken via
104    /// [`FlowEffects::speak`] — the engine traces it as its own step. Returns
105    /// the seconds actually captured (for the trace).
106    async fn record_message(&mut self, tone: MessageTone, max: Duration) -> anyhow::Result<u32>;
107
108    /// Blind-transfer the call to `target`.
109    async fn transfer(&mut self, target: &str) -> anyhow::Result<()>;
110
111    /// Speak an optional goodbye and end the call.
112    async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()>;
113
114    /// The current instant, for `hours` evaluation. Injectable so tests pin a
115    /// fixed time and the schedule is deterministic.
116    fn now(&self) -> OffsetDateTime;
117
118    /// Called once as the engine *enters* a node, before that node's own
119    /// effects run — carries the node's id and its component kind
120    /// ([`Node::kind`], e.g. `"greeting"`, `"menu"`). Lets a live consumer
121    /// light up which node is executing *right now*, and accumulate the path
122    /// the caller has taken, without waiting for the run-end trace.
123    ///
124    /// Default no-op: the durable, ordered per-node record is the trace
125    /// ([`Trace`]); a consumer that only reads results at run end ignores
126    /// this. Fires for every visited node including `hours` (which runs no
127    /// other effect) and a node re-entered by a menu loop.
128    fn on_enter(&mut self, _node: &NodeId, _kind: &'static str) {}
129}
130
131/// Engine-internal failure. `Effect` is an outside failure (call dropped) →
132/// the run aborts; the others are "impossible on a validated flow" defects
133/// the engine refuses to guess through.
134#[derive(Debug, thiserror::Error)]
135enum EngineError {
136    #[error("effect failed: {0}")]
137    Effect(anyhow::Error),
138    #[error("node {0:?} not found")]
139    UnknownNode(NodeId),
140    #[error("node {node:?} has no {exit:?} exit")]
141    MissingExit { node: NodeId, exit: String },
142    #[error("hours evaluation at {node:?} failed: {source}")]
143    Hours { node: NodeId, source: HoursError },
144}
145
146impl EngineError {
147    /// The abnormal outcome this error maps to. An outside failure is an
148    /// `Aborted` run; everything else is a `Defect` (a validation gap).
149    fn outcome(&self) -> FlowOutcome {
150        match self {
151            EngineError::Effect(_) => FlowOutcome::Aborted,
152            _ => FlowOutcome::Defect,
153        }
154    }
155}
156
157/// One node's result: go to the next node, or end the call.
158enum Step {
159    Goto(NodeId),
160    End(FlowOutcome),
161}
162
163/// Run a validated flow to completion against `fx`, filling `trace`.
164///
165/// Infallible by design: every ending — clean terminal, aborted call, or
166/// engine defect — is captured in `trace` (its `outcome`, and `error` for
167/// abnormal ends), because the daemon wants to persist and surface *what
168/// happened* regardless. Callers check [`Trace::is_clean`] to decide whether
169/// to also log a warning.
170///
171/// The trace is caller-owned (see [`Trace::new`]) so a caller that races this
172/// future against the dialog's termination signal — and drops it when the
173/// caller hangs up mid-flow — still holds the steps executed so far. A
174/// cancelled run never writes `outcome`; it keeps the [`FlowOutcome::Defect`]
175/// placeholder.
176pub async fn run<E: FlowEffects>(flow: &Flow, fx: &mut E, trace: &mut Trace) {
177    let mut current = flow.entry.clone();
178
179    for _ in 0..MAX_STEPS {
180        let node = match flow.nodes.get(&current) {
181            Some(n) => n,
182            None => return abort(trace, EngineError::UnknownNode(current)),
183        };
184        match run_node(&current, node, fx, trace).await {
185            Ok(Step::Goto(next)) => current = next,
186            Ok(Step::End(outcome)) => {
187                trace.outcome = outcome;
188                return;
189            }
190            Err(err) => return abort(trace, err),
191        }
192    }
193
194    // Step cap hit — treat as a defect (validation should prevent it).
195    trace.outcome = FlowOutcome::Defect;
196    trace.error = Some(format!(
197        "step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
198    ));
199}
200
201/// Finalize a trace for an abnormal ending.
202fn abort(trace: &mut Trace, err: EngineError) {
203    trace.outcome = err.outcome();
204    trace.error = Some(err.to_string());
205}
206
207async fn run_node<E: FlowEffects>(
208    id: &NodeId,
209    node: &Node,
210    fx: &mut E,
211    trace: &mut Trace,
212) -> Result<Step, EngineError> {
213    let kind = node.kind();
214    // Announce the node the instant the engine reaches it — before any
215    // prompt plays or digit is collected — so a live view reflects the
216    // caller's true position, not where they were a prompt ago.
217    fx.on_enter(id, kind);
218    match node {
219        Node::Greeting { prompt, .. } => {
220            fx.speak(prompt).await.map_err(EngineError::Effect)?;
221            trace.push(id, kind, StepDetail::Spoke);
222            goto(id, node, "next")
223        }
224
225        Node::Hours { .. } => {
226            let result = hours::evaluate(node, fx.now()).map_err(|source| EngineError::Hours {
227                node: id.clone(),
228                source,
229            })?;
230            let open = result == hours::HoursResult::Open;
231            trace.push(id, kind, StepDetail::Hours { open });
232            goto(id, node, result.exit())
233        }
234
235        Node::Menu {
236            prompt,
237            options,
238            retries,
239            timeout_secs,
240            ..
241        } => {
242            run_menu(
243                id,
244                node,
245                fx,
246                trace,
247                prompt,
248                options,
249                *retries,
250                *timeout_secs,
251            )
252            .await
253        }
254
255        Node::Ring { timeout_secs, .. } => {
256            let outcome = fx
257                .ring_human(Duration::from_secs(*timeout_secs))
258                .await
259                .map_err(EngineError::Effect)?;
260            match outcome {
261                RingOutcome::Answered => {
262                    trace.push(id, kind, StepDetail::Ring { answered: true });
263                    Ok(Step::End(FlowOutcome::Answered))
264                }
265                RingOutcome::NoAnswer => {
266                    trace.push(id, kind, StepDetail::Ring { answered: false });
267                    goto(id, node, "no_answer")
268                }
269            }
270        }
271
272        Node::Message {
273            prompt,
274            max_secs,
275            tone,
276            ..
277        } => {
278            // The prompt is a traced step of its own, like a greeting's — it
279            // marks the "please leave a message" moment on the call's
280            // timeline, minutes before the recording that ends the node.
281            fx.speak(prompt).await.map_err(EngineError::Effect)?;
282            trace.push(id, kind, StepDetail::Spoke);
283            let secs = fx
284                .record_message(*tone, Duration::from_secs(*max_secs))
285                .await
286                .map_err(EngineError::Effect)?;
287            trace.push(id, kind, StepDetail::MessageRecorded { secs });
288            Ok(Step::End(FlowOutcome::MessageLeft))
289        }
290
291        Node::Transfer { target, .. } => {
292            fx.transfer(target).await.map_err(EngineError::Effect)?;
293            trace.push(
294                id,
295                kind,
296                StepDetail::Transferred {
297                    target: target.clone(),
298                },
299            );
300            Ok(Step::End(FlowOutcome::Transferred))
301        }
302
303        Node::Hangup { prompt, .. } => {
304            fx.hangup(prompt.as_ref())
305                .await
306                .map_err(EngineError::Effect)?;
307            trace.push(id, kind, StepDetail::HungUp);
308            Ok(Step::End(FlowOutcome::HungUp))
309        }
310    }
311}
312
313/// The menu retry loop. Speak, collect a digit, repeat up to `retries + 1`
314/// attempts. A mapped digit follows its exit; running out of attempts follows
315/// `invalid` if the caller pressed unmapped keys at all, else `no_input`
316/// (pure silence).
317#[allow(clippy::too_many_arguments)]
318async fn run_menu<E: FlowEffects>(
319    id: &NodeId,
320    node: &Node,
321    fx: &mut E,
322    trace: &mut Trace,
323    prompt: &Prompt,
324    options: &std::collections::HashMap<String, String>,
325    retries: u64,
326    timeout_secs: u64,
327) -> Result<Step, EngineError> {
328    let attempts = retries.saturating_add(1);
329    let mut heard_any_key = false;
330
331    for _ in 0..attempts {
332        fx.speak(prompt).await.map_err(EngineError::Effect)?;
333        let pressed = fx
334            .collect_digit(Duration::from_secs(timeout_secs))
335            .await
336            .map_err(EngineError::Effect)?;
337        match pressed {
338            Some(digit) if options.contains_key(digit.as_key()) => {
339                trace.push(
340                    id,
341                    "menu",
342                    StepDetail::MenuChoice {
343                        digit: digit.as_key().to_string(),
344                    },
345                );
346                return goto(id, node, digit.as_key());
347            }
348            Some(_) => heard_any_key = true, // unmapped key → retry
349            None => {}                       // timeout → retry
350        }
351    }
352
353    if heard_any_key {
354        trace.push(id, "menu", StepDetail::MenuInvalid);
355        goto(id, node, "invalid")
356    } else {
357        trace.push(id, "menu", StepDetail::MenuNoInput);
358        goto(id, node, "no_input")
359    }
360}
361
362/// Follow a named exit to the next node. On a validated flow the exit is
363/// always present; a miss is a defect, surfaced rather than guessed.
364fn goto(id: &NodeId, node: &Node, exit: &str) -> Result<Step, EngineError> {
365    node.exits()
366        .and_then(|exits| exits.get(exit))
367        .map(|target| Step::Goto(target.clone()))
368        .ok_or_else(|| EngineError::MissingExit {
369            node: id.clone(),
370            exit: exit.to_string(),
371        })
372}
373
374#[cfg(test)]
375mod tests {
376    use std::collections::VecDeque;
377
378    use time::macros::datetime;
379
380    use super::*;
381    use crate::trace::FlowOutcome;
382    use crate::validate::validate;
383
384    /// A scripted [`FlowEffects`] — the scenario harness doc 48's M1 "done"
385    /// criterion describes ("call the line ourselves, scenario by scenario"),
386    /// at unit speed. Feed it a clock, a queue of digit presses, and a ring
387    /// outcome; read back what the flow spoke and did.
388    struct MockEffects {
389        now: OffsetDateTime,
390        digits: VecDeque<Option<Digit>>,
391        ring: RingOutcome,
392        message_secs: u32,
393        // observed:
394        spoken: Vec<String>,
395        transferred: Option<String>,
396        recorded: bool,
397        /// The record-start cue the message node asked for, when one ran.
398        record_tone: Option<MessageTone>,
399        hung_up: bool,
400        fail_speak: bool,
401        /// Every node the engine entered, in visit order (id, kind) — the
402        /// live-position signal `on_enter` feeds.
403        entered: Vec<(String, &'static str)>,
404    }
405
406    impl MockEffects {
407        fn new(now: OffsetDateTime) -> Self {
408            MockEffects {
409                now,
410                digits: VecDeque::new(),
411                ring: RingOutcome::NoAnswer,
412                message_secs: 0,
413                spoken: Vec::new(),
414                transferred: None,
415                recorded: false,
416                record_tone: None,
417                hung_up: false,
418                fail_speak: false,
419                entered: Vec::new(),
420            }
421        }
422        fn digits(mut self, seq: impl IntoIterator<Item = Option<Digit>>) -> Self {
423            self.digits = seq.into_iter().collect();
424            self
425        }
426        fn ring(mut self, r: RingOutcome) -> Self {
427            self.ring = r;
428            self
429        }
430        fn message_secs(mut self, s: u32) -> Self {
431            self.message_secs = s;
432            self
433        }
434    }
435
436    fn prompt_label(p: &Prompt) -> String {
437        match p.as_text() {
438            Some(t) => t.to_string(),
439            None => "<audio>".to_string(),
440        }
441    }
442
443    #[async_trait]
444    impl FlowEffects for MockEffects {
445        async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()> {
446            if self.fail_speak {
447                anyhow::bail!("caller hung up");
448            }
449            self.spoken.push(prompt_label(prompt));
450            Ok(())
451        }
452        async fn collect_digit(&mut self, _timeout: Duration) -> anyhow::Result<Option<Digit>> {
453            // Exhausted script = silence (timeout), never an error.
454            Ok(self.digits.pop_front().flatten())
455        }
456        async fn ring_human(&mut self, _timeout: Duration) -> anyhow::Result<RingOutcome> {
457            Ok(self.ring)
458        }
459        async fn record_message(
460            &mut self,
461            tone: MessageTone,
462            _max: Duration,
463        ) -> anyhow::Result<u32> {
464            self.recorded = true;
465            self.record_tone = Some(tone);
466            Ok(self.message_secs)
467        }
468        async fn transfer(&mut self, target: &str) -> anyhow::Result<()> {
469            self.transferred = Some(target.to_string());
470            Ok(())
471        }
472        async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()> {
473            if let Some(p) = prompt {
474                self.spoken.push(prompt_label(p));
475            }
476            self.hung_up = true;
477            Ok(())
478        }
479        fn now(&self) -> OffsetDateTime {
480            self.now
481        }
482        fn on_enter(&mut self, node: &NodeId, kind: &'static str) {
483            self.entered.push((node.clone(), kind));
484        }
485    }
486
487    const LUIGIS: &str = r#"
488schema_version: 1
489id: flow_luigi
490name: Luigi's — after hours
491version: 3
492entry: welcome
493nodes:
494  welcome:
495    kind: greeting
496    prompt: Thanks for calling Luigi's!
497    exits: { next: check_hours }
498  check_hours:
499    kind: hours
500    timezone: America/New_York
501    schedule:
502      tue: [{ open: "11:00", close: "22:00" }]
503    exits: { open: front_desk, closed: night_menu }
504  front_desk:
505    kind: ring
506    timeout_secs: 25
507    exits: { no_answer: take_message }
508  night_menu:
509    kind: menu
510    prompt: We're closed. Press 1 for hours, or hold for a message.
511    options: { "1": Hours }
512    retries: 1
513    exits: { "1": say_hours, no_input: take_message, invalid: take_message }
514  say_hours:
515    kind: greeting
516    prompt: We're open Tuesday to Sunday, eleven to ten.
517    exits: { next: take_message }
518  take_message:
519    kind: message
520    prompt: Please leave your name and number after the tone.
521"#;
522
523    fn luigis() -> Flow {
524        let flow = Flow::from_yaml(LUIGIS).expect("parses");
525        validate(&flow).expect("the scenario flow must be valid");
526        flow
527    }
528
529    // A Tuesday during business hours: 15:00 EDT = 19:00 UTC.
530    fn open_time() -> OffsetDateTime {
531        datetime!(2026-07-07 19:00 UTC)
532    }
533    // A Tuesday after close: 23:00 EDT = 03:00 UTC Wednesday.
534    fn closed_time() -> OffsetDateTime {
535        datetime!(2026-07-08 03:00 UTC)
536    }
537
538    fn kinds(trace: &Trace) -> Vec<&str> {
539        trace.steps.iter().map(|s| s.kind).collect()
540    }
541
542    /// Test-side ergonomics for the caller-owned-trace API: build the trace,
543    /// run, hand it back.
544    async fn run_trace(flow: &Flow, fx: &mut MockEffects) -> Trace {
545        let mut trace = Trace::new(&flow.id, flow.version);
546        run(flow, fx, &mut trace).await;
547        trace
548    }
549
550    #[tokio::test]
551    async fn open_hours_human_answers() {
552        let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
553        let trace = run_trace(&luigis(), &mut fx).await;
554
555        assert_eq!(trace.outcome, FlowOutcome::Answered);
556        assert!(trace.is_clean());
557        assert_eq!(kinds(&trace), vec!["greeting", "hours", "ring"]);
558        // The hours branch went `open`.
559        assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: true });
560        assert!(!fx.recorded, "a human answered — no voicemail");
561    }
562
563    #[tokio::test]
564    async fn on_enter_reports_each_node_as_the_engine_reaches_it() {
565        // Human answers during open hours: greeting → hours → ring. The
566        // effect-less `hours` node still announces itself, so a live view
567        // can light it — the whole point of the hook over the effect calls.
568        let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
569        let _ = run_trace(&luigis(), &mut fx).await;
570        assert_eq!(
571            fx.entered,
572            vec![
573                ("welcome".to_string(), "greeting"),
574                ("check_hours".to_string(), "hours"),
575                ("front_desk".to_string(), "ring"),
576            ],
577            "on_enter fires once per visited node, in path order"
578        );
579
580        // Closed → press 1 → hours greeting → voicemail: the live position
581        // tracks every hop, and the ids line up with the run-end trace.
582        let mut fx = MockEffects::new(closed_time())
583            .digits([Some(Digit::D1)])
584            .message_secs(12);
585        let trace = run_trace(&luigis(), &mut fx).await;
586        let entered_ids: Vec<&str> = fx.entered.iter().map(|(id, _)| id.as_str()).collect();
587        assert_eq!(
588            entered_ids,
589            vec![
590                "welcome",
591                "check_hours",
592                "night_menu",
593                "say_hours",
594                "take_message"
595            ],
596        );
597        // A menu's internal retries don't re-enter the node — it's announced
598        // once even though run_menu may loop for a bad/absent key.
599        assert_eq!(
600            fx.entered
601                .iter()
602                .filter(|(id, _)| id == "night_menu")
603                .count(),
604            1,
605        );
606        // Every node the trace recorded is a node on_enter announced. The
607        // message node traces twice (prompt, then recording) but is entered
608        // once, so collapse consecutive repeats before comparing.
609        let mut trace_nodes: Vec<&str> = trace.steps.iter().map(|s| s.node.as_str()).collect();
610        trace_nodes.dedup();
611        assert_eq!(entered_ids, trace_nodes);
612    }
613
614    #[tokio::test]
615    async fn open_hours_no_answer_falls_to_voicemail() {
616        let mut fx = MockEffects::new(open_time())
617            .ring(RingOutcome::NoAnswer)
618            .message_secs(40);
619        let trace = run_trace(&luigis(), &mut fx).await;
620
621        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
622        assert_eq!(
623            kinds(&trace),
624            vec!["greeting", "hours", "ring", "message", "message"]
625        );
626        // The voicemail prompt is its own traced step (regression: it used to
627        // leave no mark on the call's timeline), followed by the recording
628        // that ends the run.
629        assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
630        assert_eq!(
631            trace.steps[4].detail,
632            StepDetail::MessageRecorded { secs: 40 }
633        );
634        // The caller actually heard the prompt before the recording.
635        assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
636        assert!(fx.recorded);
637        // The unconfigured node carried the default record-start cue.
638        assert_eq!(fx.record_tone, Some(MessageTone::Beep));
639    }
640
641    #[tokio::test]
642    async fn closed_press_one_hears_hours_then_leaves_message() {
643        let mut fx = MockEffects::new(closed_time())
644            .digits([Some(Digit::D1)])
645            .message_secs(12);
646        let trace = run_trace(&luigis(), &mut fx).await;
647
648        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
649        assert_eq!(
650            kinds(&trace),
651            vec!["greeting", "hours", "menu", "greeting", "message", "message"]
652        );
653        assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: false });
654        assert_eq!(
655            trace.steps[2].detail,
656            StepDetail::MenuChoice { digit: "1".into() }
657        );
658        // The caller heard the opening-hours greeting.
659        assert!(fx
660            .spoken
661            .iter()
662            .any(|s| s.contains("open Tuesday to Sunday")));
663    }
664
665    #[tokio::test]
666    async fn closed_silence_takes_no_input_exit() {
667        // No digits scripted → every collect times out → no_input.
668        let mut fx = MockEffects::new(closed_time());
669        let trace = run_trace(&luigis(), &mut fx).await;
670
671        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
672        assert_eq!(
673            kinds(&trace),
674            vec!["greeting", "hours", "menu", "message", "message"]
675        );
676        assert_eq!(trace.steps[2].detail, StepDetail::MenuNoInput);
677    }
678
679    #[tokio::test]
680    async fn closed_wrong_keys_take_invalid_exit_after_retry() {
681        // retries: 1 → 2 attempts; press an unmapped key both times.
682        let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D9), Some(Digit::D7)]);
683        let trace = run_trace(&luigis(), &mut fx).await;
684
685        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
686        assert_eq!(trace.steps[2].detail, StepDetail::MenuInvalid);
687        // The menu prompt was spoken once per attempt.
688        let menu_prompts = fx
689            .spoken
690            .iter()
691            .filter(|s| s.contains("Press 1 for hours"))
692            .count();
693        assert_eq!(menu_prompts, 2);
694    }
695
696    #[tokio::test]
697    async fn wrong_key_then_valid_digit_still_routes() {
698        // First attempt an unmapped key, second the real option.
699        let mut fx = MockEffects::new(closed_time())
700            .digits([Some(Digit::D9), Some(Digit::D1)])
701            .message_secs(5);
702        let trace = run_trace(&luigis(), &mut fx).await;
703
704        assert_eq!(
705            trace.steps[2].detail,
706            StepDetail::MenuChoice { digit: "1".into() }
707        );
708        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
709    }
710
711    #[tokio::test]
712    async fn steps_carry_monotonic_timeline_offsets() {
713        // Each step is stamped with its offset from run start, so the daemon
714        // can place it on the call's (and recording's) timeline. Mock effects
715        // resolve instantly, so we can't assert real gaps — only that the
716        // offsets exist and never run backwards.
717        let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D1)]);
718        let trace = run_trace(&luigis(), &mut fx).await;
719
720        assert!(trace.steps.len() >= 3);
721        let offsets: Vec<u64> = trace.steps.iter().map(|s| s.at_ms).collect();
722        assert!(
723            offsets.windows(2).all(|w| w[0] <= w[1]),
724            "offsets must be non-decreasing: {offsets:?}"
725        );
726    }
727
728    #[tokio::test]
729    async fn effect_failure_aborts_with_partial_trace() {
730        let mut fx = MockEffects::new(open_time());
731        fx.fail_speak = true; // the caller hangs up during the greeting
732        let trace = run_trace(&luigis(), &mut fx).await;
733
734        assert_eq!(trace.outcome, FlowOutcome::Aborted);
735        assert!(!trace.is_clean());
736        assert!(trace.error.as_deref().unwrap().contains("caller hung up"));
737        // Nothing was appended — it failed on the first node's effect.
738        assert!(trace.steps.is_empty());
739    }
740
741    #[tokio::test]
742    async fn transfer_and_hangup_terminals() {
743        let src = r#"
744schema_version: 1
745id: f
746name: n
747entry: g
748nodes:
749  g:
750    kind: greeting
751    prompt: one moment
752    exits: { next: t }
753  t:
754    kind: transfer
755    target: sip:desk@example.com
756"#;
757        let flow = Flow::from_yaml(src).unwrap();
758        validate(&flow).unwrap();
759        let mut fx = MockEffects::new(open_time());
760        let trace = run_trace(&flow, &mut fx).await;
761        assert_eq!(trace.outcome, FlowOutcome::Transferred);
762        assert_eq!(fx.transferred.as_deref(), Some("sip:desk@example.com"));
763    }
764}