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
119/// Engine-internal failure. `Effect` is an outside failure (call dropped) →
120/// the run aborts; the others are "impossible on a validated flow" defects
121/// the engine refuses to guess through.
122#[derive(Debug, thiserror::Error)]
123enum EngineError {
124    #[error("effect failed: {0}")]
125    Effect(anyhow::Error),
126    #[error("node {0:?} not found")]
127    UnknownNode(NodeId),
128    #[error("node {node:?} has no {exit:?} exit")]
129    MissingExit { node: NodeId, exit: String },
130    #[error("hours evaluation at {node:?} failed: {source}")]
131    Hours { node: NodeId, source: HoursError },
132}
133
134impl EngineError {
135    /// The abnormal outcome this error maps to. An outside failure is an
136    /// `Aborted` run; everything else is a `Defect` (a validation gap).
137    fn outcome(&self) -> FlowOutcome {
138        match self {
139            EngineError::Effect(_) => FlowOutcome::Aborted,
140            _ => FlowOutcome::Defect,
141        }
142    }
143}
144
145/// One node's result: go to the next node, or end the call.
146enum Step {
147    Goto(NodeId),
148    End(FlowOutcome),
149}
150
151/// Run a validated flow to completion against `fx`, filling `trace`.
152///
153/// Infallible by design: every ending — clean terminal, aborted call, or
154/// engine defect — is captured in `trace` (its `outcome`, and `error` for
155/// abnormal ends), because the daemon wants to persist and surface *what
156/// happened* regardless. Callers check [`Trace::is_clean`] to decide whether
157/// to also log a warning.
158///
159/// The trace is caller-owned (see [`Trace::new`]) so a caller that races this
160/// future against the dialog's termination signal — and drops it when the
161/// caller hangs up mid-flow — still holds the steps executed so far. A
162/// cancelled run never writes `outcome`; it keeps the [`FlowOutcome::Defect`]
163/// placeholder.
164pub async fn run<E: FlowEffects>(flow: &Flow, fx: &mut E, trace: &mut Trace) {
165    let mut current = flow.entry.clone();
166
167    for _ in 0..MAX_STEPS {
168        let node = match flow.nodes.get(&current) {
169            Some(n) => n,
170            None => return abort(trace, EngineError::UnknownNode(current)),
171        };
172        match run_node(&current, node, fx, trace).await {
173            Ok(Step::Goto(next)) => current = next,
174            Ok(Step::End(outcome)) => {
175                trace.outcome = outcome;
176                return;
177            }
178            Err(err) => return abort(trace, err),
179        }
180    }
181
182    // Step cap hit — treat as a defect (validation should prevent it).
183    trace.outcome = FlowOutcome::Defect;
184    trace.error = Some(format!(
185        "step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
186    ));
187}
188
189/// Finalize a trace for an abnormal ending.
190fn abort(trace: &mut Trace, err: EngineError) {
191    trace.outcome = err.outcome();
192    trace.error = Some(err.to_string());
193}
194
195async fn run_node<E: FlowEffects>(
196    id: &NodeId,
197    node: &Node,
198    fx: &mut E,
199    trace: &mut Trace,
200) -> Result<Step, EngineError> {
201    let kind = node.kind();
202    match node {
203        Node::Greeting { prompt, .. } => {
204            fx.speak(prompt).await.map_err(EngineError::Effect)?;
205            trace.push(id, kind, StepDetail::Spoke);
206            goto(id, node, "next")
207        }
208
209        Node::Hours { .. } => {
210            let result = hours::evaluate(node, fx.now()).map_err(|source| EngineError::Hours {
211                node: id.clone(),
212                source,
213            })?;
214            let open = result == hours::HoursResult::Open;
215            trace.push(id, kind, StepDetail::Hours { open });
216            goto(id, node, result.exit())
217        }
218
219        Node::Menu {
220            prompt,
221            options,
222            retries,
223            timeout_secs,
224            ..
225        } => {
226            run_menu(
227                id,
228                node,
229                fx,
230                trace,
231                prompt,
232                options,
233                *retries,
234                *timeout_secs,
235            )
236            .await
237        }
238
239        Node::Ring { timeout_secs, .. } => {
240            let outcome = fx
241                .ring_human(Duration::from_secs(*timeout_secs))
242                .await
243                .map_err(EngineError::Effect)?;
244            match outcome {
245                RingOutcome::Answered => {
246                    trace.push(id, kind, StepDetail::Ring { answered: true });
247                    Ok(Step::End(FlowOutcome::Answered))
248                }
249                RingOutcome::NoAnswer => {
250                    trace.push(id, kind, StepDetail::Ring { answered: false });
251                    goto(id, node, "no_answer")
252                }
253            }
254        }
255
256        Node::Message {
257            prompt,
258            max_secs,
259            tone,
260            ..
261        } => {
262            // The prompt is a traced step of its own, like a greeting's — it
263            // marks the "please leave a message" moment on the call's
264            // timeline, minutes before the recording that ends the node.
265            fx.speak(prompt).await.map_err(EngineError::Effect)?;
266            trace.push(id, kind, StepDetail::Spoke);
267            let secs = fx
268                .record_message(*tone, Duration::from_secs(*max_secs))
269                .await
270                .map_err(EngineError::Effect)?;
271            trace.push(id, kind, StepDetail::MessageRecorded { secs });
272            Ok(Step::End(FlowOutcome::MessageLeft))
273        }
274
275        Node::Transfer { target, .. } => {
276            fx.transfer(target).await.map_err(EngineError::Effect)?;
277            trace.push(
278                id,
279                kind,
280                StepDetail::Transferred {
281                    target: target.clone(),
282                },
283            );
284            Ok(Step::End(FlowOutcome::Transferred))
285        }
286
287        Node::Hangup { prompt, .. } => {
288            fx.hangup(prompt.as_ref())
289                .await
290                .map_err(EngineError::Effect)?;
291            trace.push(id, kind, StepDetail::HungUp);
292            Ok(Step::End(FlowOutcome::HungUp))
293        }
294    }
295}
296
297/// The menu retry loop. Speak, collect a digit, repeat up to `retries + 1`
298/// attempts. A mapped digit follows its exit; running out of attempts follows
299/// `invalid` if the caller pressed unmapped keys at all, else `no_input`
300/// (pure silence).
301#[allow(clippy::too_many_arguments)]
302async fn run_menu<E: FlowEffects>(
303    id: &NodeId,
304    node: &Node,
305    fx: &mut E,
306    trace: &mut Trace,
307    prompt: &Prompt,
308    options: &std::collections::HashMap<String, String>,
309    retries: u64,
310    timeout_secs: u64,
311) -> Result<Step, EngineError> {
312    let attempts = retries.saturating_add(1);
313    let mut heard_any_key = false;
314
315    for _ in 0..attempts {
316        fx.speak(prompt).await.map_err(EngineError::Effect)?;
317        let pressed = fx
318            .collect_digit(Duration::from_secs(timeout_secs))
319            .await
320            .map_err(EngineError::Effect)?;
321        match pressed {
322            Some(digit) if options.contains_key(digit.as_key()) => {
323                trace.push(
324                    id,
325                    "menu",
326                    StepDetail::MenuChoice {
327                        digit: digit.as_key().to_string(),
328                    },
329                );
330                return goto(id, node, digit.as_key());
331            }
332            Some(_) => heard_any_key = true, // unmapped key → retry
333            None => {}                       // timeout → retry
334        }
335    }
336
337    if heard_any_key {
338        trace.push(id, "menu", StepDetail::MenuInvalid);
339        goto(id, node, "invalid")
340    } else {
341        trace.push(id, "menu", StepDetail::MenuNoInput);
342        goto(id, node, "no_input")
343    }
344}
345
346/// Follow a named exit to the next node. On a validated flow the exit is
347/// always present; a miss is a defect, surfaced rather than guessed.
348fn goto(id: &NodeId, node: &Node, exit: &str) -> Result<Step, EngineError> {
349    node.exits()
350        .and_then(|exits| exits.get(exit))
351        .map(|target| Step::Goto(target.clone()))
352        .ok_or_else(|| EngineError::MissingExit {
353            node: id.clone(),
354            exit: exit.to_string(),
355        })
356}
357
358#[cfg(test)]
359mod tests {
360    use std::collections::VecDeque;
361
362    use time::macros::datetime;
363
364    use super::*;
365    use crate::trace::FlowOutcome;
366    use crate::validate::validate;
367
368    /// A scripted [`FlowEffects`] — the scenario harness doc 48's M1 "done"
369    /// criterion describes ("call the line ourselves, scenario by scenario"),
370    /// at unit speed. Feed it a clock, a queue of digit presses, and a ring
371    /// outcome; read back what the flow spoke and did.
372    struct MockEffects {
373        now: OffsetDateTime,
374        digits: VecDeque<Option<Digit>>,
375        ring: RingOutcome,
376        message_secs: u32,
377        // observed:
378        spoken: Vec<String>,
379        transferred: Option<String>,
380        recorded: bool,
381        /// The record-start cue the message node asked for, when one ran.
382        record_tone: Option<MessageTone>,
383        hung_up: bool,
384        fail_speak: bool,
385    }
386
387    impl MockEffects {
388        fn new(now: OffsetDateTime) -> Self {
389            MockEffects {
390                now,
391                digits: VecDeque::new(),
392                ring: RingOutcome::NoAnswer,
393                message_secs: 0,
394                spoken: Vec::new(),
395                transferred: None,
396                recorded: false,
397                record_tone: None,
398                hung_up: false,
399                fail_speak: false,
400            }
401        }
402        fn digits(mut self, seq: impl IntoIterator<Item = Option<Digit>>) -> Self {
403            self.digits = seq.into_iter().collect();
404            self
405        }
406        fn ring(mut self, r: RingOutcome) -> Self {
407            self.ring = r;
408            self
409        }
410        fn message_secs(mut self, s: u32) -> Self {
411            self.message_secs = s;
412            self
413        }
414    }
415
416    fn prompt_label(p: &Prompt) -> String {
417        match p.as_text() {
418            Some(t) => t.to_string(),
419            None => "<audio>".to_string(),
420        }
421    }
422
423    #[async_trait]
424    impl FlowEffects for MockEffects {
425        async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()> {
426            if self.fail_speak {
427                anyhow::bail!("caller hung up");
428            }
429            self.spoken.push(prompt_label(prompt));
430            Ok(())
431        }
432        async fn collect_digit(&mut self, _timeout: Duration) -> anyhow::Result<Option<Digit>> {
433            // Exhausted script = silence (timeout), never an error.
434            Ok(self.digits.pop_front().flatten())
435        }
436        async fn ring_human(&mut self, _timeout: Duration) -> anyhow::Result<RingOutcome> {
437            Ok(self.ring)
438        }
439        async fn record_message(
440            &mut self,
441            tone: MessageTone,
442            _max: Duration,
443        ) -> anyhow::Result<u32> {
444            self.recorded = true;
445            self.record_tone = Some(tone);
446            Ok(self.message_secs)
447        }
448        async fn transfer(&mut self, target: &str) -> anyhow::Result<()> {
449            self.transferred = Some(target.to_string());
450            Ok(())
451        }
452        async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()> {
453            if let Some(p) = prompt {
454                self.spoken.push(prompt_label(p));
455            }
456            self.hung_up = true;
457            Ok(())
458        }
459        fn now(&self) -> OffsetDateTime {
460            self.now
461        }
462    }
463
464    const LUIGIS: &str = r#"
465schema_version: 1
466id: flow_luigi
467name: Luigi's — after hours
468version: 3
469entry: welcome
470nodes:
471  welcome:
472    kind: greeting
473    prompt: Thanks for calling Luigi's!
474    exits: { next: check_hours }
475  check_hours:
476    kind: hours
477    timezone: America/New_York
478    schedule:
479      tue: [{ open: "11:00", close: "22:00" }]
480    exits: { open: front_desk, closed: night_menu }
481  front_desk:
482    kind: ring
483    timeout_secs: 25
484    exits: { no_answer: take_message }
485  night_menu:
486    kind: menu
487    prompt: We're closed. Press 1 for hours, or hold for a message.
488    options: { "1": Hours }
489    retries: 1
490    exits: { "1": say_hours, no_input: take_message, invalid: take_message }
491  say_hours:
492    kind: greeting
493    prompt: We're open Tuesday to Sunday, eleven to ten.
494    exits: { next: take_message }
495  take_message:
496    kind: message
497    prompt: Please leave your name and number after the tone.
498"#;
499
500    fn luigis() -> Flow {
501        let flow = Flow::from_yaml(LUIGIS).expect("parses");
502        validate(&flow).expect("the scenario flow must be valid");
503        flow
504    }
505
506    // A Tuesday during business hours: 15:00 EDT = 19:00 UTC.
507    fn open_time() -> OffsetDateTime {
508        datetime!(2026-07-07 19:00 UTC)
509    }
510    // A Tuesday after close: 23:00 EDT = 03:00 UTC Wednesday.
511    fn closed_time() -> OffsetDateTime {
512        datetime!(2026-07-08 03:00 UTC)
513    }
514
515    fn kinds(trace: &Trace) -> Vec<&str> {
516        trace.steps.iter().map(|s| s.kind).collect()
517    }
518
519    /// Test-side ergonomics for the caller-owned-trace API: build the trace,
520    /// run, hand it back.
521    async fn run_trace(flow: &Flow, fx: &mut MockEffects) -> Trace {
522        let mut trace = Trace::new(&flow.id, flow.version);
523        run(flow, fx, &mut trace).await;
524        trace
525    }
526
527    #[tokio::test]
528    async fn open_hours_human_answers() {
529        let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
530        let trace = run_trace(&luigis(), &mut fx).await;
531
532        assert_eq!(trace.outcome, FlowOutcome::Answered);
533        assert!(trace.is_clean());
534        assert_eq!(kinds(&trace), vec!["greeting", "hours", "ring"]);
535        // The hours branch went `open`.
536        assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: true });
537        assert!(!fx.recorded, "a human answered — no voicemail");
538    }
539
540    #[tokio::test]
541    async fn open_hours_no_answer_falls_to_voicemail() {
542        let mut fx = MockEffects::new(open_time())
543            .ring(RingOutcome::NoAnswer)
544            .message_secs(40);
545        let trace = run_trace(&luigis(), &mut fx).await;
546
547        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
548        assert_eq!(
549            kinds(&trace),
550            vec!["greeting", "hours", "ring", "message", "message"]
551        );
552        // The voicemail prompt is its own traced step (regression: it used to
553        // leave no mark on the call's timeline), followed by the recording
554        // that ends the run.
555        assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
556        assert_eq!(
557            trace.steps[4].detail,
558            StepDetail::MessageRecorded { secs: 40 }
559        );
560        // The caller actually heard the prompt before the recording.
561        assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
562        assert!(fx.recorded);
563        // The unconfigured node carried the default record-start cue.
564        assert_eq!(fx.record_tone, Some(MessageTone::Beep));
565    }
566
567    #[tokio::test]
568    async fn closed_press_one_hears_hours_then_leaves_message() {
569        let mut fx = MockEffects::new(closed_time())
570            .digits([Some(Digit::D1)])
571            .message_secs(12);
572        let trace = run_trace(&luigis(), &mut fx).await;
573
574        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
575        assert_eq!(
576            kinds(&trace),
577            vec!["greeting", "hours", "menu", "greeting", "message", "message"]
578        );
579        assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: false });
580        assert_eq!(
581            trace.steps[2].detail,
582            StepDetail::MenuChoice { digit: "1".into() }
583        );
584        // The caller heard the opening-hours greeting.
585        assert!(fx
586            .spoken
587            .iter()
588            .any(|s| s.contains("open Tuesday to Sunday")));
589    }
590
591    #[tokio::test]
592    async fn closed_silence_takes_no_input_exit() {
593        // No digits scripted → every collect times out → no_input.
594        let mut fx = MockEffects::new(closed_time());
595        let trace = run_trace(&luigis(), &mut fx).await;
596
597        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
598        assert_eq!(
599            kinds(&trace),
600            vec!["greeting", "hours", "menu", "message", "message"]
601        );
602        assert_eq!(trace.steps[2].detail, StepDetail::MenuNoInput);
603    }
604
605    #[tokio::test]
606    async fn closed_wrong_keys_take_invalid_exit_after_retry() {
607        // retries: 1 → 2 attempts; press an unmapped key both times.
608        let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D9), Some(Digit::D7)]);
609        let trace = run_trace(&luigis(), &mut fx).await;
610
611        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
612        assert_eq!(trace.steps[2].detail, StepDetail::MenuInvalid);
613        // The menu prompt was spoken once per attempt.
614        let menu_prompts = fx
615            .spoken
616            .iter()
617            .filter(|s| s.contains("Press 1 for hours"))
618            .count();
619        assert_eq!(menu_prompts, 2);
620    }
621
622    #[tokio::test]
623    async fn wrong_key_then_valid_digit_still_routes() {
624        // First attempt an unmapped key, second the real option.
625        let mut fx = MockEffects::new(closed_time())
626            .digits([Some(Digit::D9), Some(Digit::D1)])
627            .message_secs(5);
628        let trace = run_trace(&luigis(), &mut fx).await;
629
630        assert_eq!(
631            trace.steps[2].detail,
632            StepDetail::MenuChoice { digit: "1".into() }
633        );
634        assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
635    }
636
637    #[tokio::test]
638    async fn steps_carry_monotonic_timeline_offsets() {
639        // Each step is stamped with its offset from run start, so the daemon
640        // can place it on the call's (and recording's) timeline. Mock effects
641        // resolve instantly, so we can't assert real gaps — only that the
642        // offsets exist and never run backwards.
643        let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D1)]);
644        let trace = run_trace(&luigis(), &mut fx).await;
645
646        assert!(trace.steps.len() >= 3);
647        let offsets: Vec<u64> = trace.steps.iter().map(|s| s.at_ms).collect();
648        assert!(
649            offsets.windows(2).all(|w| w[0] <= w[1]),
650            "offsets must be non-decreasing: {offsets:?}"
651        );
652    }
653
654    #[tokio::test]
655    async fn effect_failure_aborts_with_partial_trace() {
656        let mut fx = MockEffects::new(open_time());
657        fx.fail_speak = true; // the caller hangs up during the greeting
658        let trace = run_trace(&luigis(), &mut fx).await;
659
660        assert_eq!(trace.outcome, FlowOutcome::Aborted);
661        assert!(!trace.is_clean());
662        assert!(trace.error.as_deref().unwrap().contains("caller hung up"));
663        // Nothing was appended — it failed on the first node's effect.
664        assert!(trace.steps.is_empty());
665    }
666
667    #[tokio::test]
668    async fn transfer_and_hangup_terminals() {
669        let src = r#"
670schema_version: 1
671id: f
672name: n
673entry: g
674nodes:
675  g:
676    kind: greeting
677    prompt: one moment
678    exits: { next: t }
679  t:
680    kind: transfer
681    target: sip:desk@example.com
682"#;
683        let flow = Flow::from_yaml(src).unwrap();
684        validate(&flow).unwrap();
685        let mut fx = MockEffects::new(open_time());
686        let trace = run_trace(&flow, &mut fx).await;
687        assert_eq!(trace.outcome, FlowOutcome::Transferred);
688        assert_eq!(fx.transferred.as_deref(), Some("sip:desk@example.com"));
689    }
690}