1use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use gemini_adk_rs::flow::{Enforcement, FlowMonitor, FlowSnapshot, FlowStack};
17use gemini_adk_rs::state::State;
18
19use super::SessionSpec;
20
21#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum SimEvent {
25 User(String),
28 Tool(String),
33 Set(BTreeMap<String, Value>),
36 Expect(TestExpectation),
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
43pub struct TestExpectation {
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub done: Vec<String>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub active: Vec<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub allowed: Vec<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub blocked: Vec<String>,
56 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58 pub state: BTreeMap<String, Value>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub complete: Option<bool>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct SpecTest {
68 pub name: String,
70 pub script: Vec<SimEvent>,
72}
73
74#[derive(Debug, Clone, Serialize)]
76pub struct TestStepResult {
77 pub index: usize,
79 pub event: String,
81 pub failures: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize)]
88pub struct TestReport {
89 pub name: String,
91 pub passed: bool,
93 pub failures: Vec<TestStepResult>,
95 pub events: usize,
97}
98
99#[derive(Debug, Clone, Serialize)]
103pub struct SimSnapshot {
104 pub index: usize,
106 pub event: String,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub failures: Vec<String>,
111 #[serde(flatten)]
113 pub status: FlowSnapshot,
114}
115
116pub fn trace_test(spec: &SessionSpec, test_name: &str) -> Result<Vec<SimSnapshot>, Vec<String>> {
120 if let Some((skill_name, local_name)) = test_name.split_once('/')
121 && let Some(skill) = spec.skills.iter().find(|skill| skill.name == skill_name)
122 {
123 let validation = spec.validate_for_replay();
124 if !validation.valid {
125 return Err(validation.errors);
126 }
127 return trace_test(&skill.definition(), local_name);
128 }
129 let test = spec
130 .tests
131 .iter()
132 .find(|t| t.name == test_name)
133 .ok_or_else(|| vec![format!("no test named '{test_name}' in the spec")])?;
134 replay(spec, test)
135}
136
137fn replay_stack(spec: &SessionSpec) -> Result<FlowStack, Vec<String>> {
140 let validation = spec.validate_for_replay();
141 if !validation.valid {
142 return Err(validation.errors);
143 }
144 if let Some(conversation) = &spec.conversation {
145 crate::conversation::Conversation::from_spec_stubbing_resolvers(conversation.clone())
146 .map(|compiled| compiled.stack(Enforcement::Enforce))
147 .map_err(|error| vec![format!("conversation: {error}")])
148 } else {
149 FlowMonitor::try_new(spec.effective_flow()?, Enforcement::Enforce)
150 .map(FlowMonitor::into_stack)
151 .map_err(|errors| errors.0.iter().map(ToString::to_string).collect())
152 }
153}
154
155fn replay(spec: &SessionSpec, test: &SpecTest) -> Result<Vec<SimSnapshot>, Vec<String>> {
158 let state = State::new();
159 spec.seed_state_defaults(&state);
163 spec.recompute_computed(&state);
164 let mut stack = replay_stack(spec)?;
165 stack.relatch(&state);
166
167 let snapshot =
168 |index: usize, event: String, failures: Vec<String>, stack: &FlowStack, state: &State| {
169 SimSnapshot {
170 index,
171 event,
172 failures,
173 status: stack.snapshot(state),
174 }
175 };
176
177 let mut snapshots = vec![snapshot(0, "start".into(), Vec::new(), &stack, &state)];
178 for (index, event) in test.script.iter().enumerate() {
179 let mut failures = Vec::new();
180 let label = match event {
181 SimEvent::User(text) => {
182 spec.recompute_computed(&state);
183 stack.on_turn(&state);
184 format!("user: {text}")
185 }
186 SimEvent::Tool(name) => {
187 match stack.admits_tool(name, &state) {
188 Ok(()) => {
189 spec.apply_tool_state(name, &state);
190 spec.recompute_computed(&state);
191 stack.on_tool_ok(name, &state);
192 }
193 Err(reason) => {
194 let anticipated = matches!(
195 test.script.get(index + 1),
196 Some(SimEvent::Expect(e)) if e.blocked.iter().any(|t| t == name)
197 );
198 if !anticipated {
199 failures.push(format!("tool '{name}' was blocked: {reason}"));
200 }
201 }
202 }
203 format!("tool: {name}")
204 }
205 SimEvent::Set(map) => {
206 for (key, value) in map {
207 let _ = state.set(key, value.clone());
208 }
209 spec.recompute_computed(&state);
210 stack.relatch(&state);
211 format!(
212 "set: {}",
213 map.keys().cloned().collect::<Vec<_>>().join(", ")
214 )
215 }
216 SimEvent::Expect(expect) => {
217 check(expect, &stack, &state, &mut failures);
218 "expect".to_string()
219 }
220 };
221 snapshots.push(snapshot(index + 1, label, failures, &stack, &state));
222 }
223 Ok(snapshots)
224}
225
226pub(crate) fn run_tests(spec: &SessionSpec) -> Vec<TestReport> {
228 spec.tests.iter().map(|test| run_one(spec, test)).collect()
229}
230
231fn run_one(spec: &SessionSpec, test: &SpecTest) -> TestReport {
232 let snapshots = match replay(spec, test) {
233 Ok(snapshots) => snapshots,
234 Err(errors) => {
235 return TestReport {
236 name: test.name.clone(),
237 passed: false,
238 failures: vec![TestStepResult {
239 index: 0,
240 event: "setup".into(),
241 failures: errors,
242 }],
243 events: 0,
244 };
245 }
246 };
247 let failures: Vec<TestStepResult> = snapshots
248 .into_iter()
249 .skip(1) .filter(|s| !s.failures.is_empty())
251 .map(|s| TestStepResult {
252 index: s.index - 1,
253 event: s.event,
254 failures: s.failures,
255 })
256 .collect();
257
258 TestReport {
259 name: test.name.clone(),
260 passed: failures.is_empty(),
261 failures,
262 events: test.script.len(),
263 }
264}
265
266fn check(expect: &TestExpectation, stack: &FlowStack, state: &State, failures: &mut Vec<String>) {
267 let explanation = stack.explain(state);
268 for step in &expect.done {
269 if !stack.marking().done.contains(step) {
270 failures.push(format!(
271 "expected step '{step}' done; done = [{}]",
272 join(&stack.marking().done.iter().cloned().collect::<Vec<_>>())
273 ));
274 }
275 }
276 for step in &expect.active {
277 if !explanation.active.contains(step) {
278 failures.push(format!(
279 "expected step '{step}' active; active = [{}]",
280 join(&explanation.active)
281 ));
282 }
283 }
284 for tool in &expect.allowed {
285 if !explanation.allowed_tools.contains(tool) {
286 failures.push(format!(
287 "expected tool '{tool}' allowed; allowed = [{}]",
288 join(&explanation.allowed_tools)
289 ));
290 }
291 }
292 for tool in &expect.blocked {
293 if stack.admits_tool(tool, state).is_ok() {
294 failures.push(format!("expected tool '{tool}' blocked; it was admitted"));
295 }
296 }
297 for (key, expected) in &expect.state {
298 let actual = state.get::<Value>(key);
299 if actual.as_ref() != Some(expected) {
300 failures.push(format!(
301 "expected state '{key}' = {expected}; got {}",
302 actual.map_or("<absent>".to_string(), |v| v.to_string())
303 ));
304 }
305 }
306 if let Some(complete) = expect.complete
307 && stack.is_complete() != complete
308 {
309 failures.push(format!(
310 "expected complete = {complete}; got {}",
311 stack.is_complete()
312 ));
313 }
314}
315
316fn join(items: &[String]) -> String {
317 items.join(", ")
318}
319
320#[cfg(test)]
321mod tests {
322 use super::super::SessionSpec;
323 use serde_json::{Value, json};
324
325 fn spec_with_tests() -> SessionSpec {
326 SessionSpec::from_value(json!({
327 "name": "collections",
328 "instruction": "Collect.",
329 "tools": [
330 {"name": "verify_identity", "set_state": {"identity_verified": true}},
331 {"name": "charge_card", "response": {"charged": true}}
332 ],
333 "flow": {
334 "steps": [
335 {"id": "verify", "posture": "Verify.", "allow": ["verify_identity"],
336 "done": {"is_true": "identity_verified"}},
337 {"id": "pay", "after": ["verify"], "posture": "Pay.",
338 "allow": ["charge_card"], "done": {"called_ok": "charge_card"}}
339 ],
340 "constraints": [
341 {"never_until": {"tool": "charge_card",
342 "until": {"is_true": "identity_verified"}}},
343 {"require": ["pay"]}
344 ]
345 },
346 "tests": [
347 {"name": "happy path", "script": [
348 {"expect": {"active": ["verify"], "blocked": ["charge_card"],
349 "complete": false}},
350 {"tool": "verify_identity"},
351 {"expect": {"done": ["verify"], "active": ["pay"],
352 "allowed": ["charge_card"],
353 "state": {"identity_verified": true}}},
354 {"tool": "charge_card"},
355 {"expect": {"done": ["pay"], "complete": true}}
356 ]},
357 {"name": "premature charge is blocked", "script": [
358 {"tool": "charge_card"},
359 {"expect": {"blocked": ["charge_card"], "complete": false}}
360 ]},
361 {"name": "deliberately wrong", "script": [
362 {"expect": {"done": ["pay"]}}
363 ]}
364 ]
365 }))
366 .expect("spec parses")
367 }
368
369 #[test]
370 fn scripted_tests_replay_through_the_real_monitor() {
371 let reports = spec_with_tests().run_tests();
372 assert_eq!(reports.len(), 3);
373 assert!(reports[0].passed, "happy path: {:?}", reports[0].failures);
374 assert!(
375 reports[1].passed,
376 "anticipated block passes: {:?}",
377 reports[1].failures
378 );
379 assert!(!reports[2].passed, "wrong expectation fails");
380 assert!(reports[2].failures[0].failures[0].contains("expected step 'pay' done"));
381 }
382
383 #[test]
384 fn trace_snapshots_every_event() {
385 let spec = spec_with_tests();
386 let snapshots = super::trace_test(&spec, "happy path").expect("traces");
387 assert_eq!(snapshots.len(), 6);
389 assert_eq!(snapshots[0].event, "start");
390 assert!(
391 snapshots[0]
392 .status
393 .explanation
394 .active
395 .contains(&"verify".to_string())
396 );
397 assert!(snapshots[2].status.done.contains(&"verify".to_string()));
399 assert!(
400 snapshots[2]
401 .status
402 .explanation
403 .active
404 .contains(&"pay".to_string())
405 );
406 assert!(snapshots[5].status.complete);
408 assert!(super::trace_test(&spec, "no such test").is_err());
409 }
410
411 #[test]
412 fn computed_variables_latch_guards_in_replay() {
413 let spec = SessionSpec::from_value(json!({
414 "instruction": "x",
415 "state": {"attempts": {"type": "number", "default": 0}},
416 "tools": [{"name": "record_score", "set_state": {"score": 0.9}}],
417 "computed": [{"key": "high_risk",
418 "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
419 "flow": {"steps": [
420 {"id": "assess", "posture": "Assess.", "allow": ["record_score"],
421 "done": {"is_true": "high_risk"}},
422 {"id": "wrap", "after": ["assess"], "terminal": true}
423 ], "constraints": [{"require": ["wrap"]}]},
424 "tests": [{"name": "risk computes", "script": [
425 {"expect": {"active": ["assess"],
426 "state": {"attempts": 0}}},
427 {"tool": "record_score"},
428 {"expect": {"done": ["assess", "wrap"], "complete": true,
429 "state": {"derived:high_risk": true}}}
430 ]}]
431 }))
432 .expect("parses");
433 let reports = spec.run_tests();
434 assert!(
435 reports[0].passed,
436 "computed guard latches offline: {:?}",
437 reports[0].failures
438 );
439 }
440
441 #[test]
442 fn unanticipated_block_is_a_failure() {
443 let mut spec = spec_with_tests();
444 spec.tests = vec![super::SpecTest {
446 name: "unanticipated".into(),
447 script: vec![super::SimEvent::Tool("charge_card".into())],
448 }];
449 let reports = spec.run_tests();
450 assert!(!reports[0].passed);
451 assert!(reports[0].failures[0].failures[0].contains("was blocked"));
452 }
453
454 fn conversation_spec(script: Value) -> SessionSpec {
455 use crate::conversation::Conversation;
456 use gemini_adk_rs::flow::{Guard, Resume};
457
458 let conversation = Conversation::new("support")
459 .stage("main")
460 .allow(["finish_main"])
461 .complete_when(Guard::called_ok("finish_main"))
462 .next("main_end", Guard::called_ok("finish_main"))
463 .stage("main_end")
464 .terminal()
465 .require(["main_end"])
466 .overlay("faq")
467 .trigger(Guard::is_true("intent:faq"))
468 .stage("answer")
469 .allow(["finish_faq"])
470 .complete_when(Guard::called_ok("finish_faq"))
471 .next("faq_end", Guard::called_ok("finish_faq"))
472 .stage("faq_end")
473 .terminal()
474 .require(["faq_end"])
475 .resume(Resume::Previous)
476 .end_overlay()
477 .overlay("clarify")
478 .trigger(Guard::is_true("intent:clarify"))
479 .stage("clarification")
480 .allow(["finish_clarify"])
481 .complete_when(Guard::called_ok("finish_clarify"))
482 .next("clarify_end", Guard::called_ok("finish_clarify"))
483 .stage("clarify_end")
484 .terminal()
485 .require(["clarify_end"])
486 .resume(Resume::Previous)
487 .end_overlay()
488 .overlay("cancel")
489 .trigger(Guard::is_true("intent:cancel"))
490 .stage("cancelled")
491 .terminal()
492 .require(["cancelled"])
493 .resume(Resume::Terminate)
494 .end_overlay()
495 .into_spec();
496 SessionSpec::from_value(json!({
497 "conversation": conversation,
498 "tools": [
499 {"name": "finish_main"},
500 {"name": "finish_faq", "set_state": {"intent:faq": false}},
501 {"name": "finish_clarify", "set_state": {"intent:clarify": false}}
502 ],
503 "tests": [{"name": "conversation", "script": script}]
504 }))
505 .expect("spec parses")
506 }
507
508 #[test]
509 fn preview_and_tests_preserve_nested_overlays_and_resume() {
510 let spec = conversation_spec(json!([
511 {"set": {"intent:faq": true}},
512 {"user": "FAQ"},
513 {"expect": {"active": ["answer"], "allowed": ["finish_faq"],
514 "complete": false}},
515 {"set": {"intent:clarify": true}},
516 {"user": "Clarify"},
517 {"expect": {"active": ["clarification"], "allowed": ["finish_clarify"],
518 "complete": false}},
519 {"tool": "finish_clarify"},
520 {"user": "Back to FAQ"},
521 {"expect": {"active": ["answer"], "allowed": ["finish_faq"]}},
522 {"tool": "finish_faq"},
523 {"user": "Back to main"},
524 {"expect": {"active": ["main"], "allowed": ["finish_main"],
525 "complete": false}},
526 {"tool": "finish_main"},
527 {"expect": {"done": ["main", "main_end"], "complete": true}}
528 ]));
529 let reports = spec.run_tests();
530 assert!(reports[0].passed, "{:?}", reports[0].failures);
531 let snapshots = super::trace_test(&spec, "conversation").expect("traces");
532 assert!(snapshots.iter().all(|s| s.failures.is_empty()));
533 assert_eq!(snapshots[2].status.overlay_path, ["faq"]);
534 assert_eq!(snapshots[5].status.overlay_path, ["faq", "clarify"]);
535 assert_eq!(snapshots[7].status.overlay_path, ["faq", "clarify"]);
536 assert_eq!(snapshots[8].status.overlay_path, ["faq"]);
537 assert!(snapshots[11].status.overlay_path.is_empty());
538 assert!(snapshots.last().expect("final").status.complete);
539 let value = serde_json::to_value(&snapshots[5]).expect("serializes");
540 assert_eq!(value["overlay_path"], json!(["faq", "clarify"]));
541 assert_eq!(value["terminated"], false);
542 assert!(value.get("status").is_none(), "runtime fields stay flat");
543 }
544
545 #[test]
546 fn nested_termination_is_reported_and_blocks_mock_tool_effects() {
547 let mut spec = conversation_spec(json!([
548 {"set": {"intent:faq": true}},
549 {"user": "FAQ"},
550 {"set": {"intent:cancel": true}},
551 {"user": "Cancel"},
552 {"expect": {"complete": false}},
553 {"user": "Closing turn"},
554 {"expect": {"complete": true, "blocked": ["finish_main"]}},
555 {"tool": "finish_main"},
556 {"expect": {"complete": true, "blocked": ["finish_main"],
557 "state": {"charged": false}}}
558 ]));
559 spec.state = serde_json::from_value(json!({
560 "charged": {"type": "boolean", "default": false}
561 }))
562 .expect("state parses");
563 spec.tools[0]
564 .set_state
565 .insert("charged".into(), json!(true));
566 let reports = spec.run_tests();
567 assert!(reports[0].passed, "{:?}", reports[0].failures);
568 let snapshots = super::trace_test(&spec, "conversation").expect("traces");
569 assert_eq!(snapshots[4].status.overlay_path, ["faq", "cancel"]);
570 assert!(!snapshots[4].status.complete);
571 let final_status = &snapshots.last().expect("final").status;
572 assert!(final_status.terminated);
573 assert!(final_status.complete);
574 assert!(final_status.overlay_path.is_empty());
575 assert!(final_status.explanation.active.is_empty());
576 assert!(final_status.explanation.allowed_tools.is_empty());
577 }
578
579 #[test]
580 fn overlay_denial_can_be_asserted_for_a_suspended_layers_tool() {
581 let mut spec = conversation_spec(json!([
582 {"set": {"intent:faq": true, "charged": false}},
583 {"user": "FAQ"},
584 {"tool": "finish_main"},
585 {"expect": {"blocked": ["finish_main"], "state": {"charged": false}}}
586 ]));
587 spec.tools[0]
588 .set_state
589 .insert("charged".into(), json!(true));
590 let reports = spec.run_tests();
591 assert!(reports[0].passed, "{:?}", reports[0].failures);
592 let trace = super::trace_test(&spec, "conversation").unwrap();
593 assert!(trace.iter().all(|snapshot| snapshot.failures.is_empty()));
594 }
595
596 #[test]
597 fn replay_uses_conversation_repair_and_set_does_not_count_as_a_turn() {
598 use crate::conversation::Conversation;
599 use gemini_adk_rs::flow::{Guard, RepairPolicy, reprompt_flag};
600
601 let conversation = Conversation::new("repair")
602 .stage("collect")
603 .complete_when(Guard::is_true("info"))
604 .next("done", Guard::is_true("info"))
605 .repair(RepairPolicy::new(2, 3).escalate_to("handoff"))
606 .stage("done")
607 .terminal()
608 .stage("handoff")
609 .complete_when(Guard::is_true("handoff_complete"))
610 .require(["done"])
611 .into_spec();
612 let spec = SessionSpec::from_value(json!({
613 "conversation": conversation,
614 "tests": [{"name": "repair", "script": [
615 {"set": {"unrelated": 1}},
616 {"set": {"unrelated": 2}},
617 {"user": "First turn"},
618 {"expect": {"active": ["collect"], "complete": false}},
619 {"user": "Second turn"},
620 {"expect": {"active": ["collect"],
621 "state": {reprompt_flag("collect"): true}}},
622 {"user": "Third turn"},
623 {"expect": {"active": ["handoff"],
624 "state": {"repair:collect:escalate": true}}}
625 ]}]
626 }))
627 .expect("parses");
628 let reports = spec.run_tests();
629 assert!(reports[0].passed, "{:?}", reports[0].failures);
630 let snapshots = super::trace_test(&spec, "repair").expect("traces");
631 assert!(
632 snapshots
633 .last()
634 .expect("final")
635 .status
636 .explanation
637 .active
638 .contains(&"handoff".into())
639 );
640 }
641
642 #[test]
643 fn invalid_specs_fail_preview_and_tests_at_setup() {
644 let mut spec = spec_with_tests();
645 spec.flow.as_mut().expect("flow").steps[1].after[0].step = "missing".into();
646 assert!(!spec.validate().valid);
647 assert!(super::trace_test(&spec, "happy path").is_err());
648 let reports = spec.run_tests();
649 assert!(reports.iter().all(|r| !r.passed && r.events == 0));
650 assert!(reports.iter().all(|r| r.failures[0].event == "setup"));
651 }
652
653 #[test]
654 fn offline_http_tools_keep_mock_effects_without_transport_features() {
655 let mut spec = spec_with_tests();
656 spec.tools[0].http = Some(
657 serde_json::from_value(json!({
658 "url": "http://127.0.0.1:1/never-executed"
659 }))
660 .expect("binding parses"),
661 );
662 let reports = spec.run_tests();
663 assert!(reports[0].passed, "{:?}", reports[0].failures);
664 assert!(reports[1].passed, "{:?}", reports[1].failures);
665 }
666
667 #[test]
668 fn offline_validation_preserves_conflicting_binding_errors() {
669 let mut spec = spec_with_tests();
670 spec.tools[0].http = Some(
671 serde_json::from_value(json!({
672 "url": "http://127.0.0.1:1/never-executed"
673 }))
674 .unwrap(),
675 );
676 spec.tools[0].mcp = Some("never-executed-tool-server".into());
677 let validation = spec.validate_for_replay();
678 assert!(!validation.valid);
679 assert!(
680 validation
681 .errors
682 .iter()
683 .any(|error| error.contains("both an http and an mcp"))
684 );
685 assert!(super::trace_test(&spec, "happy path").is_err());
686 assert!(
687 spec.run_tests()
688 .iter()
689 .all(|report| !report.passed && report.events == 0)
690 );
691 }
692}