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::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
33/// Backstop against a cycle in a flow that somehow reached the engine
34/// unvalidated (validation's trap check should make this unreachable). A real
35/// flow visits a handful of nodes; 100 is far above any genuine path and far
36/// below an infinite loop.
37const MAX_STEPS: u32 = 100;
38
39/// A DTMF key a caller can press.
40#[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    /// The document key for this digit (`"1"`, `"*"`, …) — matches the keys
58    /// of a `menu`'s `options` / `exits`.
59    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/// What a `book` node needs to know to ask "when is this business free?"
78/// — its own config, nothing about the caller and nothing about which
79/// calendar answers. Borrowed from the node, so the engine copies no
80/// schedules around.
81#[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    /// How many times to ask for. The answer may be shorter, never longer.
91    pub max_offers: u64,
92}
93
94/// One offerable appointment, as absolute instants (RFC 3339). The
95/// engine never invents these and never adjusts them — it offers what it
96/// was given and hands the chosen one straight back.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Slot {
99    pub start: String,
100    pub end: String,
101}
102
103/// The answer to [`FlowEffects::fetch_slots`].
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct SlotOffer {
106    pub slots: Vec<Slot>,
107    /// The zone the times should be *said* in — the business's, echoed
108    /// back so the engine doesn't have to trust its own parse of the
109    /// node's config.
110    pub timezone: String,
111}
112
113/// What came of trying to book one slot.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum BookOutcome {
116    /// It is in the calendar; the caller has an appointment.
117    Booked,
118    /// Somebody else took it between the offer and the keypress. Worth
119    /// its own answer because the caller can be offered something else,
120    /// where `Unavailable` means asking again is pointless.
121    SlotTaken,
122    /// The booking could not be attempted or did not stick — no
123    /// connection, a provider outage, a refused request.
124    Unavailable,
125}
126
127/// Result of a `ring` node.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum RingOutcome {
130    /// A human answered — they own the call now and the engine steps out.
131    Answered,
132    /// The ring window elapsed with no answer.
133    NoAnswer,
134}
135
136/// The side effects the components need. The engine calls these; the impl
137/// performs the telephony/audio. `&mut self` because a real impl holds the
138/// live call. Methods return `anyhow::Result` so the daemon impl can surface
139/// call-dropped / device errors uniformly; the engine treats any `Err` as
140/// "the call is gone" and aborts with a trace.
141#[async_trait]
142pub trait FlowEffects: Send {
143    /// Play a prompt (TTS text or an audio asset) to the caller, returning
144    /// when playback finishes.
145    async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()>;
146
147    /// Wait up to `timeout` for one DTMF digit. `Ok(None)` means the window
148    /// elapsed with no press (distinct from an error).
149    async fn collect_digit(&mut self, timeout: Duration) -> anyhow::Result<Option<Digit>>;
150
151    /// Ring the human for up to `timeout`.
152    async fn ring_human(&mut self, timeout: Duration) -> anyhow::Result<RingOutcome>;
153
154    /// Record a voicemail: play `tone` (the caller's record-start cue), then
155    /// capture up to `max`. The node's prompt has already been spoken via
156    /// [`FlowEffects::speak`] — the engine traces it as its own step. Returns
157    /// the seconds actually captured (for the trace).
158    async fn record_message(&mut self, tone: MessageTone, max: Duration) -> anyhow::Result<u32>;
159
160    /// Blind-transfer the call to `target`.
161    async fn transfer(&mut self, target: &str) -> anyhow::Result<()>;
162
163    /// Speak an optional goodbye and end the call.
164    async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()>;
165
166    /// Ask when the business is free, for a `book` node.
167    ///
168    /// The credential never comes near here: the impl asks the platform,
169    /// which holds the calendar connection and answers with times. An
170    /// `Err` is a transport failure — the platform unreachable, a
171    /// timeout — and routes the caller to the node's `unavailable` exit
172    /// rather than aborting the call, because unlike [`Self::speak`]
173    /// this failing says nothing about whether the caller is still
174    /// there. A working call with no calendar behind it still has a
175    /// voicemail to fall to.
176    async fn fetch_slots(&mut self, query: &SlotQuery<'_>) -> anyhow::Result<SlotOffer>;
177
178    /// Book one of the slots [`Self::fetch_slots`] returned. Idempotent
179    /// per call as far as the engine is concerned — it asks at most
180    /// twice per node, and never for two different slots at once.
181    /// `Err` is treated as [`BookOutcome::Unavailable`], for the same
182    /// reason as above.
183    async fn book_slot(&mut self, slot: &Slot) -> anyhow::Result<BookOutcome>;
184
185    /// The current instant, for `hours` evaluation. Injectable so tests pin a
186    /// fixed time and the schedule is deterministic.
187    fn now(&self) -> OffsetDateTime;
188
189    /// Called once as the engine *enters* a node, before that node's own
190    /// effects run — carries the node's id and its component kind
191    /// ([`Node::kind`], e.g. `"greeting"`, `"menu"`). Lets a live consumer
192    /// light up which node is executing *right now*, and accumulate the path
193    /// the caller has taken, without waiting for the run-end trace.
194    ///
195    /// Default no-op: the durable, ordered per-node record is the trace
196    /// ([`Trace`]); a consumer that only reads results at run end ignores
197    /// this. Fires for every visited node including `hours` (which runs no
198    /// other effect) and a node re-entered by a menu loop.
199    fn on_enter(&mut self, _node: &NodeId, _kind: &'static str) {}
200}
201
202/// Engine-internal failure. `Effect` is an outside failure (call dropped) →
203/// the run aborts; the others are "impossible on a validated flow" defects
204/// the engine refuses to guess through.
205#[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    /// The abnormal outcome this error maps to. An outside failure is an
219    /// `Aborted` run; everything else is a `Defect` (a validation gap).
220    fn outcome(&self) -> FlowOutcome {
221        match self {
222            EngineError::Effect(_) => FlowOutcome::Aborted,
223            _ => FlowOutcome::Defect,
224        }
225    }
226}
227
228/// One node's result: go to the next node, or end the call.
229enum Step {
230    Goto(NodeId),
231    End(FlowOutcome),
232}
233
234/// Run a validated flow to completion against `fx`, filling `trace`.
235///
236/// Infallible by design: every ending — clean terminal, aborted call, or
237/// engine defect — is captured in `trace` (its `outcome`, and `error` for
238/// abnormal ends), because the daemon wants to persist and surface *what
239/// happened* regardless. Callers check [`Trace::is_clean`] to decide whether
240/// to also log a warning.
241///
242/// The trace is caller-owned (see [`Trace::new`]) so a caller that races this
243/// future against the dialog's termination signal — and drops it when the
244/// caller hangs up mid-flow — still holds the steps executed so far. A
245/// cancelled run never writes `outcome`; it keeps the [`FlowOutcome::Defect`]
246/// placeholder.
247pub 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(&current) {
252            Some(n) => n,
253            None => return abort(trace, EngineError::UnknownNode(current)),
254        };
255        match run_node(&current, 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    // Step cap hit — treat as a defect (validation should prevent it).
266    trace.outcome = FlowOutcome::Defect;
267    trace.error = Some(format!(
268        "step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
269    ));
270}
271
272/// Finalize a trace for an abnormal ending.
273fn 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    // Announce the node the instant the engine reaches it — before any
286    // prompt plays or digit is collected — so a live view reflects the
287    // caller's true position, not where they were a prompt ago.
288    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            // The prompt is a traced step of its own, like a greeting's — it
350            // marks the "please leave a message" moment on the call's
351            // timeline, minutes before the recording that ends the node.
352            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
386/// The `book` node: offer open times, take a keypress, book it.
387///
388/// Four ways out, and the shape of the code is mostly about keeping
389/// three of them from turning into a dropped call. Nothing here retries
390/// a provider or waits on a queue — there is a person on the line, so
391/// every failure resolves to an exit the author wired.
392async 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        // Unreachable: the caller matched on the variant.
415        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    // The one thing a slot must be able to do is be *said*. The
430    // vocabulary was rendered from this node's own schedule at publish,
431    // so a time outside it has no clip — offering it would play the key
432    // to press after a silence where the time should be. Dropping it is
433    // the honest degradation, and it keeps a schedule edited after
434    // publish from producing a mute offer.
435    let sayable = book::vocabulary_refs(node);
436
437    // At most two rounds: the caller's chosen slot can be taken out from
438    // under them once and still leave something to offer; a second miss
439    // means the calendar is busier than this conversation can keep up
440    // with, and pretending otherwise just keeps them on the phone.
441    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            // A zone name neither side can resolve. Validation rejects
453            // this at publish, so reaching it means the two disagree —
454            // which is a defect, not something to guess a zone for.
455            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                // "You're booked for" → "Tuesday" → "ten thirty a.m." The
504                // confirmation is a prompt and the time is clips, which is
505                // why the prompt takes no placeholder (see the schema).
506                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                // Round two re-reads the calendar rather than re-offering
541                // the stale list: whatever took this slot may have taken
542                // another.
543            }
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
555/// Speak the intro and the offers, then wait for a key. Returns the
556/// index of the offer the caller took, or `None` when the attempts ran
557/// out. Unmapped keys and silence both simply cost an attempt — `book`
558/// has no `invalid` exit, because "you pressed 7" and "you pressed
559/// nothing" want the same thing from the flow: ask again, then move on.
560async 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/// The menu retry loop. Speak, collect a digit, repeat up to `retries + 1`
603/// attempts. A mapped digit follows its exit; running out of attempts follows
604/// `invalid` if the caller pressed unmapped keys at all, else `no_input`
605/// (pure silence).
606#[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, // unmapped key → retry
638            None => {}                       // timeout → retry
639        }
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
651/// Follow a named exit to the next node. On a validated flow the exit is
652/// always present; a miss is a defect, surfaced rather than guessed.
653fn 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    /// A scripted [`FlowEffects`] — the scenario harness doc 48's M1 "done"
674    /// criterion describes ("call the line ourselves, scenario by scenario"),
675    /// at unit speed. Feed it a clock, a queue of digit presses, and a ring
676    /// outcome; read back what the flow spoke and did.
677    struct MockEffects {
678        now: OffsetDateTime,
679        digits: VecDeque<Option<Digit>>,
680        ring: RingOutcome,
681        message_secs: u32,
682        // observed:
683        spoken: Vec<String>,
684        transferred: Option<String>,
685        recorded: bool,
686        /// The record-start cue the message node asked for, when one ran.
687        record_tone: Option<MessageTone>,
688        hung_up: bool,
689        fail_speak: bool,
690        /// Every node the engine entered, in visit order (id, kind) — the
691        /// live-position signal `on_enter` feeds.
692        entered: Vec<(String, &'static str)>,
693        /// Scripted answers for `book`, consumed in order. An exhausted
694        /// queue answers the way an unreachable platform does, which is
695        /// what a test that forgot to script one should see.
696        slot_answers: VecDeque<anyhow::Result<SlotOffer>>,
697        book_answers: VecDeque<anyhow::Result<BookOutcome>>,
698        /// Observed: what was asked for, and what was taken.
699        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    /// What the caller heard, as a string a test can assert on. An audio
752    /// prompt reports its ref: for `book` that *is* the content — the
753    /// clips are the words.
754    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            // Exhausted script = silence (timeout), never an error.
772            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    // A Tuesday during business hours: 15:00 EDT = 19:00 UTC.
860    fn open_time() -> OffsetDateTime {
861        datetime!(2026-07-07 19:00 UTC)
862    }
863    // A Tuesday after close: 23:00 EDT = 03:00 UTC Wednesday.
864    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    /// Test-side ergonomics for the caller-owned-trace API: build the trace,
873    /// run, hand it back.
874    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        // The hours branch went `open`.
889        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        // Human answers during open hours: greeting → hours → ring. The
896        // effect-less `hours` node still announces itself, so a live view
897        // can light it — the whole point of the hook over the effect calls.
898        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        // Closed → press 1 → hours greeting → voicemail: the live position
911        // tracks every hop, and the ids line up with the run-end trace.
912        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        // A menu's internal retries don't re-enter the node — it's announced
928        // once even though run_menu may loop for a bad/absent key.
929        assert_eq!(
930            fx.entered
931                .iter()
932                .filter(|(id, _)| id == "night_menu")
933                .count(),
934            1,
935        );
936        // Every node the trace recorded is a node on_enter announced. The
937        // message node traces twice (prompt, then recording) but is entered
938        // once, so collapse consecutive repeats before comparing.
939        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        // The voicemail prompt is its own traced step (regression: it used to
957        // leave no mark on the call's timeline), followed by the recording
958        // that ends the run.
959        assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
960        assert_eq!(
961            trace.steps[4].detail,
962            StepDetail::MessageRecorded { secs: 40 }
963        );
964        // The caller actually heard the prompt before the recording.
965        assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
966        assert!(fx.recorded);
967        // The unconfigured node carried the default record-start cue.
968        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        // The caller heard the opening-hours greeting.
989        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        // No digits scripted → every collect times out → no_input.
998        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        // retries: 1 → 2 attempts; press an unmapped key both times.
1012        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        // The menu prompt was spoken once per attempt.
1018        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        // First attempt an unmapped key, second the real option.
1029        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        // Each step is stamped with its offset from run start, so the daemon
1044        // can place it on the call's (and recording's) timeline. Mock effects
1045        // resolve instantly, so we can't assert real gaps — only that the
1046        // offsets exist and never run backwards.
1047        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; // the caller hangs up during the greeting
1062        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        // Nothing was appended — it failed on the first node's effect.
1068        assert!(trace.steps.is_empty());
1069    }
1070
1071    // ── `book` (schema_version 2) ────────────────────────────────────────
1072    //
1073    // The scenarios that matter are the ones where nothing goes right:
1074    // three of `book`'s four exits exist because a calendar can fail, and
1075    // each of them has to end with the caller somewhere sensible rather
1076    // than listening to silence.
1077
1078    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    /// Two Tuesday-morning slots, in the vocabulary the CLINIC schedule
1122    /// renders (09:00–11:30 on the half hour).
1123    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    // The Monday before those slots: they are "tomorrow", not "Tuesday".
1134    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        // The caller heard the intro, then each time with its key, then
1147        // the confirmation followed by the time they actually got.
1148        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        // The second offer is what was booked — the digit maps by
1165        // position, not by any id in the payload.
1166        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        // Nothing was offered, so nothing was booked and no key was asked
1189        // for — the caller goes straight to voicemail.
1190        assert!(fx.booked.is_empty());
1191    }
1192
1193    #[tokio::test]
1194    async fn an_unreachable_platform_takes_the_unavailable_exit() {
1195        // The `book` effects failing is NOT the call failing: the caller
1196        // is still on the line and must land on voicemail, not on a
1197        // dropped call with an aborted trace.
1198        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        // First choice is gone; the second read is a fresh one (whatever
1210        // took that slot may have taken another), and the caller gets
1211        // what they pick from it.
1212        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        // retries: 1 → two attempts. An out-of-range key costs an attempt
1253        // exactly as silence does: `book` has no `invalid` exit, because
1254        // both want the same thing from the flow.
1255        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        // The offers were spoken once per attempt.
1264        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        // 13:00 is outside the node's own schedule, so no clip for it was
1270        // ever rendered. Offering it would play "press one" after a
1271        // silence; dropping it is the honest degradation.
1272        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}