From 4299d0c6b5597db2b6513a5065cb89720aabb469 Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Tue, 21 Jul 2026 21:09:56 +1200 Subject: [PATCH 1/3] feat(engine): add on_enter node-entry hook to FlowEffects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's per-node record was only available at run end (the trace). A live consumer that wants to show which node is executing *right now* — and the path the caller has taken so far — had no signal until the call was over. Add `FlowEffects::on_enter(node, kind)`, called once as the engine reaches each node, before that node's own effects run. It fires for every visited node, including `hours` (which runs no other effect) and a node re-entered by a goto; a menu's internal retries do not re-fire it. Default no-op, so existing impls are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Hj93UCb3RKjF6mBwzhsp9f --- Cargo.lock | 2 +- crates/wavekat-flow/Cargo.toml | 2 +- crates/wavekat-flow/src/engine.rs | 74 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b9e18d..ba78fe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,7 +1551,7 @@ dependencies = [ [[package]] name = "wavekat-flow" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "async-trait", diff --git a/crates/wavekat-flow/Cargo.toml b/crates/wavekat-flow/Cargo.toml index 90d1b96..a8a4d71 100644 --- a/crates/wavekat-flow/Cargo.toml +++ b/crates/wavekat-flow/Cargo.toml @@ -2,7 +2,7 @@ name = "wavekat-flow" # Literal (not workspace-inherited) so release-please's Cargo updater can # bump it — the cargo-workspace plugin doesn't rewrite [workspace.package]. -version = "0.0.4" +version = "0.0.5" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/wavekat-flow/src/engine.rs b/crates/wavekat-flow/src/engine.rs index b152c77..e75623b 100644 --- a/crates/wavekat-flow/src/engine.rs +++ b/crates/wavekat-flow/src/engine.rs @@ -114,6 +114,18 @@ pub trait FlowEffects: Send { /// The current instant, for `hours` evaluation. Injectable so tests pin a /// fixed time and the schedule is deterministic. fn now(&self) -> OffsetDateTime; + + /// Called once as the engine *enters* a node, before that node's own + /// effects run — carries the node's id and its component kind + /// ([`Node::kind`], e.g. `"greeting"`, `"menu"`). Lets a live consumer + /// light up which node is executing *right now*, and accumulate the path + /// the caller has taken, without waiting for the run-end trace. + /// + /// Default no-op: the durable, ordered per-node record is the trace + /// ([`Trace`]); a consumer that only reads results at run end ignores + /// this. Fires for every visited node including `hours` (which runs no + /// other effect) and a node re-entered by a menu loop. + fn on_enter(&mut self, _node: &NodeId, _kind: &'static str) {} } /// Engine-internal failure. `Effect` is an outside failure (call dropped) → @@ -199,6 +211,10 @@ async fn run_node( trace: &mut Trace, ) -> Result { let kind = node.kind(); + // Announce the node the instant the engine reaches it — before any + // prompt plays or digit is collected — so a live view reflects the + // caller's true position, not where they were a prompt ago. + fx.on_enter(id, kind); match node { Node::Greeting { prompt, .. } => { fx.speak(prompt).await.map_err(EngineError::Effect)?; @@ -382,6 +398,9 @@ mod tests { record_tone: Option, hung_up: bool, fail_speak: bool, + /// Every node the engine entered, in visit order (id, kind) — the + /// live-position signal `on_enter` feeds. + entered: Vec<(String, &'static str)>, } impl MockEffects { @@ -397,6 +416,7 @@ mod tests { record_tone: None, hung_up: false, fail_speak: false, + entered: Vec::new(), } } fn digits(mut self, seq: impl IntoIterator>) -> Self { @@ -459,6 +479,9 @@ mod tests { fn now(&self) -> OffsetDateTime { self.now } + fn on_enter(&mut self, node: &NodeId, kind: &'static str) { + self.entered.push((node.clone(), kind)); + } } const LUIGIS: &str = r#" @@ -537,6 +560,57 @@ nodes: assert!(!fx.recorded, "a human answered — no voicemail"); } + #[tokio::test] + async fn on_enter_reports_each_node_as_the_engine_reaches_it() { + // Human answers during open hours: greeting → hours → ring. The + // effect-less `hours` node still announces itself, so a live view + // can light it — the whole point of the hook over the effect calls. + let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered); + let _ = run_trace(&luigis(), &mut fx).await; + assert_eq!( + fx.entered, + vec![ + ("welcome".to_string(), "greeting"), + ("check_hours".to_string(), "hours"), + ("front_desk".to_string(), "ring"), + ], + "on_enter fires once per visited node, in path order" + ); + + // Closed → press 1 → hours greeting → voicemail: the live position + // tracks every hop, and the ids line up with the run-end trace. + let mut fx = MockEffects::new(closed_time()) + .digits([Some(Digit::D1)]) + .message_secs(12); + let trace = run_trace(&luigis(), &mut fx).await; + let entered_ids: Vec<&str> = fx.entered.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + entered_ids, + vec![ + "welcome", + "check_hours", + "night_menu", + "say_hours", + "take_message" + ], + ); + // A menu's internal retries don't re-enter the node — it's announced + // once even though run_menu may loop for a bad/absent key. + assert_eq!( + fx.entered + .iter() + .filter(|(id, _)| id == "night_menu") + .count(), + 1, + ); + // Every node the trace recorded is a node on_enter announced. The + // message node traces twice (prompt, then recording) but is entered + // once, so collapse consecutive repeats before comparing. + let mut trace_nodes: Vec<&str> = trace.steps.iter().map(|s| s.node.as_str()).collect(); + trace_nodes.dedup(); + assert_eq!(entered_ids, trace_nodes); + } + #[tokio::test] async fn open_hours_no_answer_falls_to_voicemail() { let mut fx = MockEffects::new(open_time()) From 57ccd28bb0d3a00e071b164c98eef7c6d33f355b Mon Sep 17 00:00:00 2001 From: Eason Date: Tue, 21 Jul 2026 22:13:27 +1200 Subject: [PATCH 2/3] Change version back to 0.0.4 --- crates/wavekat-flow/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/wavekat-flow/Cargo.toml b/crates/wavekat-flow/Cargo.toml index a8a4d71..90d1b96 100644 --- a/crates/wavekat-flow/Cargo.toml +++ b/crates/wavekat-flow/Cargo.toml @@ -2,7 +2,7 @@ name = "wavekat-flow" # Literal (not workspace-inherited) so release-please's Cargo updater can # bump it — the cargo-workspace plugin doesn't rewrite [workspace.package]. -version = "0.0.5" +version = "0.0.4" edition.workspace = true license.workspace = true rust-version.workspace = true From 6a2ce29ecb49f03366ca16c9e633f9ec34cd4847 Mon Sep 17 00:00:00 2001 From: Eason Date: Tue, 21 Jul 2026 22:13:56 +1200 Subject: [PATCH 3/3] Change version back to 0.0.4 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ba78fe9..5b9e18d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,7 +1551,7 @@ dependencies = [ [[package]] name = "wavekat-flow" -version = "0.0.5" +version = "0.0.4" dependencies = [ "anyhow", "async-trait",