1use serde::{Deserialize, Serialize};
48use serde_json::Value;
49use std::collections::{BTreeMap, BTreeSet};
50use std::future::Future;
51use std::pin::Pin;
52use std::sync::Arc;
53use std::time::Duration;
54
55use gemini_adk_rs::extract::Extract;
56use gemini_adk_rs::flow::{CompiledFlow, Enforcement, Flow, FlowErrors, FlowMonitor, Guard, Pred};
57use gemini_adk_rs::frame::{Frame, FrameSpec};
58
59type SlotFetch =
61 Arc<dyn Fn(Value) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send>> + Send + Sync>;
62
63#[derive(Clone, Default)]
85pub struct ResolverRegistry {
86 fetchers: BTreeMap<String, SlotFetch>,
87}
88
89impl ResolverRegistry {
90 pub fn new() -> Self {
92 Self::default()
93 }
94
95 pub fn with<F, Fut>(mut self, name: impl Into<String>, fetch: F) -> Self
97 where
98 F: Fn(Value) -> Fut + Send + Sync + 'static,
99 Fut: Future<Output = Result<Value, String>> + Send + 'static,
100 {
101 self.add(name, fetch);
102 self
103 }
104
105 pub fn add<F, Fut>(&mut self, name: impl Into<String>, fetch: F)
107 where
108 F: Fn(Value) -> Fut + Send + Sync + 'static,
109 Fut: Future<Output = Result<Value, String>> + Send + 'static,
110 {
111 let fetch = Arc::new(fetch);
112 self.fetchers.insert(
113 name.into(),
114 Arc::new(move |v| {
115 let fetch = fetch.clone();
116 Box::pin(async move { fetch(v).await })
117 }),
118 );
119 }
120
121 pub fn contains(&self, name: &str) -> bool {
123 self.fetchers.contains_key(name)
124 }
125
126 pub fn stubbing(spec: &ConversationSpec) -> Self {
136 let mut reg = Self::new();
137 let stages = spec
138 .stages
139 .iter()
140 .chain(spec.overlays.iter().flat_map(|o| o.stages.iter()));
141 for stage in stages {
142 for r in &stage.resolve {
143 let name = r.resolver_name().to_string();
144 if !reg.contains(&name) {
145 reg.add(name, |_args| async { Ok(serde_json::Value::Null) });
146 }
147 }
148 }
149 reg
150 }
151
152 fn get(&self, name: &str) -> Option<SlotFetch> {
153 self.fetchers.get(name).cloned()
154 }
155}
156
157#[derive(Clone)]
161struct StageResolver {
162 stage: String,
163 name: String,
164 args: Vec<String>,
165 ttl: Option<Duration>,
166 fetch: SlotFetch,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
176pub struct ResolveSpec {
177 pub slot: String,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub resolver: Option<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub args: Vec<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub ttl_secs: Option<u64>,
188}
189
190impl ResolveSpec {
191 fn resolver_name(&self) -> &str {
194 self.resolver.as_deref().unwrap_or(&self.slot)
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
200pub struct TransitionSpec {
201 pub to: String,
203 pub when: Guard,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
209pub struct CommitSpec {
210 pub tool: String,
212 pub when: Guard,
214}
215
216#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
218pub struct StageSpec {
219 pub id: String,
221 #[serde(
224 default,
225 alias = "instruction",
226 skip_serializing_if = "Option::is_none"
227 )]
228 pub say: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub ground: Option<String>,
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub collect: Vec<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub frame: Option<FrameSpec>,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub allow: Vec<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub done: Option<Guard>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub commit: Option<CommitSpec>,
248 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub next: Vec<TransitionSpec>,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub after: Vec<String>,
254 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
256 pub terminal: bool,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub repair: Option<RepairPolicy>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub timing: Option<VoiceTiming>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub verbatim: Option<String>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub resolve: Vec<ResolveSpec>,
274}
275
276pub use gemini_adk_rs::flow::{FlowStack, RepairPolicy, Resume, VoiceTiming};
280use gemini_adk_rs::flow::{Overlay, correction_flag, escalate_flag, verbatim_flag};
281
282#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
285pub struct OverlaySpec {
286 pub name: String,
289 pub trigger: Guard,
291 #[serde(default)]
293 pub stages: Vec<StageSpec>,
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub require: Vec<String>,
297 #[serde(default)]
299 pub resume: Resume,
300}
301
302#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
305pub struct ConversationSpec {
306 pub name: String,
308 #[serde(default)]
310 pub stages: Vec<StageSpec>,
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub require: Vec<String>,
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub overlays: Vec<OverlaySpec>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub policies: Vec<crate::policy::Policy>,
320}
321
322#[derive(Debug)]
324pub enum ConversationError {
325 Empty,
327 Spec(String),
329 Flow(Vec<String>),
331 Compile(FlowErrors),
333}
334
335impl std::fmt::Display for ConversationError {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 match self {
338 ConversationError::Empty => write!(f, "conversation has no stages"),
339 ConversationError::Spec(m) => write!(f, "conversation spec error: {m}"),
340 ConversationError::Flow(errs) => {
341 write!(f, "lowered flow is invalid: {}", errs.join("; "))
342 }
343 ConversationError::Compile(e) => write!(f, "lowered flow failed to compile: {e}"),
344 }
345 }
346}
347
348impl std::error::Error for ConversationError {}
349
350pub fn conversation_spec_schema() -> String {
356 let schema = schemars::schema_for!(ConversationSpec);
357 serde_json::to_string_pretty(&schema).expect("schema serialization is infallible")
358}
359
360impl serde::Serialize for ConversationError {
361 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
365 use serde::ser::SerializeMap;
366 let mut m = s.serialize_map(None)?;
367 m.serialize_entry("message", &self.to_string())?;
368 match self {
369 ConversationError::Empty => {
370 m.serialize_entry("kind", "empty")?;
371 }
372 ConversationError::Spec(msg) => {
373 m.serialize_entry("kind", "spec")?;
374 m.serialize_entry("detail", msg)?;
375 }
376 ConversationError::Flow(errs) => {
377 m.serialize_entry("kind", "flow")?;
378 m.serialize_entry("errors", errs)?;
379 }
380 ConversationError::Compile(e) => {
381 m.serialize_entry("kind", "compile")?;
382 m.serialize_entry("errors", &e.0)?;
383 }
384 }
385 m.end()
386 }
387}
388
389#[derive(Clone)]
391pub struct CompiledOverlay {
392 pub name: String,
394 pub trigger: Guard,
396 pub flow: CompiledFlow,
398 pub extractors: Vec<Extract>,
400 pub resume: Resume,
402}
403
404impl CompiledOverlay {
405 pub fn to_runtime(&self) -> Overlay {
409 Overlay::new(
410 self.name.clone(),
411 self.trigger.clone(),
412 self.flow.clone(),
413 self.resume,
414 )
415 }
416}
417
418#[derive(Clone)]
421pub struct CompiledConversation {
422 flow: CompiledFlow,
423 extractors: Vec<Extract>,
424 overlays: Vec<CompiledOverlay>,
425 repair: BTreeMap<String, RepairPolicy>,
426 timing: BTreeMap<String, VoiceTiming>,
427 corrections: BTreeMap<String, Vec<String>>,
428 verbatim: BTreeMap<String, String>,
429 policies: Vec<crate::policy::Policy>,
430 spec: ConversationSpec,
431}
432
433impl std::fmt::Debug for CompiledConversation {
436 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437 f.debug_struct("CompiledConversation")
438 .field("flow", &self.flow)
439 .field("extractors", &self.extractors.len())
440 .field("overlays", &self.overlays.len())
441 .field("policies", &self.policies)
442 .field("spec", &self.spec)
443 .finish()
444 }
445}
446
447impl CompiledConversation {
448 pub fn flow(&self) -> &CompiledFlow {
450 &self.flow
451 }
452 pub fn extractors(&self) -> &[Extract] {
455 &self.extractors
456 }
457 pub fn overlays(&self) -> &[CompiledOverlay] {
459 &self.overlays
460 }
461 pub fn policies(&self) -> &[crate::policy::Policy] {
463 &self.policies
464 }
465 pub fn redacted_fields(&self) -> BTreeSet<String> {
467 self.policies
468 .iter()
469 .flat_map(|p| p.redacted_keys().iter().cloned())
470 .collect()
471 }
472 pub fn all_extractors(&self) -> Vec<Extract> {
475 let mut all = self.extractors.clone();
476 for ov in &self.overlays {
477 all.extend(ov.extractors.iter().cloned());
478 }
479 all
480 }
481 pub fn stack(&self, mode: Enforcement) -> FlowStack {
489 FlowStack::new(self.flow.clone(), mode)
490 .with_overlays(self.overlays.iter().map(CompiledOverlay::to_runtime))
491 .with_repairs(self.repair.clone())
492 .with_timings(self.timing.clone())
493 .with_corrections(self.corrections.clone())
494 .with_verbatims(self.verbatim.clone())
495 }
496 pub fn repair_policies(&self) -> &BTreeMap<String, RepairPolicy> {
498 &self.repair
499 }
500 pub fn verbatim_policies(&self) -> &BTreeMap<String, String> {
502 &self.verbatim
503 }
504 pub fn correction_policies(&self) -> &BTreeMap<String, Vec<String>> {
508 &self.corrections
509 }
510 pub fn timing_policies(&self) -> &BTreeMap<String, VoiceTiming> {
513 &self.timing
514 }
515 pub fn spec(&self) -> &ConversationSpec {
517 &self.spec
518 }
519 pub fn to_mermaid(&self) -> String {
521 self.flow.to_mermaid()
522 }
523 pub fn monitor(&self, mode: Enforcement) -> FlowMonitor {
525 FlowMonitor::compiled(self.flow.clone(), mode)
526 }
527}
528
529#[derive(Clone, Default)]
534pub struct Conversation {
535 spec: ConversationSpec,
536 resolvers: Vec<StageResolver>,
537 current_overlay: Option<usize>,
540}
541
542impl Conversation {
543 pub fn new(name: impl Into<String>) -> Self {
545 Self {
546 spec: ConversationSpec {
547 name: name.into(),
548 ..Default::default()
549 },
550 resolvers: Vec::new(),
551 current_overlay: None,
552 }
553 }
554
555 pub fn stage(mut self, id: impl Into<String>) -> Self {
558 let stage = StageSpec {
559 id: id.into(),
560 ..Default::default()
561 };
562 match self.current_overlay {
563 Some(i) => self.spec.overlays[i].stages.push(stage),
564 None => self.spec.stages.push(stage),
565 }
566 self
567 }
568
569 pub fn overlay(mut self, name: impl Into<String>) -> Self {
573 self.spec.overlays.push(OverlaySpec {
574 name: name.into(),
575 trigger: Guard::is_true("__overlay_never_triggers__"),
577 stages: Vec::new(),
578 require: Vec::new(),
579 resume: Resume::Previous,
580 });
581 self.current_overlay = Some(self.spec.overlays.len() - 1);
582 self
583 }
584
585 pub fn trigger(mut self, guard: Guard) -> Self {
587 if let Some(i) = self.current_overlay {
588 self.spec.overlays[i].trigger = guard;
589 }
590 self
591 }
592
593 pub fn resume(mut self, resume: Resume) -> Self {
595 if let Some(i) = self.current_overlay {
596 self.spec.overlays[i].resume = resume;
597 }
598 self
599 }
600
601 pub fn end_overlay(mut self) -> Self {
605 self.current_overlay = None;
606 self
607 }
608
609 pub fn add_stage(mut self, stage: StageSpec) -> Self {
613 match self.current_overlay {
614 Some(i) => self.spec.overlays[i].stages.push(stage),
615 None => self.spec.stages.push(stage),
616 }
617 self
618 }
619
620 pub fn add_overlay(mut self, overlay: OverlaySpec) -> Self {
623 self.spec.overlays.push(overlay);
624 self.current_overlay = None;
625 self
626 }
627
628 pub fn policy(mut self, policy: impl Into<crate::policy::Policy>) -> Self {
630 self.spec.policies.push(policy.into());
631 self
632 }
633
634 fn current(&mut self) -> &mut StageSpec {
635 let stages = match self.current_overlay {
636 Some(i) => &mut self.spec.overlays[i].stages,
637 None => &mut self.spec.stages,
638 };
639 stages
640 .last_mut()
641 .expect("call .stage(..) before configuring a stage")
642 }
643
644 pub fn instruction(mut self, text: impl Into<String>) -> Self {
648 self.current().say = Some(text.into());
649 self
650 }
651
652 pub fn say(self, text: impl Into<String>) -> Self {
655 self.instruction(text)
656 }
657
658 pub fn ground(mut self, template: impl Into<String>) -> Self {
660 self.current().ground = Some(template.into());
661 self
662 }
663
664 pub fn collect<I, S>(mut self, fields: I) -> Self
666 where
667 I: IntoIterator<Item = S>,
668 S: Into<String>,
669 {
670 self.current().collect = fields.into_iter().map(Into::into).collect();
671 self
672 }
673
674 pub fn collect_frame<F: Frame>(mut self) -> Self {
679 let spec = F::frame();
680 let stage = self.current();
681 stage.collect = spec.slot_keys();
682 stage.frame = Some(spec);
683 self
684 }
685
686 pub fn allow<I, S>(mut self, tools: I) -> Self
693 where
694 I: IntoIterator<Item = S>,
695 S: Into<String>,
696 {
697 self.current().allow = tools.into_iter().map(Into::into).collect();
698 self
699 }
700
701 pub fn complete_when(mut self, guard: Guard) -> Self {
704 self.current().done = Some(guard);
705 self
706 }
707
708 pub fn commit(mut self, tool: impl Into<String>, when: Guard) -> Self {
710 self.current().commit = Some(CommitSpec {
711 tool: tool.into(),
712 when,
713 });
714 self
715 }
716
717 pub fn next(mut self, to: impl Into<String>, when: Guard) -> Self {
719 self.current().next.push(TransitionSpec {
720 to: to.into(),
721 when,
722 });
723 self
724 }
725
726 pub fn resolve_slot<I, S, F, Fut>(
735 mut self,
736 name: impl Into<String>,
737 args: I,
738 ttl: Option<Duration>,
739 fetch: F,
740 ) -> Self
741 where
742 I: IntoIterator<Item = S>,
743 S: Into<String>,
744 F: Fn(Value) -> Fut + Send + Sync + 'static,
745 Fut: Future<Output = Result<Value, String>> + Send + 'static,
746 {
747 let name = name.into();
748 let stage = self.current().id.clone();
749 let args: Vec<String> = args.into_iter().map(Into::into).collect();
750 if !self.current().collect.contains(&name) {
751 self.current().collect.push(name.clone());
752 }
753 self.current().resolve.push(ResolveSpec {
756 slot: name.clone(),
757 resolver: None,
758 args: args.clone(),
759 ttl_secs: ttl.map(|d| d.as_secs()),
760 });
761 let fetch = Arc::new(fetch);
762 self.resolvers.push(StageResolver {
763 stage,
764 name,
765 args,
766 ttl,
767 fetch: Arc::new(move |v| {
768 let fetch = fetch.clone();
769 Box::pin(async move { fetch(v).await })
770 }),
771 });
772 self
773 }
774
775 pub fn after(mut self, dep: impl Into<String>) -> Self {
777 self.current().after.push(dep.into());
778 self
779 }
780
781 pub fn terminal(mut self) -> Self {
783 self.current().terminal = true;
784 self
785 }
786
787 pub fn repair(mut self, policy: RepairPolicy) -> Self {
789 self.current().repair = Some(policy);
790 self
791 }
792
793 pub fn verbatim(mut self, text: impl Into<String>) -> Self {
798 self.current().verbatim = Some(text.into());
799 self
800 }
801
802 pub fn timing(mut self, timing: VoiceTiming) -> Self {
816 self.current().timing = Some(timing);
817 self
818 }
819
820 pub fn require<I, S>(mut self, steps: I) -> Self
823 where
824 I: IntoIterator<Item = S>,
825 S: Into<String>,
826 {
827 let req: Vec<String> = steps.into_iter().map(Into::into).collect();
828 match self.current_overlay {
829 Some(i) => self.spec.overlays[i].require = req,
830 None => self.spec.require = req,
831 }
832 self
833 }
834
835 pub fn spec(&self) -> &ConversationSpec {
837 &self.spec
838 }
839
840 pub fn into_spec(self) -> ConversationSpec {
842 self.spec
843 }
844
845 pub fn from_spec(spec: ConversationSpec) -> Result<CompiledConversation, ConversationError> {
851 Self::from_spec_with_resolvers(spec, &ResolverRegistry::new())
852 }
853
854 pub fn from_spec_stubbing_resolvers(
864 spec: ConversationSpec,
865 ) -> Result<CompiledConversation, ConversationError> {
866 let registry = ResolverRegistry::stubbing(&spec);
867 Self::from_spec_with_resolvers(spec, ®istry)
868 }
869
870 pub fn from_spec_with_resolvers(
876 spec: ConversationSpec,
877 registry: &ResolverRegistry,
878 ) -> Result<CompiledConversation, ConversationError> {
879 let mut resolvers = Vec::new();
880 let stages = spec
881 .stages
882 .iter()
883 .chain(spec.overlays.iter().flat_map(|o| o.stages.iter()));
884 for stage in stages {
885 for r in &stage.resolve {
886 let fetch = registry.get(r.resolver_name()).ok_or_else(|| {
887 ConversationError::Spec(format!(
888 "stage '{}' slot '{}' needs resolver '{}', which is not in the registry",
889 stage.id,
890 r.slot,
891 r.resolver_name()
892 ))
893 })?;
894 resolvers.push(StageResolver {
895 stage: stage.id.clone(),
896 name: r.slot.clone(),
897 args: r.args.clone(),
898 ttl: r.ttl_secs.map(Duration::from_secs),
899 fetch,
900 });
901 }
902 }
903 compile_spec(spec, resolvers)
904 }
905
906 pub fn compile(self) -> Result<CompiledConversation, ConversationError> {
908 compile_spec(self.spec, self.resolvers)
909 }
910}
911
912impl crate::live::Live {
913 pub fn converse(self, convo: &CompiledConversation) -> Self {
936 self.govern_compiled(convo.flow().clone())
937 .install_conversation(convo)
938 }
939
940 pub fn converse_observe(self, convo: &CompiledConversation) -> Self {
943 self.observe_compiled(convo.flow().clone())
944 .install_conversation(convo)
945 }
946
947 fn install_conversation(mut self, convo: &CompiledConversation) -> Self {
952 self.digressions = convo
953 .overlays()
954 .iter()
955 .map(CompiledOverlay::to_runtime)
956 .collect();
957 self.repair_policies = convo.repair_policies().clone();
958 for (step, timing) in convo.timing_policies() {
961 self.stage_timings
962 .entry(step.clone())
963 .or_insert_with(|| timing.clone());
964 }
965 self.corrections = convo.correction_policies().clone();
966 self.verbatims = convo.verbatim_policies().clone();
967 let mut policies: Vec<_> = convo
971 .policies()
972 .iter()
973 .filter(|p| !matches!(p, crate::policy::Policy::SafetyHandoff { .. }))
974 .cloned()
975 .collect();
976 policies.append(&mut self.policies);
977 self.policies = policies;
978 for extract in convo.all_extractors() {
979 self = self.extract_record(extract);
980 }
981 self
982 }
983}
984
985fn is_always(g: &Guard) -> bool {
986 matches!(g, Guard::Spec(Pred::Always))
987}
988
989fn any_of(guards: Vec<Guard>) -> Option<Guard> {
991 if guards.is_empty() {
992 return None;
993 }
994 if guards.iter().any(is_always) {
995 return Some(Guard::always());
996 }
997 if guards.len() == 1 {
998 return guards.into_iter().next();
999 }
1000 Some(Guard::any(guards))
1001}
1002
1003fn overlay_ends(stages: &[StageSpec]) -> Vec<String> {
1008 let terminal: Vec<String> = stages
1009 .iter()
1010 .filter(|s| s.terminal)
1011 .map(|s| s.id.clone())
1012 .collect();
1013 if !terminal.is_empty() {
1014 return terminal;
1015 }
1016 let followed = |id: &str| stages.iter().any(|s| s.after.iter().any(|a| a == id));
1017 stages
1018 .iter()
1019 .filter(|s| s.next.is_empty() && !followed(&s.id))
1020 .map(|s| s.id.clone())
1021 .collect()
1022}
1023
1024fn lower_flow(stages: &[StageSpec], require: &[String]) -> Result<CompiledFlow, ConversationError> {
1025 if stages.is_empty() {
1026 return Err(ConversationError::Empty);
1027 }
1028 let ids: BTreeSet<&str> = stages.iter().map(|s| s.id.as_str()).collect();
1029 for s in stages {
1030 for t in &s.next {
1031 if !ids.contains(t.to.as_str()) {
1032 return Err(ConversationError::Spec(format!(
1033 "stage '{}' transitions to unknown stage '{}'",
1034 s.id, t.to
1035 )));
1036 }
1037 }
1038 for d in &s.after {
1039 if !ids.contains(d.as_str()) {
1040 return Err(ConversationError::Spec(format!(
1041 "stage '{}' depends on unknown stage '{}'",
1042 s.id, d
1043 )));
1044 }
1045 }
1046 if let Some(target) = s.repair.as_ref().and_then(|r| r.escalate_to.as_ref())
1047 && !ids.contains(target.as_str())
1048 {
1049 return Err(ConversationError::Spec(format!(
1050 "stage '{}' escalates to unknown stage '{}'",
1051 s.id, target
1052 )));
1053 }
1054 }
1055 for r in require {
1056 if !ids.contains(r.as_str()) {
1057 return Err(ConversationError::Spec(format!(
1058 "require references unknown stage '{r}'"
1059 )));
1060 }
1061 }
1062
1063 let mut incoming: BTreeMap<&str, Vec<(&str, Guard)>> = BTreeMap::new();
1065 for s in stages {
1066 for t in &s.next {
1067 incoming
1068 .entry(t.to.as_str())
1069 .or_default()
1070 .push((s.id.as_str(), t.when.clone()));
1071 }
1072 if let Some(target) = s.repair.as_ref().and_then(|r| r.escalate_to.as_ref()) {
1074 incoming
1075 .entry(target.as_str())
1076 .or_default()
1077 .push((s.id.as_str(), Guard::is_true(escalate_flag(&s.id))));
1078 }
1079 }
1080
1081 let mut fb = Flow::new();
1082 for s in stages {
1083 fb = fb.step(&s.id);
1084
1085 let mut deps: BTreeSet<&str> = s.after.iter().map(String::as_str).collect();
1086 if let Some(inc) = incoming.get(s.id.as_str()) {
1087 for (src, _) in inc {
1088 deps.insert(src);
1089 }
1090 }
1091 for d in deps {
1092 fb = fb.after(d);
1093 }
1094
1095 if let Some(inc) = incoming.get(s.id.as_str())
1096 && let Some(gate) = any_of(inc.iter().map(|(_, w)| w.clone()).collect())
1097 {
1098 fb = fb.gate(gate);
1099 }
1100
1101 if let Some(posture) = stage_posture(s) {
1102 fb = fb.posture(posture);
1103 }
1104 if let Some(ground) = &s.ground {
1105 fb = fb.ground(ground.clone());
1106 }
1107
1108 let mut allow: Vec<String> = s.allow.clone();
1109 if let Some(c) = &s.commit
1110 && !allow.contains(&c.tool)
1111 {
1112 allow.push(c.tool.clone());
1113 }
1114 if !allow.is_empty() {
1115 fb = fb.allow(allow);
1116 }
1117 if let Some(c) = &s.commit {
1118 fb = fb.commit(&c.tool, c.when.clone());
1119 }
1120
1121 if s.terminal {
1122 fb = fb.terminal();
1123 } else {
1124 let done = stage_completion(s).ok_or_else(|| {
1125 ConversationError::Spec(format!(
1126 "non-terminal stage '{}' has no completion (add collect, next, or done)",
1127 s.id
1128 ))
1129 })?;
1130 fb = fb.done(done);
1131 }
1132 }
1133
1134 for rule in reopen_rules(stages) {
1136 if let Some(when) = any_of(
1137 rule.slots
1138 .iter()
1139 .map(|slot| Guard::is_true(correction_flag(slot)))
1140 .collect(),
1141 ) {
1142 fb = fb.reset(rule.steps).when(when);
1143 }
1144 }
1145
1146 if !require.is_empty() {
1147 fb = fb.require(require.to_vec());
1148 }
1149
1150 let flow = fb.build().map_err(|e| ConversationError::Flow(e.issues))?;
1151 flow.compile().map_err(ConversationError::Compile)
1152}
1153
1154fn frame_extractors(stages: &[StageSpec]) -> Vec<Extract> {
1156 stages
1157 .iter()
1158 .filter_map(|s| s.frame.as_ref().and_then(FrameSpec::to_extract))
1159 .collect()
1160}
1161
1162fn compile_spec(
1163 mut spec: ConversationSpec,
1164 resolvers: Vec<StageResolver>,
1165) -> Result<CompiledConversation, ConversationError> {
1166 let authored_overlay_count = spec.overlays.len();
1167 for policy in spec.policies.clone() {
1170 if let crate::policy::Policy::SafetyHandoff { intents } = policy
1171 && let Some(trigger) = any_of(
1172 intents
1173 .iter()
1174 .map(|i| Guard::is_true(format!("intent:{i}")))
1175 .collect(),
1176 )
1177 {
1178 spec.overlays.push(OverlaySpec {
1179 name: "safety".into(),
1180 trigger,
1181 stages: vec![StageSpec {
1182 id: "safety_handoff".into(),
1183 say: Some("Safety concern detected — hand off to a human now.".into()),
1184 terminal: true,
1185 ..Default::default()
1186 }],
1187 require: Vec::new(),
1188 resume: Resume::Terminate,
1189 });
1190 }
1191 }
1192
1193 let mut overlay_names = BTreeSet::new();
1196 for overlay in &spec.overlays {
1197 if !overlay_names.insert(overlay.name.as_str()) {
1198 return Err(ConversationError::Spec(format!(
1199 "duplicate overlay name '{}'",
1200 overlay.name
1201 )));
1202 }
1203 }
1204 let mut stage_layers = BTreeMap::new();
1205 let layers = std::iter::once(("main flow".to_string(), spec.stages.as_slice())).chain(
1206 spec.overlays.iter().map(|overlay| {
1207 (
1208 format!("overlay '{}'", overlay.name),
1209 overlay.stages.as_slice(),
1210 )
1211 }),
1212 );
1213 for (layer, stages) in layers {
1214 for stage in stages {
1215 if let Some(previous) = stage_layers.insert(stage.id.as_str(), layer.clone()) {
1216 return Err(ConversationError::Spec(format!(
1217 "duplicate stage ids: '{}' appears in {previous} and {layer}",
1218 stage.id
1219 )));
1220 }
1221 }
1222 }
1223
1224 for stage in spec
1229 .stages
1230 .iter_mut()
1231 .chain(spec.overlays.iter_mut().flat_map(|o| o.stages.iter_mut()))
1232 {
1233 for slot in stage
1234 .resolve
1235 .iter()
1236 .map(|r| r.slot.clone())
1237 .collect::<Vec<_>>()
1238 {
1239 if !stage.collect.contains(&slot) {
1240 stage.collect.push(slot);
1241 }
1242 }
1243 }
1244
1245 let flow = lower_flow(&spec.stages, &spec.require)?;
1247
1248 let ids: BTreeSet<&str> = spec.stages.iter().map(|s| s.id.as_str()).collect();
1251 let mut overlay_of: BTreeMap<&str, usize> = BTreeMap::new();
1252 for (i, ov) in spec.overlays.iter().enumerate() {
1253 for s in &ov.stages {
1254 overlay_of.insert(s.id.as_str(), i);
1255 }
1256 }
1257 for r in &resolvers {
1258 if !ids.contains(r.stage.as_str()) && !overlay_of.contains_key(r.stage.as_str()) {
1259 return Err(ConversationError::Spec(format!(
1260 "resolver for slot '{}' references unknown stage '{}'",
1261 r.name, r.stage
1262 )));
1263 }
1264 }
1265
1266 let build_resolver_extractor = |stage: &str, binds: &[&StageResolver]| {
1269 let mut builder = Extract::record(format!("{}__{}_resolve", spec.name, stage));
1270 for r in binds {
1271 let fetch = r.fetch.clone();
1272 builder = builder.field_resolve(r.name.clone(), r.args.clone(), r.ttl, move |args| {
1273 let fetch = fetch.clone();
1274 async move { fetch(args).await }
1275 });
1276 }
1277 builder.build()
1278 };
1279 let mut by_stage: BTreeMap<&str, Vec<&StageResolver>> = BTreeMap::new();
1280 for r in &resolvers {
1281 by_stage.entry(r.stage.as_str()).or_default().push(r);
1282 }
1283 let mut overlay_extractors: BTreeMap<usize, Vec<Extract>> = BTreeMap::new();
1284
1285 let mut extractors = frame_extractors(&spec.stages);
1287 for (stage, binds) in &by_stage {
1288 let extractor = build_resolver_extractor(stage, binds);
1289 if ids.contains(stage) {
1290 extractors.push(extractor);
1291 } else if let Some(&i) = overlay_of.get(stage) {
1292 overlay_extractors.entry(i).or_default().push(extractor);
1293 }
1294 }
1295
1296 let mut overlays = Vec::with_capacity(spec.overlays.len());
1303 for ov in &spec.overlays {
1304 let require = if ov.require.is_empty() {
1305 overlay_ends(&ov.stages)
1306 } else {
1307 ov.require.clone()
1308 };
1309 let ov_flow = lower_flow(&ov.stages, &require)?;
1310 let mut ov_extractors = frame_extractors(&ov.stages);
1311 if let Some(bound) = overlay_extractors.remove(&overlays.len()) {
1312 ov_extractors.extend(bound);
1313 }
1314 overlays.push(CompiledOverlay {
1315 name: ov.name.clone(),
1316 trigger: ov.trigger.clone(),
1317 flow: ov_flow,
1318 extractors: ov_extractors,
1319 resume: ov.resume,
1320 });
1321 }
1322
1323 let repair = spec
1325 .stages
1326 .iter()
1327 .filter_map(|s| s.repair.clone().map(|p| (s.id.clone(), p)))
1328 .collect();
1329
1330 let timing = spec
1332 .stages
1333 .iter()
1334 .chain(spec.overlays.iter().flat_map(|ov| ov.stages.iter()))
1335 .filter_map(|s| {
1336 let timing = s.timing.clone().or_else(|| {
1337 s.verbatim
1338 .as_ref()
1339 .map(|_| VoiceTiming::new().uninterruptible())
1340 });
1341 timing.map(|t| (s.id.clone(), t))
1342 })
1343 .collect();
1344
1345 let verbatim = spec
1347 .stages
1348 .iter()
1349 .chain(spec.overlays.iter().flat_map(|ov| ov.stages.iter()))
1350 .filter_map(|s| s.verbatim.clone().map(|t| (s.id.clone(), t)))
1351 .collect();
1352
1353 let mut corrections: BTreeMap<String, Vec<String>> = BTreeMap::new();
1356 for rule in reopen_rules(&spec.stages) {
1357 for slot in rule.slots {
1358 let clear = corrections.entry(slot).or_default();
1359 for key in &rule.clear {
1360 if !clear.contains(key) {
1361 clear.push(key.clone());
1362 }
1363 }
1364 }
1365 }
1366
1367 let policies = spec.policies.clone();
1368 spec.overlays.truncate(authored_overlay_count);
1371
1372 Ok(CompiledConversation {
1373 flow,
1374 extractors,
1375 overlays,
1376 repair,
1377 timing,
1378 corrections,
1379 verbatim,
1380 policies,
1381 spec,
1382 })
1383}
1384
1385struct Reopen {
1393 slots: Vec<String>,
1394 steps: Vec<String>,
1395 clear: Vec<String>,
1396}
1397
1398fn reopen_rules(stages: &[StageSpec]) -> Vec<Reopen> {
1402 let mut dependents: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
1404 for s in stages {
1405 for d in &s.after {
1406 dependents
1407 .entry(d.as_str())
1408 .or_default()
1409 .insert(s.id.as_str());
1410 }
1411 for t in &s.next {
1412 dependents
1413 .entry(s.id.as_str())
1414 .or_default()
1415 .insert(t.to.as_str());
1416 }
1417 if let Some(target) = s.repair.as_ref().and_then(|r| r.escalate_to.as_ref()) {
1418 dependents
1419 .entry(s.id.as_str())
1420 .or_default()
1421 .insert(target.as_str());
1422 }
1423 }
1424 let collected: BTreeSet<&str> = stages
1425 .iter()
1426 .flat_map(|s| s.collect.iter().map(String::as_str))
1427 .collect();
1428 stages
1429 .iter()
1430 .filter(|s| !s.collect.is_empty())
1431 .filter_map(|s| {
1432 let mut downstream: BTreeSet<&str> = BTreeSet::new();
1433 let mut queue = vec![s.id.as_str()];
1434 while let Some(id) = queue.pop() {
1435 for next in dependents.get(id).into_iter().flatten() {
1436 if *next != s.id && downstream.insert(next) {
1437 queue.push(next);
1438 }
1439 }
1440 }
1441 if downstream.is_empty() {
1442 return None;
1443 }
1444 let clear = stages
1445 .iter()
1446 .filter(|st| downstream.contains(st.id.as_str()))
1447 .filter_map(|st| st.commit.as_ref())
1448 .flat_map(|c| c.when.state_keys())
1449 .filter(|k| !collected.contains(k.as_str()))
1450 .collect::<BTreeSet<_>>()
1451 .into_iter()
1452 .collect();
1453 Some(Reopen {
1454 slots: s.collect.clone(),
1455 steps: downstream.into_iter().map(str::to_string).collect(),
1456 clear,
1457 })
1458 })
1459 .collect()
1460}
1461
1462fn stage_posture(s: &StageSpec) -> Option<String> {
1465 let read = s.verbatim.as_ref().map(|text| {
1466 format!(
1467 "Say the following exactly as written, word for word. Do not paraphrase, \
1468 add to or leave out any part of it:\n\n{text}"
1469 )
1470 });
1471 match (&s.say, read) {
1472 (Some(say), Some(read)) => Some(format!("{say}\n\n{read}")),
1473 (say, read) => say.clone().or(read),
1474 }
1475}
1476
1477fn stage_completion(s: &StageSpec) -> Option<Guard> {
1478 let base = stage_base_completion(s);
1479 match (base, s.verbatim.is_some()) {
1481 (base, false) => base,
1482 (None, true) => Some(Guard::is_true(verbatim_flag(&s.id))),
1483 (Some(base), true) => Some(Guard::all(vec![base, Guard::is_true(verbatim_flag(&s.id))])),
1484 }
1485}
1486
1487fn stage_base_completion(s: &StageSpec) -> Option<Guard> {
1488 let base = if let Some(g) = &s.done {
1489 Some(g.clone())
1490 } else if !s.collect.is_empty() {
1491 Some(Guard::captured(s.collect.clone()))
1492 } else {
1493 any_of(s.next.iter().map(|t| t.when.clone()).collect())
1494 };
1495 if s.repair
1496 .as_ref()
1497 .and_then(|r| r.escalate_to.as_ref())
1498 .is_some()
1499 {
1500 let esc = Guard::is_true(escalate_flag(&s.id));
1501 return Some(match base {
1502 Some(b) => Guard::any(vec![b, esc]),
1503 None => esc,
1504 });
1505 }
1506 base
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512 use gemini_adk_rs::flow::Enforcement;
1513 use gemini_adk_rs::state::State;
1514
1515 fn booking() -> CompiledConversation {
1516 Conversation::new("booking")
1517 .stage("collect")
1518 .instruction("Help the user book a table.")
1519 .collect(["party_size", "slot"])
1520 .next("check", Guard::captured(["party_size", "slot"]))
1521 .stage("check")
1522 .ground("Party of {party_size} at {slot}.")
1523 .next("confirm", Guard::is_true("availability_ok"))
1524 .stage("confirm")
1525 .commit("book", Guard::is_true("user_confirmed"))
1526 .next("done", Guard::called_ok("book"))
1527 .stage("done")
1528 .terminal()
1529 .require(["done"])
1530 .compile()
1531 .expect("booking compiles")
1532 }
1533
1534 #[test]
1535 fn compiles_to_a_governed_flow() {
1536 let convo = booking();
1537 assert!(convo.flow().tool_surface().tools.contains("book"));
1539 assert_eq!(convo.flow().flow().steps.len(), 4);
1540 }
1541
1542 #[test]
1543 fn lowered_flow_enforces_stage_order_and_commit() {
1544 let convo = booking();
1545 let mut mon = convo.monitor(Enforcement::Enforce);
1546 let state = State::new();
1547
1548 let ex = mon.explain(&state);
1550 assert!(ex.active.contains(&"collect".to_string()));
1551 assert!(ex.blocked_tools.contains_key("book"));
1552
1553 let _ = state.set("party_size", 4u8);
1555 let _ = state.set("slot", "tomorrow 7pm");
1556 mon.on_turn(&state);
1557 assert!(mon.explain(&state).active.contains(&"check".to_string()));
1558
1559 let _ = state.set("availability_ok", true);
1561 mon.on_turn(&state);
1562 assert!(mon.admits_tool("book", &state).is_err());
1563
1564 let _ = state.set("user_confirmed", true);
1566 assert!(mon.admits_tool("book", &state).is_ok());
1567 mon.on_tool_ok("book", &state);
1568 mon.on_turn(&state);
1569 assert!(mon.is_complete());
1570 }
1571
1572 #[test]
1573 fn spec_round_trips_through_json() {
1574 let spec = booking().spec().clone();
1575 let json = serde_json::to_string(&spec).expect("serialize spec");
1576 let back: ConversationSpec = serde_json::from_str(&json).expect("deserialize spec");
1577 let recompiled = Conversation::from_spec(back).expect("recompile from spec");
1578 assert_eq!(recompiled.flow().flow().steps.len(), 4);
1579 }
1580
1581 #[tokio::test]
1582 async fn named_resolver_spec_round_trips_and_binds_from_registry() {
1583 let json = r#"
1585 {
1586 "name": "booking",
1587 "stages": [
1588 { "id": "check",
1589 "resolve": [{ "slot": "availability", "resolver": "avail", "args": ["party_size"] }],
1590 "next": [{ "to": "done", "when": { "captured": ["availability"] } }] },
1591 { "id": "done", "terminal": true }
1592 ],
1593 "require": ["done"]
1594 }
1595 "#;
1596 let spec: ConversationSpec = serde_json::from_str(json).expect("parse");
1597 let reser = serde_json::to_string(&spec).unwrap();
1599 let back: ConversationSpec = serde_json::from_str(&reser).unwrap();
1600 assert_eq!(back.stages[0].resolve[0].resolver_name(), "avail");
1601
1602 let err = Conversation::from_spec(back.clone()).expect_err("unbound resolver errors");
1605 assert!(matches!(err, ConversationError::Spec(m) if m.contains("avail")));
1606
1607 let registry = ResolverRegistry::new().with("avail", |_args| async move {
1609 Ok(serde_json::json!({ "open": true }))
1610 });
1611 let convo =
1612 Conversation::from_spec_with_resolvers(back, ®istry).expect("binds and compiles");
1613 assert!(
1615 convo.extractors().iter().any(|e| e
1616 .field_state_keys()
1617 .iter()
1618 .any(|(_, k)| k == "availability")),
1619 "a resolver extractor filling 'availability' was compiled in"
1620 );
1621 }
1622
1623 #[tokio::test]
1624 async fn resolver_spec_is_validatable_and_simulatable_without_a_registry() {
1625 let json = r#"{ "name": "r", "stages": [
1628 { "id": "check",
1629 "resolve": [{ "slot": "avail", "resolver": "lookup", "args": ["x"] }],
1630 "next": [{ "to": "done", "when": { "captured": ["avail"] } }] },
1631 { "id": "done", "terminal": true } ], "require": ["done"] }"#;
1632 let spec: ConversationSpec = serde_json::from_str(json).unwrap();
1633
1634 assert!(Conversation::from_spec(spec.clone()).is_err());
1636
1637 let convo = Conversation::from_spec_stubbing_resolvers(spec)
1639 .expect("stubbed resolvers compile for testing");
1640
1641 use crate::simulation::Sim;
1644 let mut sim = Sim::new(&convo, gemini_adk_rs::flow::Enforcement::Enforce);
1645 sim.set("avail", serde_json::json!({ "open": true }));
1646 sim.turn();
1647 assert!(sim.is_complete(), "resolver slot supplied by set completes");
1648 }
1649
1650 #[tokio::test]
1651 async fn overlay_resolver_binds_validates_and_fills_the_overlay() {
1652 let json = r#"{ "name": "ov", "stages": [
1656 { "id": "main", "terminal": true } ],
1657 "require": ["main"],
1658 "overlays": [ { "name": "lookup", "trigger": { "is_true": "intent:lookup" },
1659 "stages": [
1660 { "id": "fetch", "resolve": [{ "slot": "balance", "resolver": "bal" }] },
1661 { "id": "ov_done", "terminal": true, "after": ["fetch"] } ] } ] }"#;
1662 let spec: ConversationSpec = serde_json::from_str(json).unwrap();
1663
1664 let err = Conversation::from_spec(spec.clone()).expect_err("unbound overlay resolver");
1666 assert!(matches!(err, ConversationError::Spec(m) if m.contains("bal")));
1667
1668 let registry =
1670 ResolverRegistry::new().with("bal", |_args| async move { Ok(serde_json::json!(42)) });
1671 let convo = Conversation::from_spec_with_resolvers(spec, ®istry).expect("compiles");
1672 assert!(
1673 convo.overlays()[0]
1674 .extractors
1675 .iter()
1676 .any(|e| e.field_state_keys().iter().any(|(_, k)| k == "balance")),
1677 "the overlay carries the resolver extractor for 'balance'"
1678 );
1679 }
1680
1681 #[tokio::test]
1682 async fn resolver_slot_counts_toward_stage_completion_from_json() {
1683 let json = r#"{ "name": "r2", "stages": [
1687 { "id": "check", "resolve": [{ "slot": "avail", "resolver": "lookup" }] },
1688 { "id": "done", "terminal": true, "after": ["check"] } ],
1689 "require": ["done"] }"#;
1690 let spec: ConversationSpec = serde_json::from_str(json).unwrap();
1691 let convo =
1692 Conversation::from_spec_stubbing_resolvers(spec).expect("resolver-only stage compiles");
1693 assert!(
1694 convo.spec().stages[0]
1695 .collect
1696 .contains(&"avail".to_string()),
1697 "declared resolver slot normalized into collect"
1698 );
1699 }
1700
1701 #[test]
1702 fn conversation_spec_schema_is_valid_json_with_expected_shape() {
1703 let schema_str = conversation_spec_schema();
1704 let schema: serde_json::Value =
1705 serde_json::from_str(&schema_str).expect("schema is valid JSON");
1706 assert_eq!(schema["title"], "ConversationSpec");
1709 assert!(
1710 schema["properties"]["stages"].is_object(),
1711 "schema exposes the stages property: {schema}"
1712 );
1713 assert!(
1715 schema["definitions"]["Pred"].is_object() || schema["$defs"]["Pred"].is_object(),
1716 "Pred atom schema is present"
1717 );
1718 }
1719
1720 #[test]
1721 fn conversation_error_serializes_machine_readable() {
1722 let err = Conversation::new("x")
1724 .stage("s")
1725 .commit("pay", Guard::always())
1726 .complete_when(Guard::called_ok("pay"))
1727 .next("done", Guard::called_ok("pay"))
1728 .stage("done")
1729 .terminal()
1730 .compile()
1731 .expect_err("unguarded commit must fail");
1732 let json = serde_json::to_value(&err).expect("error serializes");
1733 assert_eq!(json["kind"], "compile");
1734 assert!(json["message"].is_string());
1735 let errors = json["errors"].as_array().expect("errors array");
1737 assert!(
1738 errors.iter().any(|e| e["kind"] == "unguarded_commit_tool"),
1739 "structured FlowError kinds present: {json}"
1740 );
1741 }
1742
1743 #[test]
1744 fn collect_frame_uses_frame_slot_keys() {
1745 use gemini_adk_rs::frame::{Frame, FrameSpec, SlotSpec};
1746
1747 struct Booking;
1748 impl Frame for Booking {
1749 fn frame() -> FrameSpec {
1750 FrameSpec {
1751 name: "booking".into(),
1752 slots: vec![SlotSpec::new("party_size"), SlotSpec::new("slot")],
1753 }
1754 }
1755 }
1756
1757 let convo = Conversation::new("b")
1758 .stage("collect")
1759 .collect_frame::<Booking>()
1760 .next("done", Guard::captured(["party_size", "slot"]))
1761 .stage("done")
1762 .terminal()
1763 .compile()
1764 .expect("compiles");
1765
1766 let mut mon = convo.monitor(Enforcement::Enforce);
1768 let state = State::new();
1769 assert!(mon.explain(&state).active.contains(&"collect".to_string()));
1770 let _ = state.set("party_size", 2u8);
1771 let _ = state.set("slot", "noon");
1772 mon.on_turn(&state);
1773 assert!(mon.marking().done.contains("collect"));
1775 assert!(mon.marking().done.contains("done"));
1776 }
1777
1778 #[tokio::test]
1779 async fn collect_frame_extractor_fills_and_scores_slots() {
1780 use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
1781 use gemini_adk_rs::live::TranscriptTurn;
1782
1783 struct Order;
1784 impl Frame for Order {
1785 fn frame() -> FrameSpec {
1786 FrameSpec {
1787 name: "order".into(),
1788 slots: vec![SlotSpec {
1789 recognizer: Some(SlotRecognizer::OneOf(vec![
1790 "pizza".into(),
1791 "salad".into(),
1792 ])),
1793 ..SlotSpec::new("item")
1794 }],
1795 }
1796 }
1797 }
1798
1799 let convo = Conversation::new("o")
1800 .stage("collect")
1801 .collect_frame::<Order>()
1802 .next("done", Guard::captured(["item"]))
1803 .stage("done")
1804 .terminal()
1805 .compile()
1806 .expect("compiles");
1807
1808 assert_eq!(convo.extractors().len(), 1);
1810 let extractor = convo.extractors()[0].clone().into_extractor();
1811
1812 let state = State::new();
1815 let window = vec![TranscriptTurn {
1816 turn_number: 0,
1817 user: "I'd like a large PIZZA".into(),
1818 model: String::new(),
1819 tool_calls: Vec::new(),
1820 timestamp: std::time::Instant::now(),
1821 }];
1822 let out = extractor.extract_with_state(&window, &state).await.unwrap();
1823 assert_eq!(out.get("item").and_then(|v| v.as_str()), Some("pizza"));
1824
1825 let ev = state.evidence("item");
1826 assert_eq!(ev.source.as_deref(), Some("extraction"));
1827 assert!(ev.confidence.unwrap() > 0.0);
1828 }
1829
1830 #[tokio::test]
1831 async fn validate_rejects_out_of_range_recognized_values() {
1832 use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec, SlotValidator};
1833 use gemini_adk_rs::live::TranscriptTurn;
1834
1835 struct Party;
1836 impl Frame for Party {
1837 fn frame() -> FrameSpec {
1838 FrameSpec {
1839 name: "party".into(),
1840 slots: vec![SlotSpec {
1841 recognizer: Some(SlotRecognizer::Integer),
1842 validate: Some(SlotValidator::Range {
1843 min: Some(1.0),
1844 max: Some(12.0),
1845 }),
1846 ..SlotSpec::new("party_size")
1847 }],
1848 }
1849 }
1850 }
1851
1852 let convo = Conversation::new("p")
1853 .stage("collect")
1854 .collect_frame::<Party>()
1855 .next("done", Guard::captured(["party_size"]))
1856 .stage("done")
1857 .terminal()
1858 .compile()
1859 .expect("compiles");
1860 let extractor = convo.extractors()[0].clone().into_extractor();
1861
1862 let run = |text: &str| {
1863 let extractor = extractor.clone();
1864 let text = text.to_string();
1865 async move {
1866 let state = State::new();
1867 let window = vec![TranscriptTurn {
1868 turn_number: 0,
1869 user: text,
1870 model: String::new(),
1871 tool_calls: Vec::new(),
1872 timestamp: std::time::Instant::now(),
1873 }];
1874 let out = extractor.extract_with_state(&window, &state).await.unwrap();
1875 out.get("party_size").cloned()
1876 }
1877 };
1878
1879 assert_eq!(run("a table for 4").await, Some(serde_json::json!(4)));
1881 assert_eq!(run("a table for 40").await, None);
1882 }
1883
1884 #[tokio::test]
1885 async fn resolve_slot_fills_from_async_fetch() {
1886 use gemini_adk_rs::live::TranscriptTurn;
1887
1888 let convo = Conversation::new("c")
1889 .stage("check")
1890 .resolve_slot("availability", ["party_size"], None, |args| async move {
1891 let n = args
1893 .get("party_size")
1894 .and_then(serde_json::Value::as_i64)
1895 .unwrap_or(0);
1896 Ok(serde_json::json!(n <= 8))
1897 })
1898 .next("done", Guard::is_set("availability"))
1899 .stage("done")
1900 .terminal()
1901 .compile()
1902 .expect("compiles");
1903
1904 assert_eq!(convo.extractors().len(), 1);
1906 let extractor = convo.extractors()[0].clone().into_extractor();
1907
1908 let state = State::new();
1909 let _ = state.set("party_size", 4i64);
1910 let window = vec![TranscriptTurn {
1911 turn_number: 0,
1912 user: "any".into(),
1913 model: String::new(),
1914 tool_calls: Vec::new(),
1915 timestamp: std::time::Instant::now(),
1916 }];
1917 let out = extractor.extract_with_state(&window, &state).await.unwrap();
1918 assert_eq!(out.get("availability"), Some(&serde_json::json!(true)));
1919 }
1920
1921 #[test]
1922 fn converse_registers_flow_and_extractors() {
1923 use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
1925
1926 struct Order;
1927 impl Frame for Order {
1928 fn frame() -> FrameSpec {
1929 FrameSpec {
1930 name: "order".into(),
1931 slots: vec![SlotSpec {
1932 recognizer: Some(SlotRecognizer::OneOf(vec!["pizza".into()])),
1933 ..SlotSpec::new("item")
1934 }],
1935 }
1936 }
1937 }
1938 let convo = Conversation::new("o")
1939 .stage("collect")
1940 .collect_frame::<Order>()
1941 .next("done", Guard::captured(["item"]))
1942 .stage("done")
1943 .terminal()
1944 .compile()
1945 .expect("compiles");
1946
1947 let _live = crate::live::Live::builder().converse(&convo);
1949 }
1950
1951 #[test]
1952 fn overlay_suspends_main_then_resumes_previous() {
1953 let convo = Conversation::new("support")
1956 .stage("a")
1957 .next("b", Guard::is_true("a_done"))
1958 .stage("b")
1959 .terminal()
1960 .overlay("faq")
1961 .trigger(Guard::is_true("intent:faq"))
1962 .stage("answer")
1964 .complete_when(Guard::is_true("faq_answered"))
1965 .next("faq_end", Guard::is_true("faq_answered"))
1966 .stage("faq_end")
1967 .terminal()
1968 .resume(Resume::Previous)
1969 .end_overlay()
1970 .compile()
1971 .expect("compiles");
1972
1973 assert_eq!(convo.overlays().len(), 1);
1974 let mut stack = convo.stack(Enforcement::Enforce);
1975 let state = State::new();
1976
1977 assert!(stack.explain(&state).active.contains(&"a".to_string()));
1979 assert!(stack.active_overlay().is_none());
1980
1981 let _ = state.set("intent:faq", true);
1983 stack.on_turn(&state);
1984 assert_eq!(stack.active_overlay(), Some("faq"));
1985
1986 let _ = state.set("faq_answered", true);
1989 let _ = state.set("intent:faq", false);
1990 stack.on_turn(&state);
1991 assert_eq!(stack.active_overlay(), Some("faq"));
1992
1993 stack.on_turn(&state);
1995 assert!(stack.active_overlay().is_none());
1996 assert!(stack.explain(&state).active.contains(&"a".to_string()));
1997
1998 let _ = state.set("a_done", true);
2000 stack.on_turn(&state);
2001 assert!(stack.current().marking().done.contains("a"));
2002 }
2003
2004 #[test]
2010 fn a_handoff_digression_waits_for_its_transfer() {
2011 let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
2012 "name": "dental",
2013 "stages": [
2014 { "id": "book", "allow": ["book_cleaning"], "next": [{ "to": "done", "when": { "called_ok": "book_cleaning" } }] },
2015 { "id": "done", "terminal": true }
2016 ],
2017 "overlays": [{
2018 "name": "handoff",
2019 "trigger": { "is_true": "intent:urgent_care" },
2020 "stages": [{
2021 "id": "transfer",
2022 "allow": ["transfer_to_staff"],
2023 "done": { "called_ok": "transfer_to_staff" }
2024 }],
2025 "resume": "terminate"
2026 }]
2027 }))
2028 .unwrap();
2029 let convo = Conversation::from_spec(spec).expect("compiles");
2030 let mut stack = convo.stack(Enforcement::Enforce);
2031 let state = State::new();
2032
2033 let _ = state.set("intent:urgent_care", true);
2034 stack.on_turn(&state);
2035 assert_eq!(stack.active_overlay(), Some("handoff"));
2036 stack.on_turn(&state);
2038 stack.on_turn(&state);
2039 assert_eq!(stack.active_overlay(), Some("handoff"));
2040 assert!(
2041 !stack.is_terminated(),
2042 "the call is still waiting on the transfer"
2043 );
2044 assert!(stack.admits_tool("transfer_to_staff", &state).is_ok());
2045 assert!(stack.admits_tool("book_cleaning", &state).is_err());
2046
2047 stack.on_tool_ok("transfer_to_staff", &state);
2048 stack.on_turn(&state);
2049 stack.on_turn(&state);
2050 assert!(stack.is_terminated(), "terminated once the transfer ran");
2051 }
2052
2053 #[test]
2059 fn live_and_simulator_drive_the_same_stack() {
2060 use crate::simulation::Sim;
2061
2062 let convo = Conversation::new("support")
2063 .stage("collect")
2064 .allow(["lookup"])
2065 .complete_when(Guard::is_true("info"))
2066 .next("done", Guard::is_true("info"))
2067 .repair(RepairPolicy::new(2, 3).escalate_to("handoff"))
2068 .stage("done")
2069 .terminal()
2070 .stage("handoff")
2071 .complete_when(Guard::is_true("handoff_complete"))
2072 .overlay("faq")
2073 .trigger(Guard::is_true("intent:faq"))
2074 .stage("answer")
2075 .allow(["search_faq"])
2076 .complete_when(Guard::is_true("faq_answered"))
2077 .next("faq_end", Guard::is_true("faq_answered"))
2078 .stage("faq_end")
2079 .terminal()
2080 .resume(Resume::Previous)
2081 .end_overlay()
2082 .compile()
2083 .expect("compiles");
2084
2085 let live = crate::live::Live::builder().converse(&convo);
2087 assert_eq!(
2088 live.digressions()
2089 .iter()
2090 .map(gemini_adk_rs::flow::Overlay::name)
2091 .collect::<Vec<_>>(),
2092 ["faq"]
2093 );
2094 assert!(live.repair_policies().contains_key("collect"));
2095 let monitor = FlowMonitor::compiled(convo.flow().clone(), Enforcement::Enforce);
2096 let mut installed = crate::live::connect::assemble_stack(
2097 monitor,
2098 live.digressions().to_vec(),
2099 live.repair_policies().clone(),
2100 std::collections::BTreeMap::new(),
2101 std::collections::BTreeMap::new(),
2102 std::collections::BTreeMap::new(),
2103 &[],
2104 );
2105
2106 let mut sim = Sim::new(&convo, Enforcement::Enforce);
2108 let state = sim.state().clone();
2110
2111 let script: [&[(&str, bool)]; 6] = [
2112 &[],
2113 &[("intent:faq", true)],
2114 &[("faq_answered", true), ("intent:faq", false)],
2115 &[],
2116 &[],
2117 &[],
2118 ];
2119 for facts in script {
2120 for (k, v) in facts {
2121 let _ = state.set(*k, *v);
2122 }
2123 sim.turn();
2124 installed.on_turn(&state);
2125 let a = sim.explain();
2126 let b = installed.explain(&state);
2127 assert_eq!(a.active, b.active);
2128 assert_eq!(a.allowed_tools, b.allowed_tools);
2129 assert_eq!(a.blocked_tools, b.blocked_tools);
2130 assert_eq!(sim.active_overlay(), installed.active_overlay());
2131 assert_eq!(sim.is_complete(), installed.is_complete());
2132 }
2133 assert!(
2135 installed
2136 .explain(&state)
2137 .active
2138 .contains(&"handoff".to_string())
2139 );
2140 assert_eq!(state.get::<bool>("repair:collect:escalate"), Some(true));
2141 }
2142
2143 #[test]
2146 fn regoverning_drops_installed_digressions() {
2147 let convo = Conversation::new("s")
2148 .stage("a")
2149 .terminal()
2150 .overlay("faq")
2151 .trigger(Guard::is_true("intent:faq"))
2152 .stage("x")
2153 .terminal()
2154 .end_overlay()
2155 .compile()
2156 .expect("compiles");
2157 let live = crate::live::Live::builder().converse(&convo);
2158 assert_eq!(live.digressions().len(), 1);
2159 let live = live.govern(Flow::new().step("only").terminal().build().expect("valid"));
2160 assert!(live.digressions().is_empty());
2161 }
2162
2163 #[test]
2164 fn overlay_spec_round_trips_through_json() {
2165 let spec = Conversation::new("s")
2166 .stage("main")
2167 .terminal()
2168 .overlay("cancel")
2169 .trigger(Guard::is_true("intent:cancel"))
2170 .stage("confirm")
2171 .terminal()
2172 .resume(Resume::Terminate)
2173 .end_overlay()
2174 .into_spec();
2175 let json = serde_json::to_string(&spec).unwrap();
2176 let back: ConversationSpec = serde_json::from_str(&json).unwrap();
2177 assert_eq!(back.overlays.len(), 1);
2178 assert_eq!(back.overlays[0].resume, Resume::Terminate);
2179 assert!(Conversation::from_spec(back).is_ok());
2181 }
2182
2183 #[tokio::test]
2184 async fn safety_policy_terminates_on_intent() {
2185 use crate::policy::Policy;
2186 use crate::simulation::Sim;
2187
2188 let convo = Conversation::new("support")
2189 .policy(Policy::safety_handoff(["self_harm", "abuse"]))
2190 .policy(Policy::redact(["card_number"]))
2191 .stage("triage")
2192 .next("resolve", Guard::is_true("triaged"))
2193 .stage("resolve")
2194 .terminal()
2195 .require(["resolve"])
2196 .compile()
2197 .expect("compiles");
2198
2199 assert!(convo.redacted_fields().contains("card_number"));
2201 assert!(convo.overlays().iter().any(|o| o.name == "safety"));
2203
2204 let mut sim = Sim::new(&convo, Enforcement::Enforce);
2205 assert!(sim.active().contains(&"triage".to_string()));
2206 assert!(!sim.is_complete());
2207
2208 sim.set("intent:abuse", true);
2211 sim.turn();
2212 assert_eq!(sim.active_overlay(), Some("safety"));
2213 assert!(!sim.is_complete());
2214 assert!(
2215 sim.postures()
2216 .iter()
2217 .any(|p| p.contains("hand off to a human")),
2218 "{:?}",
2219 sim.postures()
2220 );
2221
2222 sim.turn();
2224 assert!(sim.is_complete());
2225 assert!(sim.active().is_empty());
2226 }
2227
2228 #[tokio::test]
2231 async fn a_corrected_slot_reopens_the_confirmation() {
2232 use crate::simulation::{Scenario, SimStep};
2233 use serde_json::json;
2234 let convo = Conversation::new("booking")
2235 .stage("collect")
2236 .collect(["party_size"])
2237 .stage("book")
2238 .after("collect")
2239 .commit("book_table", Guard::is_true("confirmed"))
2240 .complete_when(Guard::called_ok("book_table"))
2241 .stage("end")
2242 .after("book")
2243 .terminal()
2244 .compile()
2245 .unwrap();
2246 assert_eq!(
2247 convo.correction_policies()["party_size"],
2248 ["confirmed"],
2249 "a correction to party_size clears the booking confirmation"
2250 );
2251
2252 let set = |key: &str, value: serde_json::Value| SimStep::Set {
2253 key: key.into(),
2254 value,
2255 };
2256 let scenario = Scenario {
2257 name: "correction".into(),
2258 steps: vec![
2259 set("party_size", json!(4)),
2260 SimStep::Turn,
2261 SimStep::ExpectActive(vec!["book".into()]),
2262 set("confirmed", json!(true)),
2263 SimStep::Turn,
2264 SimStep::ExpectAllowed("book_table".into()),
2265 set("party_size", json!(5)),
2267 SimStep::Turn,
2268 SimStep::ExpectDenied("book_table".into()),
2269 SimStep::ExpectActive(vec!["book".into()]),
2270 set("confirmed", json!(true)),
2271 SimStep::Turn,
2272 SimStep::ExpectAllowed("book_table".into()),
2273 SimStep::ToolOk("book_table".into()),
2274 SimStep::ExpectComplete,
2275 ],
2276 };
2277 scenario.run(&convo, Enforcement::Enforce).await.unwrap();
2278 }
2279
2280 #[tokio::test]
2282 async fn repeated_barge_ins_escalate_to_the_handoff() {
2283 use crate::simulation::{Scenario, SimStep};
2284 let convo = Conversation::new("support")
2285 .stage("collect")
2286 .collect(["issue"])
2287 .repair(
2288 RepairPolicy::new(10, 10)
2289 .escalate_after_interruptions(2)
2290 .escalate_to("handoff"),
2291 )
2292 .stage("handoff")
2293 .terminal()
2294 .compile()
2295 .unwrap();
2296 let scenario = Scenario {
2297 name: "barge-ins".into(),
2298 steps: vec![
2299 SimStep::Turn,
2300 SimStep::ExpectActive(vec!["collect".into()]),
2301 SimStep::Interrupt,
2302 SimStep::Turn,
2303 SimStep::ExpectActive(vec!["collect".into()]),
2304 SimStep::Interrupt,
2305 SimStep::ExpectSlot {
2306 key: escalate_flag("collect"),
2307 value: serde_json::json!(true),
2308 },
2309 SimStep::Turn,
2310 SimStep::ExpectComplete,
2312 ],
2313 };
2314 scenario.run(&convo, Enforcement::Enforce).await.unwrap();
2315 }
2316
2317 #[test]
2318 fn stage_timing_round_trips_and_reaches_the_stack() {
2319 use std::time::Duration;
2320 let convo = Conversation::new("pacing")
2321 .stage("ask")
2322 .collect(["name"])
2323 .timing(VoiceTiming::new().reprompt_after(Duration::from_secs(6)))
2324 .add_stage(crate::motifs::Motif::disclosure("terms", "terms_ack"))
2325 .compile()
2326 .unwrap();
2327
2328 let json = serde_json::to_value(convo.spec()).unwrap();
2329 assert_eq!(json["stages"][0]["timing"]["reprompt_after_ms"], 6000);
2330 assert_eq!(json["stages"][1]["timing"]["interruptible"], false);
2331 let back: ConversationSpec = serde_json::from_value(json).unwrap();
2332 assert_eq!(
2333 back.stages[0].timing,
2334 Some(VoiceTiming::new().reprompt_after(Duration::from_secs(6)))
2335 );
2336
2337 assert!(convo.timing_policies()["terms"].holds_floor());
2338 let stack = convo.stack(Enforcement::Enforce);
2339 assert_eq!(stack.timing_policies().len(), 2);
2340 }
2341
2342 #[test]
2343 fn converse_keeps_timings_and_policies_set_on_the_builder() {
2344 use std::time::Duration;
2345 let convo = Conversation::new("pacing")
2346 .stage("ask")
2347 .collect(["name"])
2348 .timing(VoiceTiming::new().reprompt_after(Duration::from_secs(6)))
2349 .stage("done")
2350 .after("ask")
2351 .terminal()
2352 .policy(crate::policy::Policy::redact(["card"]))
2353 .compile()
2354 .unwrap();
2355 let mine = VoiceTiming::new().reprompt_after(Duration::from_secs(3));
2356
2357 for live in [
2359 crate::live::Live::builder()
2360 .stage_timing("ask", mine.clone())
2361 .policy(crate::policy::Policy::redact(["ssn"]))
2362 .converse(&convo),
2363 crate::live::Live::builder()
2364 .converse(&convo)
2365 .stage_timing("ask", mine.clone())
2366 .policy(crate::policy::Policy::redact(["ssn"])),
2367 ] {
2368 assert_eq!(live.stage_timings["ask"], mine);
2369 assert_eq!(live.policies.len(), 2, "{:?}", live.policies);
2370 }
2371 }
2372
2373 #[tokio::test]
2374 async fn repair_reprompts_then_escalates_to_handoff() {
2375 use crate::simulation::Sim;
2376
2377 let convo = Conversation::new("support")
2380 .stage("collect")
2381 .complete_when(Guard::is_true("info"))
2382 .next("done", Guard::is_true("info"))
2383 .repair(RepairPolicy::new(2, 3).escalate_to("handoff"))
2384 .stage("done")
2385 .terminal()
2386 .stage("handoff")
2388 .complete_when(Guard::is_true("handoff_complete"))
2389 .compile()
2390 .expect("compiles");
2391
2392 let mut sim = Sim::new(&convo, Enforcement::Enforce);
2393 assert!(sim.active().contains(&"collect".to_string()));
2394
2395 sim.turn();
2397 assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), None);
2398 sim.turn();
2400 assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), Some(true));
2401 assert!(sim.active().contains(&"collect".to_string()));
2402 sim.turn();
2404 assert_eq!(sim.slot::<bool>("repair:collect:escalate"), Some(true));
2405 assert!(sim.active().contains(&"handoff".to_string()));
2406 assert!(!sim.active().contains(&"collect".to_string()));
2407 }
2408
2409 #[tokio::test]
2410 async fn repair_signal_clears_when_stage_satisfied() {
2411 use crate::simulation::Sim;
2412
2413 let convo = Conversation::new("s")
2414 .stage("collect")
2415 .complete_when(Guard::is_true("info"))
2416 .next("done", Guard::is_true("info"))
2417 .repair(RepairPolicy::new(1, 9))
2418 .stage("done")
2419 .terminal()
2420 .require(["done"])
2421 .compile()
2422 .expect("compiles");
2423
2424 let mut sim = Sim::new(&convo, Enforcement::Enforce);
2425 sim.turn(); assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), Some(true));
2427
2428 sim.set("info", true);
2431 sim.turn();
2432 sim.turn();
2433 assert_eq!(sim.slot::<bool>("repair:collect:reprompt"), Some(false));
2434 assert!(sim.is_complete());
2435 }
2436
2437 #[test]
2438 fn rejects_transition_to_unknown_stage() {
2439 let err = Conversation::new("x")
2440 .stage("a")
2441 .next("ghost", Guard::always())
2442 .stage("b")
2443 .terminal()
2444 .compile()
2445 .expect_err("unknown target must fail");
2446 assert!(matches!(err, ConversationError::Spec(_)));
2447 }
2448
2449 #[test]
2450 fn rejects_unguarded_commit_via_flow_compile() {
2451 let err = Conversation::new("x")
2453 .stage("s")
2454 .commit("pay", Guard::always())
2455 .complete_when(Guard::called_ok("pay"))
2456 .next("done", Guard::called_ok("pay"))
2457 .stage("done")
2458 .terminal()
2459 .compile()
2460 .expect_err("unguarded commit must fail");
2461 assert!(matches!(err, ConversationError::Compile(_)));
2462 }
2463
2464 #[test]
2465 fn stage_ids_must_be_unique_across_main_and_overlays() {
2466 let error = Conversation::new("collision")
2467 .stage("shared")
2468 .terminal()
2469 .overlay("faq")
2470 .trigger(Guard::is_true("intent:faq"))
2471 .stage("shared")
2472 .terminal()
2473 .end_overlay()
2474 .compile()
2475 .expect_err("a stage cannot have two owners");
2476 assert!(matches!(error, ConversationError::Spec(message)
2477 if message.contains("duplicate stage ids")
2478 && message.contains("shared")
2479 && message.contains("main flow")
2480 && message.contains("overlay 'faq'")));
2481 }
2482
2483 #[test]
2484 fn stage_ids_must_be_unique_between_overlays() {
2485 let error = Conversation::new("collision")
2486 .stage("main")
2487 .terminal()
2488 .overlay("faq")
2489 .trigger(Guard::is_true("intent:faq"))
2490 .stage("shared")
2491 .terminal()
2492 .end_overlay()
2493 .overlay("clarify")
2494 .trigger(Guard::is_true("intent:clarify"))
2495 .stage("shared")
2496 .terminal()
2497 .end_overlay()
2498 .compile()
2499 .expect_err("overlays cannot share stage identity");
2500 assert!(matches!(error, ConversationError::Spec(message)
2501 if message.contains("shared")
2502 && message.contains("overlay 'faq'")
2503 && message.contains("overlay 'clarify'")));
2504 }
2505
2506 #[test]
2507 fn overlay_names_must_be_unique() {
2508 let error = Conversation::new("collision")
2509 .stage("main")
2510 .terminal()
2511 .overlay("faq")
2512 .trigger(Guard::is_true("intent:faq"))
2513 .stage("answer")
2514 .terminal()
2515 .end_overlay()
2516 .overlay("faq")
2517 .trigger(Guard::is_true("intent:other_faq"))
2518 .stage("other_answer")
2519 .terminal()
2520 .end_overlay()
2521 .compile()
2522 .expect_err("overlay names select active layers");
2523 assert!(matches!(error, ConversationError::Spec(message)
2524 if message == "duplicate overlay name 'faq'"));
2525 }
2526
2527 #[test]
2528 fn generated_safety_overlay_cannot_collide_with_authored_names_or_stages() {
2529 use crate::policy::Policy;
2530
2531 let error = Conversation::new("collision")
2532 .stage("main")
2533 .terminal()
2534 .overlay("safety")
2535 .trigger(Guard::is_true("intent:custom_safety"))
2536 .stage("custom_handoff")
2537 .terminal()
2538 .end_overlay()
2539 .policy(Policy::safety_handoff(["self_harm"]))
2540 .compile()
2541 .expect_err("generated safety overlay requires a unique name");
2542 assert!(matches!(error, ConversationError::Spec(message)
2543 if message == "duplicate overlay name 'safety'"));
2544
2545 let error = Conversation::new("collision")
2546 .stage("safety_handoff")
2547 .terminal()
2548 .policy(Policy::safety_handoff(["self_harm"]))
2549 .compile()
2550 .expect_err("generated safety stage requires a unique id");
2551 assert!(matches!(error, ConversationError::Spec(message)
2552 if message.contains("safety_handoff")
2553 && message.contains("main flow")
2554 && message.contains("overlay 'safety'")));
2555 }
2556
2557 #[test]
2558 fn duplicate_stage_ids_within_one_layer_remain_invalid() {
2559 let error = Conversation::new("collision")
2560 .stage("shared")
2561 .terminal()
2562 .stage("shared")
2563 .terminal()
2564 .compile()
2565 .expect_err("local duplicate stages remain invalid");
2566 assert!(matches!(error, ConversationError::Spec(message)
2567 if message.contains("duplicate stage ids")));
2568 }
2569
2570 #[test]
2571 fn compiled_safety_spec_can_be_serialized_and_compiled_again() {
2572 use crate::policy::Policy;
2573
2574 let compiled = Conversation::new("safety")
2575 .stage("main")
2576 .terminal()
2577 .policy(Policy::safety_handoff(["self_harm"]))
2578 .compile()
2579 .expect("compiles");
2580 assert_eq!(compiled.overlays().len(), 1);
2581 let value = serde_json::to_value(compiled.spec()).expect("serializes");
2582 let spec = serde_json::from_value(value).expect("parses");
2583 let recompiled = Conversation::from_spec(spec).expect("recompiles");
2584 assert_eq!(recompiled.overlays().len(), 1);
2585 assert_eq!(recompiled.overlays()[0].name, "safety");
2586 }
2587}