Skip to main content

gemini_adk_fluent_rs/
simulation.rs

1//! Model-free conversation simulation.
2//!
3//! A deterministic harness that drives a [`CompiledConversation`] without any live
4//! API: a **fake user** supplies utterances (run through the conversation's
5//! recognizers) or sets slots directly, tools succeed on demand or after a
6//! latency, and the [`FlowStack`] advances turn by turn. Everything is driven by
7//! `State` + guards, so motifs, repair, policies, and digressions become testable
8//! in CI — "a flow SDK with simulation is infra; without it, a demo framework".
9//!
10//! ```no_run
11//! # use gemini_adk_fluent_rs::prelude::*;
12//! # use gemini_adk_fluent_rs::conversation::Conversation;
13//! # use gemini_adk_fluent_rs::simulation::Sim;
14//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
15//! let convo = Conversation::new("booking")
16//!     .stage("check").collect(["party_size", "slot"])
17//!         .next("confirm", Guard::captured(["party_size", "slot"]))
18//!     .stage("confirm").commit("book", Guard::is_true("user_confirmed"))
19//!         .next("done", Guard::called_ok("book"))
20//!     .stage("done").terminal()
21//!     .require(["done"])
22//!     .compile()?;
23//! let mut sim = Sim::new(&convo, Enforcement::Enforce);
24//! sim.user("a table for 4 tomorrow at 7pm").await;
25//! assert!(sim.active().contains(&"check".to_string()));
26//! assert!(!sim.allowed("book"));            // not confirmed yet
27//! sim.set("user_confirmed", true);
28//! sim.tool_ok("book");
29//! assert!(sim.is_complete());
30//! # Ok(())
31//! # }
32//! ```
33
34use std::collections::BTreeMap;
35use std::sync::Arc;
36use std::time::Instant;
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41use gemini_adk_rs::decision::{Decision, Question};
42use gemini_adk_rs::flow::{DECISION_TURN_KEY, Enforcement, FlowExplanation};
43use gemini_adk_rs::live::{TranscriptTurn, TurnExtractor};
44use gemini_adk_rs::state::State;
45
46use crate::conversation::{CompiledConversation, FlowStack};
47
48struct BoundExtractor {
49    extractor: Arc<dyn TurnExtractor>,
50    /// (field name, state key) — how to promote the returned record into `State`.
51    fields: Vec<(String, String)>,
52}
53
54/// A deterministic, model-free driver for a compiled conversation.
55pub struct Sim {
56    stack: FlowStack,
57    extractors: Vec<BoundExtractor>,
58    state: State,
59    turn_no: u32,
60    /// Tools scheduled to succeed at a future turn — models tool latency.
61    pending_tools: Vec<(String, u32)>,
62    /// The questions scripted answers are to, by id.
63    decisions: BTreeMap<String, Decision>,
64    /// Caller turns so far, for the turn answers are about.
65    caller_turns: u32,
66}
67
68impl Sim {
69    /// Build a simulator over `convo` in the given enforcement mode.
70    pub fn new(convo: &CompiledConversation, mode: Enforcement) -> Self {
71        let extractors = convo
72            .all_extractors()
73            .into_iter()
74            .map(|e| BoundExtractor {
75                fields: e.field_state_keys(),
76                extractor: e.into_extractor(),
77            })
78            .collect();
79        Self {
80            stack: convo.stack(mode),
81            extractors,
82            state: State::new(),
83            turn_no: 0,
84            pending_tools: Vec::new(),
85            decisions: BTreeMap::new(),
86            caller_turns: 0,
87        }
88    }
89
90    /// Set a state value directly (information a recognizer can't supply, or a
91    /// scripted shortcut). Does not advance a turn.
92    pub fn set(&self, key: impl Into<String>, value: impl Serialize) -> &Self {
93        let _ = self.state.set(key, value);
94        self
95    }
96
97    /// The fake user speaks: run the conversation's extractors over the utterance
98    /// to fill slots (respecting validators), then advance a turn.
99    pub async fn user(&mut self, utterance: &str) -> &mut Self {
100        self.next_caller_turn();
101        let window = [TranscriptTurn {
102            turn_number: self.turn_no,
103            user: utterance.to_string(),
104            model: String::new(),
105            tool_calls: Vec::new(),
106            timestamp: Instant::now(),
107        }];
108        for bound in &self.extractors {
109            if let Ok(Value::Object(obj)) = bound
110                .extractor
111                .extract_with_state(&window, &self.state)
112                .await
113            {
114                for (name, key) in &bound.fields {
115                    if let Some(v) = obj.get(name)
116                        && !v.is_null()
117                    {
118                        let _ = self.state.set(key.clone(), v.clone());
119                    }
120                }
121            }
122        }
123        self.advance();
124        self
125    }
126
127    /// Advance a turn with no new user input (e.g. waiting on a tool/resolver).
128    pub fn turn(&mut self) -> &mut Self {
129        self.advance();
130        self
131    }
132
133    /// A tool succeeds now; records it and advances a turn (processing any
134    /// digression resume).
135    pub fn tool_ok(&mut self, tool: &str) -> &mut Self {
136        self.stack.on_tool_ok(tool, &self.state);
137        self.advance();
138        self
139    }
140
141    /// A tool fails or times out. Counts toward the active stage's
142    /// `escalate_after_tool_failures`; does not advance a turn.
143    pub fn tool_failed(&mut self, tool: &str) -> &mut Self {
144        self.stack.observe_tool(tool, false, &self.state);
145        self
146    }
147
148    /// The user barges in on the model. Counts toward the active stage's
149    /// `escalate_after_interruptions`; does not advance a turn.
150    pub fn interrupt(&mut self) -> &mut Self {
151        self.stack.on_interrupted(&self.state);
152        self
153    }
154
155    /// Schedule a tool to succeed `after` turns — models tool latency.
156    pub fn schedule_tool(&mut self, tool: impl Into<String>, after: u32) -> &mut Self {
157        self.pending_tools
158            .push((tool.into(), self.turn_no + after.max(1)));
159        self
160    }
161
162    /// Answer scripted decisions as these questions would be: a pick from
163    /// `options_from` and a `writes` key behave as in a live session.
164    /// Without the bank, an answer is recorded but writes nothing.
165    #[must_use]
166    pub fn with_decisions(mut self, bank: BTreeMap<String, Decision>) -> Self {
167        self.decisions = bank;
168        self
169    }
170
171    /// The caller just spoke, and a decision model answered: `true`/`false`
172    /// for a boolean, an option's key for a choice (`"none_of_these"` for
173    /// none), a number for a score, `null` for unsure. Earlier answers no
174    /// longer count, as at a new caller turn in a live session. The flow
175    /// takes the answers in at once, as before a tool is admitted: a
176    /// digression they trigger opens now.
177    pub fn decide(&mut self, answers: &BTreeMap<String, Value>) -> &mut Self {
178        let turn = self.next_caller_turn();
179        for (id, answer) in answers {
180            match self.decisions.get(id) {
181                Some(decision) => decision.record_scripted(id, answer, &self.state, &turn),
182                None => Decision::new(Question::boolean("")).record_scripted(
183                    id,
184                    answer,
185                    &self.state,
186                    &turn,
187                ),
188            }
189        }
190        self.stack.on_decisions(&self.state);
191        self
192    }
193
194    /// Start a new caller turn: answers to the previous one stop counting.
195    fn next_caller_turn(&mut self) -> String {
196        self.caller_turns += 1;
197        let turn = format!("scenario:{}", self.caller_turns);
198        let _ = self.state.set(DECISION_TURN_KEY, turn.clone());
199        turn
200    }
201
202    fn advance(&mut self) {
203        self.turn_no += 1;
204        // Fire any tools whose latency has elapsed.
205        let due: Vec<String> = self
206            .pending_tools
207            .iter()
208            .filter(|(_, at)| *at <= self.turn_no)
209            .map(|(t, _)| t.clone())
210            .collect();
211        self.pending_tools.retain(|(_, at)| *at > self.turn_no);
212        for tool in due {
213            self.stack.on_tool_ok(&tool, &self.state);
214        }
215        self.stack.on_turn(&self.state);
216    }
217
218    /// Apply one scripted step: drive the conversation, or check an
219    /// expectation. `Err` carries why an expectation did not hold. This is
220    /// what [`Scenario::run`] does for each step, exposed so another driver
221    /// (an interactive session, a binding) shares the exact semantics.
222    pub async fn apply(&mut self, step: &SimStep) -> Result<(), String> {
223        match step {
224            SimStep::User(text) => {
225                self.user(text).await;
226            }
227            SimStep::Set { key, value } => {
228                self.set(key.clone(), value.clone());
229            }
230            SimStep::Remove { key } => {
231                let _ = self.state.remove(key);
232            }
233            SimStep::Decide(answers) => {
234                self.decide(answers);
235            }
236            SimStep::ToolOk(tool) => {
237                self.tool_ok(tool);
238            }
239            SimStep::ToolFailed(tool) => {
240                self.tool_failed(tool);
241            }
242            SimStep::Interrupt => {
243                self.interrupt();
244            }
245            SimStep::ToolResult { tool, ok } => {
246                self.stack.observe_tool(tool, *ok, &self.state);
247            }
248            SimStep::ScheduleTool { tool, after } => {
249                self.schedule_tool(tool.clone(), *after);
250            }
251            SimStep::Turn => {
252                self.turn();
253            }
254            SimStep::ExpectActive(expected) => {
255                let active = self.active();
256                for e in expected {
257                    if !active.contains(e) {
258                        return Err(format!("expected active '{e}', got {active:?}"));
259                    }
260                }
261            }
262            SimStep::ExpectDenied(tool) => {
263                if self.allowed(tool) {
264                    return Err(format!("expected '{tool}' denied, but it was admitted"));
265                }
266            }
267            SimStep::ExpectAllowed(tool) => {
268                if !self.allowed(tool) {
269                    let why = self.denied().get(tool).cloned().unwrap_or_default();
270                    return Err(format!("expected '{tool}' allowed, but denied: {why}"));
271                }
272            }
273            SimStep::ExpectSlot { key, value } => {
274                let got = self.state().get_raw(key);
275                if got.as_ref() != Some(value) {
276                    return Err(format!("expected slot '{key}' = {value}, got {got:?}"));
277                }
278            }
279            SimStep::ExpectComplete => {
280                if !self.is_complete() {
281                    return Err("expected conversation complete".into());
282                }
283            }
284        }
285        Ok(())
286    }
287
288    /// Active step ids in the currently-driving layer.
289    pub fn active(&self) -> Vec<String> {
290        self.stack.explain(&self.state).active
291    }
292
293    /// The active digression, if one is suspending the main flow.
294    pub fn active_overlay(&self) -> Option<&str> {
295        self.stack.active_overlay()
296    }
297
298    /// The instructions (stage postures) projected to the model this turn.
299    ///
300    /// On the turn a digression completes, these are its *closing* stage's —
301    /// the safety hand-off's "hand off to a human now", say — which is what a
302    /// live session would send. Assert on these to test that a digression is
303    /// heard, not merely that it fired.
304    pub fn postures(&self) -> Vec<String> {
305        self.stack.active_postures(&self.state)
306    }
307
308    /// Whether the conversation has ended because a `Resume::Terminate`
309    /// digression ran (as opposed to the main flow finishing).
310    pub fn is_terminated(&self) -> bool {
311        self.stack.is_terminated()
312    }
313
314    /// Whether `tool` is admitted right now.
315    pub fn allowed(&self, tool: &str) -> bool {
316        self.stack.admits_tool(tool, &self.state).is_ok()
317    }
318
319    /// Currently-blocked tools, mapped to the reason.
320    pub fn denied(&self) -> BTreeMap<String, String> {
321        self.stack.explain(&self.state).blocked_tools
322    }
323
324    /// Whether the conversation is complete.
325    pub fn is_complete(&self) -> bool {
326        self.stack.is_complete()
327    }
328
329    /// Read a slot value.
330    pub fn slot<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
331        self.state.get(key)
332    }
333
334    /// The active layer's control-plane explanation.
335    pub fn explain(&self) -> FlowExplanation {
336        self.stack.explain(&self.state)
337    }
338
339    /// The simulation state (for custom assertions / slot evidence).
340    pub fn state(&self) -> &State {
341        &self.state
342    }
343}
344
345/// One step in a serializable [`Scenario`].
346#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
347#[serde(rename_all = "snake_case")]
348pub enum SimStep {
349    /// The fake user speaks (recognizers fill slots), then a turn advances.
350    User(String),
351    /// Set a state value directly.
352    Set {
353        /// State key.
354        key: String,
355        /// Value to store.
356        value: Value,
357    },
358    /// Remove a state value, as the recorded runtime did.
359    Remove {
360        /// State key.
361        key: String,
362    },
363    /// A tool succeeds now.
364    ToolOk(String),
365    /// A tool fails (or times out) now; counts toward the active stage's
366    /// repair policy. Does not advance a turn.
367    ToolFailed(String),
368    /// The user barges in on the model; counts toward the active stage's
369    /// repair policy. Does not advance a turn.
370    Interrupt,
371    /// A tool call completes, successfully or not, where the live runtime
372    /// records it: the flow observes it without advancing a turn (unlike
373    /// [`ToolOk`](Self::ToolOk)). This is what a scenario extracted from a
374    /// recording uses.
375    ToolResult {
376        /// Tool name.
377        tool: String,
378        /// Whether it succeeded.
379        ok: bool,
380    },
381    /// Schedule a tool to succeed after N turns (latency).
382    ScheduleTool {
383        /// Tool name.
384        tool: String,
385        /// Turns to wait.
386        after: u32,
387    },
388    /// The caller just spoke and a decision model answered these questions
389    /// (see [`Sim::decide`]): `{"decide": {"confirmed": true, "next_step":
390    /// "book", "frustration": 2.5, "picked": "none_of_these"}}`.
391    Decide(BTreeMap<String, Value>),
392    /// Advance a turn with no input.
393    Turn,
394    /// Assert these step ids are active.
395    ExpectActive(Vec<String>),
396    /// Assert a tool is currently blocked.
397    ExpectDenied(String),
398    /// Assert a tool is currently admitted.
399    ExpectAllowed(String),
400    /// Assert a slot equals a value.
401    ExpectSlot {
402        /// State key.
403        key: String,
404        /// Expected value.
405        value: Value,
406    },
407    /// Assert the conversation is complete.
408    ExpectComplete,
409}
410
411/// A serializable simulation script — a deterministic, model-free test case that
412/// can be authored in code or loaded from YAML/JSON.
413#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
414pub struct Scenario {
415    /// Scenario name (for diagnostics).
416    pub name: String,
417    /// The steps to execute, in order.
418    pub steps: Vec<SimStep>,
419}
420
421/// State keys the runtime owns. A scenario extracted from a recording skips
422/// them: the simulator recomputes them rather than being told them.
423const RUNTIME_PREFIXES: &[&str] = &[
424    "session:",
425    "flow:",
426    "derived:",
427    "repair:",
428    "correction:",
429    "state_meta:",
430    "idempotency:",
431    "compensated:",
432    "verbatim:",
433    "turn:",
434    "bg:",
435    "decision:",
436];
437
438fn runtime_owned(key: &str) -> bool {
439    RUNTIME_PREFIXES.iter().any(|p| key.starts_with(p))
440}
441
442impl Scenario {
443    /// Turn a recorded session into a regression scenario: the incident
444    /// becomes a test.
445    ///
446    /// `journal` is the session's mutation journal (see
447    /// [`FileJournalSink`](gemini_adk_rs::state::FileJournalSink) and
448    /// [`read_journal`](gemini_adk_rs::state::read_journal)), which a
449    /// governed session writes as one ordered timeline. The scenario replays
450    /// what the application and user contributed and checks what governance
451    /// decided:
452    ///
453    /// - a slot or flag write becomes `Set`;
454    /// - a tool the flow admitted becomes `ExpectAllowed`, one it refused
455    ///   `ExpectDenied`, and its outcome `ToolResult`;
456    /// - every turn boundary where the flow was evaluated (a `flow:active`
457    ///   write) becomes a `Turn` followed by `ExpectActive` with the steps
458    ///   the session had then.
459    ///
460    /// Keys the runtime owns (`session:`, `flow:`, `derived:`, repair and
461    /// correction signals, …) are not replayed: the simulator must reach them
462    /// itself.
463    ///
464    /// Run the result against the conversation spec in CI. If a change to
465    /// the spec alters what that session would have done, the scenario fails
466    /// at the step where the two diverge.
467    pub fn from_journal(
468        name: impl Into<String>,
469        journal: &[gemini_adk_rs::state::StateMutation],
470    ) -> Self {
471        use gemini_adk_rs::flow::{TOOL_CALL_KEY, TOOL_DENIED_KEY, TOOL_RESULT_KEY};
472
473        let tool_of = |v: &Option<Value>| {
474            v.as_ref()
475                .and_then(|v| v["tool"].as_str())
476                .map(str::to_string)
477        };
478        let mut steps = Vec::new();
479        // `None` is a removal (`State::remove`, `clear_prefix`), which must
480        // replay as one: a key set to `null` is still present.
481        let mut pending: Vec<(String, Option<Value>)> = Vec::new();
482        let flush = |pending: &mut Vec<(String, Option<Value>)>, steps: &mut Vec<SimStep>| {
483            for (key, value) in pending.drain(..) {
484                steps.push(match value {
485                    Some(value) => SimStep::Set { key, value },
486                    None => SimStep::Remove { key },
487                });
488            }
489        };
490        let mut ordered: Vec<&gemini_adk_rs::state::StateMutation> = journal.iter().collect();
491        ordered.sort_by_key(|m| m.sequence);
492        // Answers recorded for the same caller turn become one `Decide` step.
493        let mut decided: BTreeMap<String, Value> = BTreeMap::new();
494        let mut decided_turn: Option<String> = None;
495        for m in ordered {
496            if let Some(question) = m.key.strip_prefix(gemini_adk_rs::flow::DECISION_PREFIX)
497                && question != "turn"
498                && let Some(record) = m.new.clone().and_then(|v| {
499                    serde_json::from_value::<gemini_adk_rs::flow::DecisionRecord>(v).ok()
500                })
501            {
502                use gemini_adk_rs::flow::Outcome;
503                if decided_turn.as_ref().is_some_and(|t| *t != record.turn) && !decided.is_empty() {
504                    flush(&mut pending, &mut steps);
505                    steps.push(SimStep::Decide(std::mem::take(&mut decided)));
506                }
507                decided_turn = Some(record.turn.clone());
508                let answer = match record.outcome {
509                    Outcome::Yes => Value::Bool(true),
510                    Outcome::No => Value::Bool(false),
511                    Outcome::None => Value::String(gemini_adk_rs::decision::NONE_OPTION.into()),
512                    Outcome::Chosen | Outcome::Scored => record.value.clone(),
513                    Outcome::Unsure => Value::Null,
514                };
515                decided.insert(question.to_string(), answer);
516                continue;
517            }
518            // The answers were taken in before the next thing the flow did:
519            // a turn's evaluation, a tool's admission, or a write.
520            let boundary = !runtime_owned(&m.key)
521                || [
522                    "flow:active",
523                    TOOL_CALL_KEY,
524                    TOOL_DENIED_KEY,
525                    TOOL_RESULT_KEY,
526                ]
527                .contains(&m.key.as_str());
528            if boundary && !decided.is_empty() {
529                flush(&mut pending, &mut steps);
530                steps.push(SimStep::Decide(std::mem::take(&mut decided)));
531            }
532            match m.key.as_str() {
533                "flow:active" => {
534                    flush(&mut pending, &mut steps);
535                    steps.push(SimStep::Turn);
536                    let active: Vec<String> = m
537                        .new
538                        .clone()
539                        .and_then(|v| serde_json::from_value(v).ok())
540                        .unwrap_or_default();
541                    steps.push(SimStep::ExpectActive(active));
542                }
543                TOOL_CALL_KEY => {
544                    if let Some(tool) = tool_of(&m.new) {
545                        flush(&mut pending, &mut steps);
546                        steps.push(SimStep::ExpectAllowed(tool));
547                    }
548                }
549                TOOL_DENIED_KEY => {
550                    if let Some(tool) = tool_of(&m.new) {
551                        flush(&mut pending, &mut steps);
552                        steps.push(SimStep::ExpectDenied(tool));
553                    }
554                }
555                TOOL_RESULT_KEY => {
556                    if let Some(tool) = tool_of(&m.new) {
557                        flush(&mut pending, &mut steps);
558                        let ok = m.new.as_ref().is_some_and(|v| v["ok"] == true);
559                        steps.push(SimStep::ToolResult { tool, ok });
560                    }
561                }
562                key if runtime_owned(key) => {}
563                key => {
564                    let value = m.new.clone();
565                    // Keep the latest value per key, in first-written order.
566                    match pending.iter_mut().find(|(k, _)| k == key) {
567                        Some(slot) => slot.1 = value,
568                        None => pending.push((key.to_string(), value)),
569                    }
570                }
571            }
572        }
573        flush(&mut pending, &mut steps);
574        if !decided.is_empty() {
575            steps.push(SimStep::Decide(decided));
576        }
577        Self {
578            name: name.into(),
579            steps,
580        }
581    }
582
583    /// Run the scenario against `convo`. Returns `Ok(())` if every `Expect*` step
584    /// holds, else `Err` with the failing step index and a diagnostic.
585    pub async fn run(&self, convo: &CompiledConversation, mode: Enforcement) -> Result<(), String> {
586        self.run_in(Sim::new(convo, mode)).await
587    }
588
589    /// Run the steps in a prepared simulator (for example one with
590    /// [`Sim::with_decisions`]).
591    pub async fn run_in(&self, mut sim: Sim) -> Result<(), String> {
592        for (i, step) in self.steps.iter().enumerate() {
593            sim.apply(step)
594                .await
595                .map_err(|msg| format!("[{}] step {i} ({step:?}): {msg}", self.name))?;
596        }
597        Ok(())
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::conversation::Conversation;
605    use gemini_adk_rs::flow::Guard;
606    use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
607
608    struct Booking;
609    impl Frame for Booking {
610        fn frame() -> FrameSpec {
611            FrameSpec {
612                name: "booking".into(),
613                slots: vec![SlotSpec {
614                    recognizer: Some(SlotRecognizer::IntegerNear(vec!["people".into()])),
615                    ..SlotSpec::new("party_size")
616                }],
617            }
618        }
619    }
620
621    fn booking() -> CompiledConversation {
622        Conversation::new("booking")
623            .stage("collect")
624            .collect_frame::<Booking>()
625            .next("confirm", Guard::captured(["party_size"]))
626            .stage("confirm")
627            .commit("book", Guard::is_true("user_confirmed"))
628            .next("done", Guard::called_ok("book"))
629            .stage("done")
630            .terminal()
631            .require(["done"])
632            .compile()
633            .expect("compiles")
634    }
635
636    #[tokio::test]
637    async fn fake_user_fills_slots_and_gates_commit() {
638        let convo = booking();
639        let mut sim = Sim::new(&convo, Enforcement::Enforce);
640
641        assert!(sim.active().contains(&"collect".to_string()));
642        assert!(!sim.allowed("book"));
643
644        // The fake user speaks; the recognizer fills party_size.
645        sim.user("a table for 4 people").await;
646        assert_eq!(sim.slot::<u32>("party_size"), Some(4));
647        assert!(sim.active().contains(&"confirm".to_string()));
648
649        // book is gated until confirmation.
650        assert!(!sim.allowed("book"));
651        sim.set("user_confirmed", true);
652        sim.turn();
653        assert!(sim.allowed("book"));
654
655        sim.tool_ok("book");
656        assert!(sim.is_complete());
657    }
658
659    #[tokio::test]
660    async fn scenario_runs_and_round_trips() {
661        let scenario = Scenario {
662            name: "happy_path".into(),
663            steps: vec![
664                SimStep::ExpectActive(vec!["collect".into()]),
665                SimStep::ExpectDenied("book".into()),
666                SimStep::User("party of 4 people".into()),
667                SimStep::ExpectSlot {
668                    key: "party_size".into(),
669                    value: serde_json::json!(4),
670                },
671                SimStep::ExpectActive(vec!["confirm".into()]),
672                SimStep::Set {
673                    key: "user_confirmed".into(),
674                    value: serde_json::json!(true),
675                },
676                SimStep::Turn,
677                SimStep::ExpectAllowed("book".into()),
678                SimStep::ToolOk("book".into()),
679                SimStep::ExpectComplete,
680            ],
681        };
682
683        scenario
684            .run(&booking(), Enforcement::Enforce)
685            .await
686            .expect("scenario passes");
687
688        // Scenarios are serializable (authorable as YAML/JSON).
689        let json = serde_json::to_string(&scenario).unwrap();
690        let back: Scenario = serde_json::from_str(&json).unwrap();
691        back.run(&booking(), Enforcement::Enforce)
692            .await
693            .expect("round-tripped scenario passes");
694    }
695
696    #[tokio::test]
697    async fn scenario_reports_failed_expectation() {
698        let scenario = Scenario {
699            name: "bad".into(),
700            steps: vec![SimStep::ExpectComplete], // not complete at the start
701        };
702        let err = scenario
703            .run(&booking(), Enforcement::Enforce)
704            .await
705            .unwrap_err();
706        assert!(err.contains("expected conversation complete"));
707    }
708
709    #[tokio::test]
710    async fn decided_answers_gate_a_commit_for_their_turn_only() {
711        let convo = Conversation::new("booking")
712            .stage("offer")
713            .commit("book", Guard::decided("confirmed"))
714            .next("done", Guard::called_ok("book"))
715            .stage("done")
716            .terminal()
717            .require(["done"])
718            .compile()
719            .expect("compiles");
720        let bank = BTreeMap::from([
721            (
722                "confirmed".to_string(),
723                Decision::new(Question::boolean("The caller agreed to book.")),
724            ),
725            (
726                "slot_pick".to_string(),
727                Decision::new(Question::choice(
728                    "Which time did the caller pick?",
729                    [("x", "x")],
730                ))
731                .options_from("offered")
732                .or_none("No offered time was picked.")
733                .writes("slot"),
734            ),
735        ]);
736        let mut sim = Sim::new(&convo, Enforcement::Enforce).with_decisions(bank);
737        sim.set(
738            "offered",
739            serde_json::json!([{ "id": "9am", "start": "09:00" }, { "id": "2pm", "start": "14:00" }]),
740        );
741        assert!(!sim.allowed("book"));
742
743        let answers = |pairs: &[(&str, Value)]| {
744            pairs
745                .iter()
746                .map(|(k, v)| ((*k).to_string(), v.clone()))
747                .collect::<BTreeMap<_, _>>()
748        };
749        sim.decide(&answers(&[
750            ("confirmed", serde_json::json!(true)),
751            ("slot_pick", serde_json::json!("2pm")),
752        ]));
753        assert!(sim.allowed("book"));
754        assert_eq!(
755            sim.slot::<Value>("slot"),
756            Some(serde_json::json!({ "id": "2pm", "start": "14:00" })),
757            "a pick writes the offered option whole"
758        );
759
760        // The caller says something else: the yes was about the last turn.
761        sim.user("actually hold on").await;
762        assert!(!sim.allowed("book"));
763        sim.decide(&answers(&[("confirmed", Value::Null)]));
764        assert!(!sim.allowed("book"), "unsure satisfies nothing");
765    }
766
767    #[test]
768    fn journal_decisions_replay_as_decide_steps() {
769        use gemini_adk_rs::flow::{DecisionRecord, Outcome, TOOL_CALL_KEY, decision_key};
770        use gemini_adk_rs::state::MemoryJournalSink;
771        let journal = std::sync::Arc::new(MemoryJournalSink::new());
772        let state = State::new().with_journal_sink(journal.clone());
773        let record = |q: &str, outcome, value: Value| {
774            let record = DecisionRecord {
775                outcome,
776                value,
777                confidence: Some(0.97),
778                turn: "3:ab".into(),
779            };
780            let _ = state.set(decision_key(q), serde_json::to_value(record).unwrap());
781        };
782        let _ = state.set(DECISION_TURN_KEY, "3:ab");
783        record("confirmed", Outcome::Yes, Value::Null);
784        record("slot_pick", Outcome::None, Value::Null);
785        record("anger", Outcome::Scored, serde_json::json!(1.5));
786        let _ = state.set(TOOL_CALL_KEY, serde_json::json!({ "tool": "book" }));
787
788        let scenario = Scenario::from_journal("incident", &journal.entries());
789        assert_eq!(
790            serde_json::to_value(&scenario.steps).unwrap(),
791            serde_json::json!([
792                { "decide": { "anger": 1.5, "confirmed": true, "slot_pick": "none_of_these" } },
793                { "expect_allowed": "book" },
794            ])
795        );
796    }
797
798    #[tokio::test]
799    async fn tool_latency_resolves_after_delay() {
800        let convo = booking();
801        let mut sim = Sim::new(&convo, Enforcement::Enforce);
802        sim.user("4 people").await;
803        sim.set("user_confirmed", true);
804        sim.turn();
805        // book completes after 2 turns of latency rather than immediately.
806        sim.schedule_tool("book", 2);
807        assert!(!sim.is_complete());
808        sim.turn();
809        assert!(!sim.is_complete());
810        sim.turn();
811        assert!(sim.is_complete());
812    }
813}