1use std::collections::HashSet;
14use std::ops::RangeInclusive;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "lowercase")]
27pub enum NodeKind {
28 #[serde(rename_all = "camelCase")]
30 Agent {
31 system_prompt: String,
32 model: String,
33 #[serde(default)]
34 tools: Vec<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
39 provider: Option<String>,
40 },
41 #[serde(rename_all = "camelCase")]
43 Tool { tool_name: String },
44 #[serde(rename_all = "camelCase")]
46 Router {
47 #[serde(default = "default_max_iterations")]
48 max_iterations: u32,
49 },
50 Respond,
52 #[serde(rename_all = "camelCase")]
56 Supervisor {
57 system_prompt: String,
58 model: String,
59 routes: Vec<SupervisorRoute>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
64 provider: Option<String>,
65 },
66 Parallel,
70 Join,
74 Approval {
81 title: String,
82 #[serde(default = "default_approval_mode")]
83 mode: String,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 risk_threshold: Option<f64>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 confidence_threshold: Option<f64>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 deadline_ms: Option<u64>,
90 },
91}
92
93fn default_approval_mode() -> String {
96 "always".to_string()
97}
98
99impl NodeKind {
100 pub fn kind_name(&self) -> &'static str {
102 match self {
103 NodeKind::Agent { .. } => "agent",
104 NodeKind::Tool { .. } => "tool",
105 NodeKind::Router { .. } => "router",
106 NodeKind::Respond => "respond",
107 NodeKind::Supervisor { .. } => "supervisor",
108 NodeKind::Parallel => "parallel",
109 NodeKind::Join => "join",
110 NodeKind::Approval { .. } => "approval",
111 }
112 }
113
114 fn requires_v2(&self) -> bool {
116 matches!(
117 self,
118 NodeKind::Supervisor { .. }
119 | NodeKind::Parallel
120 | NodeKind::Join
121 | NodeKind::Approval { .. }
122 )
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct SupervisorRoute {
130 pub branch: String,
131 pub description: String,
132}
133
134fn default_max_iterations() -> u32 {
135 4
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Node {
145 pub id: String,
146 #[serde(flatten)]
147 pub kind: NodeKind,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Edge {
153 pub from: String,
154 pub to: String,
155 #[serde(default)]
158 pub branch: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Graph {
170 pub entry: String,
171 pub nodes: Vec<Node>,
172 pub edges: Vec<Edge>,
173}
174
175impl Graph {
176 pub fn node(&self, id: &str) -> Option<&Node> {
178 self.nodes.iter().find(|n| n.id == id)
179 }
180
181 pub fn edges_from(&self, id: &str) -> impl Iterator<Item = &Edge> {
183 self.edges.iter().filter(move |e| e.from == id)
184 }
185
186 pub fn edges_to(&self, id: &str) -> impl Iterator<Item = &Edge> {
188 self.edges.iter().filter(move |e| e.to == id)
189 }
190
191 pub fn validate(&self, schema_version: u32) -> Result<(), String> {
215 let mut seen_ids: HashSet<&str> = HashSet::new();
217 for node in &self.nodes {
218 if !seen_ids.insert(node.id.as_str()) {
219 return Err(format!("duplicate node id '{}'", node.id));
220 }
221 }
222
223 if self.node(&self.entry).is_none() {
225 return Err(format!("entry node '{}' not found", self.entry));
226 }
227
228 for e in &self.edges {
230 if self.node(&e.from).is_none() {
231 return Err(format!("edge from unknown node '{}'", e.from));
232 }
233 if self.node(&e.to).is_none() {
234 return Err(format!("edge to unknown node '{}'", e.to));
235 }
236 }
237
238 for node in &self.nodes {
240 if node.kind.requires_v2() && schema_version < 2 {
242 return Err(format!(
243 "node kind `{}` requires schemaVersion 2",
244 node.kind.kind_name()
245 ));
246 }
247
248 let out: Vec<&Edge> = self.edges_from(&node.id).collect();
249 match &node.kind {
250 NodeKind::Agent { .. } | NodeKind::Tool { .. } => {
251 if out.len() != 1 {
253 return Err(format!(
254 "node '{}' must have exactly 1 outgoing edge, found {}",
255 node.id,
256 out.len()
257 ));
258 }
259 }
260 NodeKind::Router { .. } => {
261 let has = |b: &str| out.iter().any(|e| e.branch.as_deref() == Some(b));
263 if !has("loop") || !has("resolved") {
264 return Err(format!(
265 "router '{}' must have both a 'loop' and a 'resolved' branch edge",
266 node.id
267 ));
268 }
269 }
270 NodeKind::Respond => {
271 if !out.is_empty() {
273 return Err(format!(
274 "respond node '{}' cannot have outgoing edges",
275 node.id
276 ));
277 }
278 }
279 NodeKind::Supervisor { routes, .. } => {
280 self.validate_supervisor(node, routes, &out)?;
282 }
283 NodeKind::Parallel => {
284 self.validate_parallel(node, &out)?;
286 }
287 NodeKind::Join => {
288 let inc: Vec<&Edge> = self.edges_to(&node.id).collect();
290 if inc.len() < 2 {
291 return Err(format!(
292 "join node '{}' must have at least 2 incoming edges, found {}",
293 node.id,
294 inc.len()
295 ));
296 }
297 if out.len() != 1 {
298 return Err(format!(
299 "join node '{}' must have exactly 1 outgoing edge, found {}",
300 node.id,
301 out.len()
302 ));
303 }
304 for edge in &inc {
308 if !self.has_parallel_ancestor(edge.from.as_str()) {
309 return Err(format!(
310 "join node '{}' is reachable from node '{}' which is not on a parallel branch path",
311 node.id, edge.from
312 ));
313 }
314 }
315 }
316 NodeKind::Approval { .. } => {
317 if out.is_empty() {
322 return Err(format!(
323 "approval node '{}' must have at least 1 outgoing edge, found 0",
324 node.id
325 ));
326 }
327 }
328 }
329 }
330
331 Ok(())
332 }
333
334 fn validate_supervisor(
339 &self,
340 node: &Node,
341 routes: &[SupervisorRoute],
342 out: &[&Edge],
343 ) -> Result<(), String> {
344 if routes.len() < 2 {
346 return Err(format!(
347 "supervisor '{}' must have at least 2 routes, found {}",
348 node.id,
349 routes.len()
350 ));
351 }
352
353 let mut seen: HashSet<&str> = HashSet::new();
355 for r in routes {
356 if !seen.insert(r.branch.as_str()) {
357 return Err(format!(
358 "supervisor '{}' has duplicate route branch label '{}'",
359 node.id, r.branch
360 ));
361 }
362 }
363
364 for r in routes {
366 let matching: Vec<&&Edge> = out
367 .iter()
368 .filter(|e| e.branch.as_deref() == Some(r.branch.as_str()))
369 .collect();
370 match matching.len() {
371 1 => {}
372 0 => {
373 return Err(format!(
374 "supervisor '{}' route '{}' has no matching outgoing edge",
375 node.id, r.branch
376 ));
377 }
378 n => {
379 return Err(format!(
380 "supervisor '{}' route '{}' has {} matching outgoing edges (expected 1)",
381 node.id, r.branch, n
382 ));
383 }
384 }
385 }
386
387 let declared_branches: HashSet<&str> = routes.iter().map(|r| r.branch.as_str()).collect();
389 for e in out {
390 let branch = e.branch.as_deref().unwrap_or("");
391 if !declared_branches.contains(branch) {
392 return Err(format!(
393 "supervisor '{}' has outgoing edge with undeclared branch label '{}'",
394 node.id, branch
395 ));
396 }
397 }
398
399 Ok(())
400 }
401
402 fn validate_parallel(&self, node: &Node, out: &[&Edge]) -> Result<(), String> {
407 if out.len() < 2 {
409 return Err(format!(
410 "parallel node '{}' must have at least 2 outgoing edges, found {}",
411 node.id,
412 out.len()
413 ));
414 }
415
416 let mut seen_branches: HashSet<&str> = HashSet::new();
418 for e in out {
419 let label = e.branch.as_deref().unwrap_or("");
420 if label.is_empty() {
421 return Err(format!(
422 "parallel node '{}' has an outgoing edge without a branch label",
423 node.id
424 ));
425 }
426 if !seen_branches.insert(label) {
427 return Err(format!(
428 "parallel node '{}' has duplicate branch label '{}'",
429 node.id, label
430 ));
431 }
432 }
433
434 let mut branch_paths: Vec<(String, HashSet<String>)> = Vec::new();
437 let mut join_targets: Vec<String> = Vec::new();
438
439 for edge in out {
440 let branch_label = edge.branch.as_deref().unwrap_or("").to_owned();
441 let (join_id, visited) =
442 self.walk_branch_to_join(node.id.as_str(), edge.to.as_str(), node.id.as_str())?;
443 join_targets.push(join_id);
444 branch_paths.push((branch_label, visited));
445 }
446
447 let common_join = &join_targets[0];
449 for (i, j) in join_targets.iter().enumerate() {
450 if j != common_join {
451 return Err(format!(
452 "parallel node '{}': branches do not converge on the same join node (branch 0 → '{}', branch {} → '{}')",
453 node.id, common_join, i, j
454 ));
455 }
456 }
457
458 for i in 0..branch_paths.len() {
461 for j in (i + 1)..branch_paths.len() {
462 let shared: Vec<&String> =
463 branch_paths[i].1.intersection(&branch_paths[j].1).collect();
464 if !shared.is_empty() {
465 return Err(format!(
466 "parallel node '{}': branches '{}' and '{}' share node(s) {:?} before the join (branches must be node-disjoint)",
467 node.id, branch_paths[i].0, branch_paths[j].0, shared
468 ));
469 }
470 }
471 }
472
473 Ok(())
474 }
475
476 fn walk_branch_to_join(
487 &self,
488 parallel_id: &str,
489 start_id: &str,
490 _origin: &str,
491 ) -> Result<(String, HashSet<String>), String> {
492 const WALK_LIMIT: usize = 128;
495
496 let mut visited: HashSet<String> = HashSet::new();
497 let mut stack: Vec<String> = vec![start_id.to_owned()];
498 let mut found_join: Option<String> = None;
499 let mut iterations = 0_usize;
500
501 while let Some(current) = stack.pop() {
502 iterations += 1;
503 if iterations > WALK_LIMIT {
504 return Err(format!(
505 "parallel node '{}': branch walk exceeded limit (possible cycle)",
506 parallel_id
507 ));
508 }
509
510 if visited.contains(¤t) {
511 continue;
513 }
514
515 let node = self.node(¤t).ok_or_else(|| {
516 format!(
517 "parallel node '{}': branch references unknown node '{}'",
518 parallel_id, current
519 )
520 })?;
521
522 match &node.kind {
523 NodeKind::Join => {
524 match &found_join {
526 None => {
527 found_join = Some(current.clone());
528 }
529 Some(j) if j != ¤t => {
530 return Err(format!(
531 "parallel node '{}': branch path reaches multiple join nodes ('{}' and '{}')",
532 parallel_id, j, current
533 ));
534 }
535 _ => {}
536 }
537 }
539 NodeKind::Parallel => {
540 return Err(format!(
541 "parallel node '{}': nested parallel '{}' is not allowed",
542 parallel_id, current
543 ));
544 }
545 NodeKind::Respond => {
546 return Err(format!(
547 "parallel node '{}': respond node '{}' inside a parallel branch is not allowed",
548 parallel_id, current
549 ));
550 }
551 _ => {
552 visited.insert(current.clone());
553 for e in self.edges_from(¤t) {
555 stack.push(e.to.clone());
556 }
557 }
558 }
559 }
560
561 found_join
562 .ok_or_else(|| {
563 format!(
564 "parallel node '{}': branch starting at '{}' never reaches a join node",
565 parallel_id, start_id
566 )
567 })
568 .map(|j| (j, visited))
569 }
570
571 fn has_parallel_ancestor(&self, node_id: &str) -> bool {
574 let mut visited: HashSet<&str> = HashSet::new();
576 let mut queue: Vec<&str> = vec![node_id];
577 while let Some(current) = queue.pop() {
578 if visited.contains(current) {
579 continue;
580 }
581 visited.insert(current);
582 for e in self.edges_to(current) {
583 if let Some(n) = self.node(&e.from) {
584 if matches!(n.kind, NodeKind::Parallel) {
585 return true;
586 }
587 queue.push(e.from.as_str());
588 }
589 }
590 }
591 false
592 }
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
603#[serde(rename_all = "camelCase")]
604pub struct GraphConfig {
605 #[serde(default = "default_schema_version")]
606 pub schema_version: u32,
607 #[serde(flatten)]
608 pub graph: Graph,
609}
610
611fn default_schema_version() -> u32 {
612 1
613}
614
615pub const SUPPORTED_SCHEMA_VERSIONS: RangeInclusive<u32> = 1..=2;
617
618#[deprecated(since = "0.0.0", note = "use SUPPORTED_SCHEMA_VERSIONS instead")]
621pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;
622
623#[derive(Debug, thiserror::Error)]
629pub enum GraphError {
630 #[error("invalid graph: {0}")]
631 Invalid(String),
632 #[error("graph JSON parse error: {0}")]
633 Parse(#[from] serde_json::Error),
634 #[error("unsupported graph schemaVersion {0}")]
635 UnsupportedSchemaVersion(u32),
636}
637
638impl GraphConfig {
643 pub fn from_json(raw: &str) -> Result<Self, GraphError> {
649 let cfg: GraphConfig = serde_json::from_str(raw)?;
650 if !SUPPORTED_SCHEMA_VERSIONS.contains(&cfg.schema_version) {
651 return Err(GraphError::UnsupportedSchemaVersion(cfg.schema_version));
652 }
653 cfg.graph
654 .validate(cfg.schema_version)
655 .map_err(GraphError::Invalid)?;
656 Ok(cfg)
657 }
658}
659
660#[cfg(test)]
665#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
666mod tests {
667 use super::*;
668 use crate::graph::test_fixtures;
669
670 fn triage_value() -> serde_json::Value {
673 serde_json::from_str(&test_fixtures::triage_json()).expect("fixture is valid JSON")
674 }
675
676 #[test]
681 fn parses_and_validates_triage_graph() {
682 let cfg = GraphConfig::from_json(&test_fixtures::triage_json()).expect("valid graph");
683 assert_eq!(cfg.schema_version, 1);
684 assert_eq!(cfg.graph.entry, "agent");
685 assert_eq!(cfg.graph.nodes.len(), 4);
686 }
687
688 #[test]
689 fn rejects_unknown_entry() {
690 let mut v = triage_value();
691 v["entry"] = "missing".into();
692 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
693 assert!(matches!(err, GraphError::Invalid(_)), "got {err:?}");
694 }
695
696 #[test]
697 fn rejects_router_without_resolved_branch() {
698 let mut v = triage_value();
699 v["edges"]
700 .as_array_mut()
701 .unwrap()
702 .retain(|e| e["branch"] != "resolved");
703 assert!(GraphConfig::from_json(&v.to_string()).is_err());
704 }
705
706 #[test]
707 fn rejects_agent_with_two_outgoing_edges() {
708 let mut v = triage_value();
709 v["edges"]
710 .as_array_mut()
711 .unwrap()
712 .push(serde_json::json!({"from": "agent", "to": "router"}));
713 assert!(GraphConfig::from_json(&v.to_string()).is_err());
714 }
715
716 #[test]
717 fn rejects_respond_with_outgoing_edge() {
718 let mut v = triage_value();
719 v["edges"]
720 .as_array_mut()
721 .unwrap()
722 .push(serde_json::json!({"from": "respond", "to": "agent"}));
723 assert!(GraphConfig::from_json(&v.to_string()).is_err());
724 }
725
726 #[test]
727 fn unknown_schema_version_is_rejected() {
728 let mut v = triage_value();
729 v["schemaVersion"] = 99.into();
730 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
731 assert!(matches!(err, GraphError::UnsupportedSchemaVersion(99)));
732 }
733
734 #[test]
735 fn rejects_duplicate_node_ids() {
736 let mut v = triage_value();
737 v["nodes"]
738 .as_array_mut()
739 .unwrap()
740 .push(serde_json::json!({"id": "agent", "kind": "respond"}));
741 assert!(GraphConfig::from_json(&v.to_string()).is_err());
742 }
743
744 #[test]
749 fn parses_and_validates_supervisor_graph() {
750 let cfg = GraphConfig::from_json(&test_fixtures::supervisor_json())
751 .expect("valid supervisor graph");
752 assert_eq!(cfg.schema_version, 2);
753 assert_eq!(cfg.graph.entry, "sup");
754 assert!(cfg.graph.nodes.len() >= 3, "expected at least 3 nodes");
756 }
757
758 #[test]
759 fn parses_and_validates_parallel_graph() {
760 let cfg =
761 GraphConfig::from_json(&test_fixtures::parallel_json()).expect("valid parallel graph");
762 assert_eq!(cfg.schema_version, 2);
763 assert_eq!(cfg.graph.entry, "entry");
764 assert!(cfg.graph.nodes.len() >= 5, "expected at least 5 nodes");
766 }
767
768 #[test]
769 fn approval_kind_serde_tag_and_validate() {
770 let n: NodeKind = serde_json::from_value(serde_json::json!({
771 "kind":"approval","title":"Send refund","mode":"always"
772 }))
773 .unwrap();
774 assert_eq!(n.kind_name(), "approval");
775
776 let v = serde_json::json!({
778 "schemaVersion": 2,
779 "entry": "gate",
780 "nodes": [
781 {"id": "gate", "kind": "approval", "title": "Send refund"},
782 {"id": "respond", "kind": "respond"}
783 ],
784 "edges": [
785 {"from": "gate", "to": "respond", "branch": "approved"}
786 ]
787 });
788 GraphConfig::from_json(&v.to_string()).expect("valid approval graph");
789 }
790
791 #[test]
796 fn v2_supervisor_kind_in_v1_doc_rejected() {
797 let v = serde_json::json!({
798 "schemaVersion": 1,
799 "entry": "sup",
800 "nodes": [
801 {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
802 "model": "gpt-4o-mini",
803 "routes": [
804 {"branch": "a", "description": "A"},
805 {"branch": "b", "description": "B"}
806 ]},
807 {"id": "respond_a", "kind": "respond"},
808 {"id": "respond_b", "kind": "respond"}
809 ],
810 "edges": [
811 {"from": "sup", "to": "respond_a", "branch": "a"},
812 {"from": "sup", "to": "respond_b", "branch": "b"}
813 ]
814 });
815 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
816 assert!(
817 matches!(&err, GraphError::Invalid(msg) if msg.contains("supervisor") && msg.contains("schemaVersion 2")),
818 "expected Invalid with 'supervisor requires schemaVersion 2', got {err:?}"
819 );
820 }
821
822 #[test]
823 fn v2_parallel_kind_in_v1_doc_rejected() {
824 let v = serde_json::json!({
825 "schemaVersion": 1,
826 "entry": "fan",
827 "nodes": [
828 {"id": "fan", "kind": "parallel"},
829 {"id": "meet", "kind": "join"},
830 {"id": "a", "kind": "respond"},
831 {"id": "b", "kind": "respond"}
832 ],
833 "edges": [
834 {"from": "fan", "to": "a", "branch": "a"},
835 {"from": "fan", "to": "b", "branch": "b"}
836 ]
837 });
838 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
839 assert!(
840 matches!(&err, GraphError::Invalid(msg) if msg.contains("parallel") && msg.contains("schemaVersion 2")),
841 "expected Invalid with 'parallel requires schemaVersion 2', got {err:?}"
842 );
843 }
844
845 #[test]
846 fn v2_join_kind_in_v1_doc_rejected() {
847 let v = serde_json::json!({
848 "schemaVersion": 1,
849 "entry": "meet",
850 "nodes": [
851 {"id": "meet", "kind": "join"}
852 ],
853 "edges": []
854 });
855 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
856 assert!(
857 matches!(&err, GraphError::Invalid(msg) if msg.contains("join") && msg.contains("schemaVersion 2")),
858 "expected Invalid with 'join requires schemaVersion 2', got {err:?}"
859 );
860 }
861
862 #[test]
867 fn supervisor_fewer_than_two_routes_rejected() {
868 let v = serde_json::json!({
869 "schemaVersion": 2,
870 "entry": "sup",
871 "nodes": [
872 {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
873 "model": "gpt-4o-mini",
874 "routes": [{"branch": "a", "description": "A"}]},
875 {"id": "resp", "kind": "respond"}
876 ],
877 "edges": [
878 {"from": "sup", "to": "resp", "branch": "a"}
879 ]
880 });
881 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
882 assert!(
883 matches!(&err, GraphError::Invalid(msg) if msg.contains("at least 2 routes")),
884 "got {err:?}"
885 );
886 }
887
888 #[test]
889 fn supervisor_duplicate_route_labels_rejected() {
890 let v = serde_json::json!({
891 "schemaVersion": 2,
892 "entry": "sup",
893 "nodes": [
894 {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
895 "model": "gpt-4o-mini",
896 "routes": [
897 {"branch": "a", "description": "A"},
898 {"branch": "a", "description": "A duplicate"}
899 ]},
900 {"id": "resp_a1", "kind": "respond"},
901 {"id": "resp_a2", "kind": "respond"}
902 ],
903 "edges": [
904 {"from": "sup", "to": "resp_a1", "branch": "a"},
905 {"from": "sup", "to": "resp_a2", "branch": "a"}
906 ]
907 });
908 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
909 assert!(
910 matches!(&err, GraphError::Invalid(msg) if msg.contains("duplicate route branch label")),
911 "got {err:?}"
912 );
913 }
914
915 #[test]
916 fn supervisor_route_without_matching_edge_rejected() {
917 let v = serde_json::json!({
918 "schemaVersion": 2,
919 "entry": "sup",
920 "nodes": [
921 {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
922 "model": "gpt-4o-mini",
923 "routes": [
924 {"branch": "billing", "description": "Billing"},
925 {"branch": "tech", "description": "Tech"}
926 ]},
927 {"id": "resp_billing", "kind": "respond"}
928 ],
930 "edges": [
931 {"from": "sup", "to": "resp_billing", "branch": "billing"}
932 ]
934 });
935 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
936 assert!(
937 matches!(&err, GraphError::Invalid(msg) if msg.contains("no matching outgoing edge")),
938 "got {err:?}"
939 );
940 }
941
942 #[test]
943 fn supervisor_extra_outgoing_edge_rejected() {
944 let v = serde_json::json!({
945 "schemaVersion": 2,
946 "entry": "sup",
947 "nodes": [
948 {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
949 "model": "gpt-4o-mini",
950 "routes": [
951 {"branch": "billing", "description": "Billing"},
952 {"branch": "tech", "description": "Tech"}
953 ]},
954 {"id": "resp_billing", "kind": "respond"},
955 {"id": "resp_tech", "kind": "respond"},
956 {"id": "resp_extra", "kind": "respond"}
957 ],
958 "edges": [
959 {"from": "sup", "to": "resp_billing", "branch": "billing"},
960 {"from": "sup", "to": "resp_tech", "branch": "tech"},
961 {"from": "sup", "to": "resp_extra", "branch": "unknown_branch"}
962 ]
963 });
964 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
965 assert!(
966 matches!(&err, GraphError::Invalid(msg) if msg.contains("undeclared branch label")),
967 "got {err:?}"
968 );
969 }
970
971 #[test]
976 fn parallel_single_branch_rejected() {
977 let v = serde_json::json!({
978 "schemaVersion": 2,
979 "entry": "fan",
980 "nodes": [
981 {"id": "fan", "kind": "parallel"},
982 {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
983 {"id": "meet", "kind": "join"},
984 {"id": "respond", "kind": "respond"}
985 ],
986 "edges": [
987 {"from": "fan", "to": "agent_a", "branch": "a"},
988 {"from": "agent_a", "to": "meet"},
989 {"from": "meet", "to": "respond"}
990 ]
991 });
992 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
993 assert!(
994 matches!(&err, GraphError::Invalid(msg) if msg.contains("at least 2 outgoing edges")),
995 "got {err:?}"
996 );
997 }
998
999 #[test]
1000 fn parallel_duplicate_branch_labels_rejected() {
1001 let v = serde_json::json!({
1002 "schemaVersion": 2,
1003 "entry": "fan",
1004 "nodes": [
1005 {"id": "fan", "kind": "parallel"},
1006 {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1007 {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
1008 {"id": "meet", "kind": "join"},
1009 {"id": "respond", "kind": "respond"}
1010 ],
1011 "edges": [
1012 {"from": "fan", "to": "agent_a", "branch": "same"},
1013 {"from": "fan", "to": "agent_b", "branch": "same"},
1014 {"from": "agent_a", "to": "meet"},
1015 {"from": "agent_b", "to": "meet"},
1016 {"from": "meet", "to": "respond"}
1017 ]
1018 });
1019 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1020 assert!(
1021 matches!(&err, GraphError::Invalid(msg) if msg.contains("duplicate branch label")),
1022 "got {err:?}"
1023 );
1024 }
1025
1026 #[test]
1027 fn parallel_branches_sharing_node_before_join_rejected() {
1028 let v = serde_json::json!({
1030 "schemaVersion": 2,
1031 "entry": "fan",
1032 "nodes": [
1033 {"id": "fan", "kind": "parallel"},
1034 {"id": "agent_shared", "kind": "agent", "systemPrompt": "shared", "model": "gpt-4o-mini"},
1035 {"id": "meet", "kind": "join"},
1036 {"id": "respond", "kind": "respond"}
1037 ],
1038 "edges": [
1039 {"from": "fan", "to": "agent_shared", "branch": "a"},
1040 {"from": "fan", "to": "agent_shared", "branch": "b"},
1041 {"from": "agent_shared", "to": "meet"},
1042 {"from": "meet", "to": "respond"}
1043 ]
1044 });
1045 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1046 assert!(
1047 matches!(&err, GraphError::Invalid(msg) if msg.contains("share node") || msg.contains("node-disjoint")),
1048 "got {err:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn nested_parallel_rejected() {
1054 let v = serde_json::json!({
1055 "schemaVersion": 2,
1056 "entry": "outer",
1057 "nodes": [
1058 {"id": "outer", "kind": "parallel"},
1059 {"id": "inner", "kind": "parallel"},
1060 {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1061 {"id": "inner_a", "kind": "agent", "systemPrompt": "ia", "model": "gpt-4o-mini"},
1062 {"id": "inner_b", "kind": "agent", "systemPrompt": "ib", "model": "gpt-4o-mini"},
1063 {"id": "inner_join", "kind": "join"},
1064 {"id": "outer_join", "kind": "join"},
1065 {"id": "respond", "kind": "respond"}
1066 ],
1067 "edges": [
1068 {"from": "outer", "to": "agent_a", "branch": "a"},
1069 {"from": "outer", "to": "inner", "branch": "b"},
1070 {"from": "inner", "to": "inner_a", "branch": "x"},
1071 {"from": "inner", "to": "inner_b", "branch": "y"},
1072 {"from": "inner_a", "to": "inner_join"},
1073 {"from": "inner_b", "to": "inner_join"},
1074 {"from": "inner_join", "to": "outer_join"},
1075 {"from": "agent_a", "to": "outer_join"},
1076 {"from": "outer_join", "to": "respond"}
1077 ]
1078 });
1079 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1080 assert!(
1081 matches!(&err, GraphError::Invalid(msg) if msg.contains("nested parallel")),
1082 "got {err:?}"
1083 );
1084 }
1085
1086 #[test]
1087 fn respond_inside_parallel_branch_rejected() {
1088 let v = serde_json::json!({
1089 "schemaVersion": 2,
1090 "entry": "fan",
1091 "nodes": [
1092 {"id": "fan", "kind": "parallel"},
1093 {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1094 {"id": "early_respond", "kind": "respond"},
1095 {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
1096 {"id": "meet", "kind": "join"},
1097 {"id": "respond", "kind": "respond"}
1098 ],
1099 "edges": [
1100 {"from": "fan", "to": "agent_a", "branch": "a"},
1101 {"from": "fan", "to": "agent_b", "branch": "b"},
1102 {"from": "agent_a", "to": "early_respond"},
1103 {"from": "agent_b", "to": "meet"},
1104 {"from": "meet", "to": "respond"}
1105 ]
1106 });
1107 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1108 assert!(
1109 matches!(&err, GraphError::Invalid(msg) if msg.contains("respond") && msg.contains("branch")),
1110 "got {err:?}"
1111 );
1112 }
1113
1114 #[test]
1119 fn join_with_one_incoming_edge_rejected() {
1120 let v = serde_json::json!({
1122 "schemaVersion": 2,
1123 "entry": "fan",
1124 "nodes": [
1125 {"id": "fan", "kind": "parallel"},
1126 {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1127 {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
1128 {"id": "meet", "kind": "join"},
1129 {"id": "respond", "kind": "respond"}
1130 ],
1131 "edges": [
1132 {"from": "fan", "to": "agent_a", "branch": "a"},
1133 {"from": "fan", "to": "agent_b", "branch": "b"},
1134 {"from": "agent_a", "to": "meet"},
1135 {"from": "meet", "to": "respond"}
1137 ]
1138 });
1139 let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1140 assert!(matches!(&err, GraphError::Invalid(_)), "got {err:?}");
1142 }
1143
1144 #[test]
1149 fn v1_triage_fixture_still_parses_after_v2_changes() {
1150 let cfg = GraphConfig::from_json(&test_fixtures::triage_json())
1151 .expect("v1 triage must still parse");
1152 assert_eq!(cfg.schema_version, 1);
1153 }
1154
1155 #[test]
1160 fn schema_version_2_is_accepted_for_v2_graphs() {
1161 GraphConfig::from_json(&test_fixtures::supervisor_json()).expect("v2 graph must parse");
1163 }
1164
1165 #[test]
1172 fn agent_node_without_provider_deserializes_to_none() {
1173 let json = serde_json::json!({
1174 "id": "agent",
1175 "kind": "agent",
1176 "systemPrompt": "You help users.",
1177 "model": "gpt-4o-mini"
1178 });
1179 let node: Node = serde_json::from_value(json).expect("should deserialize");
1180 match node.kind {
1181 NodeKind::Agent { provider, .. } => {
1182 assert_eq!(provider, None, "absent provider must deserialize to None");
1183 }
1184 other => panic!("expected Agent, got {other:?}"),
1185 }
1186 }
1187
1188 #[test]
1191 fn agent_node_with_provider_deserializes_to_some() {
1192 let json = serde_json::json!({
1193 "id": "agent",
1194 "kind": "agent",
1195 "systemPrompt": "You help users.",
1196 "model": "claude-3-5-sonnet",
1197 "provider": "anthropic"
1198 });
1199 let node: Node = serde_json::from_value(json).expect("should deserialize");
1200 match node.kind {
1201 NodeKind::Agent { provider, .. } => {
1202 assert_eq!(
1203 provider,
1204 Some("anthropic".to_string()),
1205 "explicit provider must round-trip"
1206 );
1207 }
1208 other => panic!("expected Agent, got {other:?}"),
1209 }
1210 }
1211
1212 #[test]
1215 fn agent_node_provider_none_is_omitted_from_json() {
1216 let node = Node {
1217 id: "agent".to_string(),
1218 kind: NodeKind::Agent {
1219 system_prompt: "You help users.".to_string(),
1220 model: "gpt-4o-mini".to_string(),
1221 tools: vec![],
1222 provider: None,
1223 },
1224 };
1225 let value = serde_json::to_value(&node).expect("serialization must succeed");
1226 assert!(
1227 value.get("provider").is_none(),
1228 "provider: None must be omitted from JSON, got: {value}"
1229 );
1230 }
1231}