use std::collections::HashSet;
use std::ops::RangeInclusive;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum NodeKind {
#[serde(rename_all = "camelCase")]
Agent {
system_prompt: String,
model: String,
#[serde(default)]
tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
},
#[serde(rename_all = "camelCase")]
Tool { tool_name: String },
#[serde(rename_all = "camelCase")]
Router {
#[serde(default = "default_max_iterations")]
max_iterations: u32,
},
Respond,
#[serde(rename_all = "camelCase")]
Supervisor {
system_prompt: String,
model: String,
routes: Vec<SupervisorRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
},
Parallel,
Join,
Approval {
title: String,
#[serde(default = "default_approval_mode")]
mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
risk_threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
confidence_threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
deadline_ms: Option<u64>,
},
}
fn default_approval_mode() -> String {
"always".to_string()
}
impl NodeKind {
pub fn kind_name(&self) -> &'static str {
match self {
NodeKind::Agent { .. } => "agent",
NodeKind::Tool { .. } => "tool",
NodeKind::Router { .. } => "router",
NodeKind::Respond => "respond",
NodeKind::Supervisor { .. } => "supervisor",
NodeKind::Parallel => "parallel",
NodeKind::Join => "join",
NodeKind::Approval { .. } => "approval",
}
}
fn requires_v2(&self) -> bool {
matches!(
self,
NodeKind::Supervisor { .. }
| NodeKind::Parallel
| NodeKind::Join
| NodeKind::Approval { .. }
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SupervisorRoute {
pub branch: String,
pub description: String,
}
fn default_max_iterations() -> u32 {
4
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
#[serde(flatten)]
pub kind: NodeKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from: String,
pub to: String,
#[serde(default)]
pub branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Graph {
pub entry: String,
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
impl Graph {
pub fn node(&self, id: &str) -> Option<&Node> {
self.nodes.iter().find(|n| n.id == id)
}
pub fn edges_from(&self, id: &str) -> impl Iterator<Item = &Edge> {
self.edges.iter().filter(move |e| e.from == id)
}
pub fn edges_to(&self, id: &str) -> impl Iterator<Item = &Edge> {
self.edges.iter().filter(move |e| e.to == id)
}
pub fn validate(&self, schema_version: u32) -> Result<(), String> {
let mut seen_ids: HashSet<&str> = HashSet::new();
for node in &self.nodes {
if !seen_ids.insert(node.id.as_str()) {
return Err(format!("duplicate node id '{}'", node.id));
}
}
if self.node(&self.entry).is_none() {
return Err(format!("entry node '{}' not found", self.entry));
}
for e in &self.edges {
if self.node(&e.from).is_none() {
return Err(format!("edge from unknown node '{}'", e.from));
}
if self.node(&e.to).is_none() {
return Err(format!("edge to unknown node '{}'", e.to));
}
}
for node in &self.nodes {
if node.kind.requires_v2() && schema_version < 2 {
return Err(format!(
"node kind `{}` requires schemaVersion 2",
node.kind.kind_name()
));
}
let out: Vec<&Edge> = self.edges_from(&node.id).collect();
match &node.kind {
NodeKind::Agent { .. } | NodeKind::Tool { .. } => {
if out.len() != 1 {
return Err(format!(
"node '{}' must have exactly 1 outgoing edge, found {}",
node.id,
out.len()
));
}
}
NodeKind::Router { .. } => {
let has = |b: &str| out.iter().any(|e| e.branch.as_deref() == Some(b));
if !has("loop") || !has("resolved") {
return Err(format!(
"router '{}' must have both a 'loop' and a 'resolved' branch edge",
node.id
));
}
}
NodeKind::Respond => {
if !out.is_empty() {
return Err(format!(
"respond node '{}' cannot have outgoing edges",
node.id
));
}
}
NodeKind::Supervisor { routes, .. } => {
self.validate_supervisor(node, routes, &out)?;
}
NodeKind::Parallel => {
self.validate_parallel(node, &out)?;
}
NodeKind::Join => {
let inc: Vec<&Edge> = self.edges_to(&node.id).collect();
if inc.len() < 2 {
return Err(format!(
"join node '{}' must have at least 2 incoming edges, found {}",
node.id,
inc.len()
));
}
if out.len() != 1 {
return Err(format!(
"join node '{}' must have exactly 1 outgoing edge, found {}",
node.id,
out.len()
));
}
for edge in &inc {
if !self.has_parallel_ancestor(edge.from.as_str()) {
return Err(format!(
"join node '{}' is reachable from node '{}' which is not on a parallel branch path",
node.id, edge.from
));
}
}
}
NodeKind::Approval { .. } => {
if out.is_empty() {
return Err(format!(
"approval node '{}' must have at least 1 outgoing edge, found 0",
node.id
));
}
}
}
}
Ok(())
}
fn validate_supervisor(
&self,
node: &Node,
routes: &[SupervisorRoute],
out: &[&Edge],
) -> Result<(), String> {
if routes.len() < 2 {
return Err(format!(
"supervisor '{}' must have at least 2 routes, found {}",
node.id,
routes.len()
));
}
let mut seen: HashSet<&str> = HashSet::new();
for r in routes {
if !seen.insert(r.branch.as_str()) {
return Err(format!(
"supervisor '{}' has duplicate route branch label '{}'",
node.id, r.branch
));
}
}
for r in routes {
let matching: Vec<&&Edge> = out
.iter()
.filter(|e| e.branch.as_deref() == Some(r.branch.as_str()))
.collect();
match matching.len() {
1 => {}
0 => {
return Err(format!(
"supervisor '{}' route '{}' has no matching outgoing edge",
node.id, r.branch
));
}
n => {
return Err(format!(
"supervisor '{}' route '{}' has {} matching outgoing edges (expected 1)",
node.id, r.branch, n
));
}
}
}
let declared_branches: HashSet<&str> = routes.iter().map(|r| r.branch.as_str()).collect();
for e in out {
let branch = e.branch.as_deref().unwrap_or("");
if !declared_branches.contains(branch) {
return Err(format!(
"supervisor '{}' has outgoing edge with undeclared branch label '{}'",
node.id, branch
));
}
}
Ok(())
}
fn validate_parallel(&self, node: &Node, out: &[&Edge]) -> Result<(), String> {
if out.len() < 2 {
return Err(format!(
"parallel node '{}' must have at least 2 outgoing edges, found {}",
node.id,
out.len()
));
}
let mut seen_branches: HashSet<&str> = HashSet::new();
for e in out {
let label = e.branch.as_deref().unwrap_or("");
if label.is_empty() {
return Err(format!(
"parallel node '{}' has an outgoing edge without a branch label",
node.id
));
}
if !seen_branches.insert(label) {
return Err(format!(
"parallel node '{}' has duplicate branch label '{}'",
node.id, label
));
}
}
let mut branch_paths: Vec<(String, HashSet<String>)> = Vec::new();
let mut join_targets: Vec<String> = Vec::new();
for edge in out {
let branch_label = edge.branch.as_deref().unwrap_or("").to_owned();
let (join_id, visited) =
self.walk_branch_to_join(node.id.as_str(), edge.to.as_str(), node.id.as_str())?;
join_targets.push(join_id);
branch_paths.push((branch_label, visited));
}
let common_join = &join_targets[0];
for (i, j) in join_targets.iter().enumerate() {
if j != common_join {
return Err(format!(
"parallel node '{}': branches do not converge on the same join node (branch 0 → '{}', branch {} → '{}')",
node.id, common_join, i, j
));
}
}
for i in 0..branch_paths.len() {
for j in (i + 1)..branch_paths.len() {
let shared: Vec<&String> =
branch_paths[i].1.intersection(&branch_paths[j].1).collect();
if !shared.is_empty() {
return Err(format!(
"parallel node '{}': branches '{}' and '{}' share node(s) {:?} before the join (branches must be node-disjoint)",
node.id, branch_paths[i].0, branch_paths[j].0, shared
));
}
}
}
Ok(())
}
fn walk_branch_to_join(
&self,
parallel_id: &str,
start_id: &str,
_origin: &str,
) -> Result<(String, HashSet<String>), String> {
const WALK_LIMIT: usize = 128;
let mut visited: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = vec![start_id.to_owned()];
let mut found_join: Option<String> = None;
let mut iterations = 0_usize;
while let Some(current) = stack.pop() {
iterations += 1;
if iterations > WALK_LIMIT {
return Err(format!(
"parallel node '{}': branch walk exceeded limit (possible cycle)",
parallel_id
));
}
if visited.contains(¤t) {
continue;
}
let node = self.node(¤t).ok_or_else(|| {
format!(
"parallel node '{}': branch references unknown node '{}'",
parallel_id, current
)
})?;
match &node.kind {
NodeKind::Join => {
match &found_join {
None => {
found_join = Some(current.clone());
}
Some(j) if j != ¤t => {
return Err(format!(
"parallel node '{}': branch path reaches multiple join nodes ('{}' and '{}')",
parallel_id, j, current
));
}
_ => {}
}
}
NodeKind::Parallel => {
return Err(format!(
"parallel node '{}': nested parallel '{}' is not allowed",
parallel_id, current
));
}
NodeKind::Respond => {
return Err(format!(
"parallel node '{}': respond node '{}' inside a parallel branch is not allowed",
parallel_id, current
));
}
_ => {
visited.insert(current.clone());
for e in self.edges_from(¤t) {
stack.push(e.to.clone());
}
}
}
}
found_join
.ok_or_else(|| {
format!(
"parallel node '{}': branch starting at '{}' never reaches a join node",
parallel_id, start_id
)
})
.map(|j| (j, visited))
}
fn has_parallel_ancestor(&self, node_id: &str) -> bool {
let mut visited: HashSet<&str> = HashSet::new();
let mut queue: Vec<&str> = vec![node_id];
while let Some(current) = queue.pop() {
if visited.contains(current) {
continue;
}
visited.insert(current);
for e in self.edges_to(current) {
if let Some(n) = self.node(&e.from) {
if matches!(n.kind, NodeKind::Parallel) {
return true;
}
queue.push(e.from.as_str());
}
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GraphConfig {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
#[serde(flatten)]
pub graph: Graph,
}
fn default_schema_version() -> u32 {
1
}
pub const SUPPORTED_SCHEMA_VERSIONS: RangeInclusive<u32> = 1..=2;
#[deprecated(since = "0.0.0", note = "use SUPPORTED_SCHEMA_VERSIONS instead")]
pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum GraphError {
#[error("invalid graph: {0}")]
Invalid(String),
#[error("graph JSON parse error: {0}")]
Parse(#[from] serde_json::Error),
#[error("unsupported graph schemaVersion {0}")]
UnsupportedSchemaVersion(u32),
}
impl GraphConfig {
pub fn from_json(raw: &str) -> Result<Self, GraphError> {
let cfg: GraphConfig = serde_json::from_str(raw)?;
if !SUPPORTED_SCHEMA_VERSIONS.contains(&cfg.schema_version) {
return Err(GraphError::UnsupportedSchemaVersion(cfg.schema_version));
}
cfg.graph
.validate(cfg.schema_version)
.map_err(GraphError::Invalid)?;
Ok(cfg)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::graph::test_fixtures;
fn triage_value() -> serde_json::Value {
serde_json::from_str(&test_fixtures::triage_json()).expect("fixture is valid JSON")
}
#[test]
fn parses_and_validates_triage_graph() {
let cfg = GraphConfig::from_json(&test_fixtures::triage_json()).expect("valid graph");
assert_eq!(cfg.schema_version, 1);
assert_eq!(cfg.graph.entry, "agent");
assert_eq!(cfg.graph.nodes.len(), 4);
}
#[test]
fn rejects_unknown_entry() {
let mut v = triage_value();
v["entry"] = "missing".into();
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(matches!(err, GraphError::Invalid(_)), "got {err:?}");
}
#[test]
fn rejects_router_without_resolved_branch() {
let mut v = triage_value();
v["edges"]
.as_array_mut()
.unwrap()
.retain(|e| e["branch"] != "resolved");
assert!(GraphConfig::from_json(&v.to_string()).is_err());
}
#[test]
fn rejects_agent_with_two_outgoing_edges() {
let mut v = triage_value();
v["edges"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"from": "agent", "to": "router"}));
assert!(GraphConfig::from_json(&v.to_string()).is_err());
}
#[test]
fn rejects_respond_with_outgoing_edge() {
let mut v = triage_value();
v["edges"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"from": "respond", "to": "agent"}));
assert!(GraphConfig::from_json(&v.to_string()).is_err());
}
#[test]
fn unknown_schema_version_is_rejected() {
let mut v = triage_value();
v["schemaVersion"] = 99.into();
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(matches!(err, GraphError::UnsupportedSchemaVersion(99)));
}
#[test]
fn rejects_duplicate_node_ids() {
let mut v = triage_value();
v["nodes"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"id": "agent", "kind": "respond"}));
assert!(GraphConfig::from_json(&v.to_string()).is_err());
}
#[test]
fn parses_and_validates_supervisor_graph() {
let cfg = GraphConfig::from_json(&test_fixtures::supervisor_json())
.expect("valid supervisor graph");
assert_eq!(cfg.schema_version, 2);
assert_eq!(cfg.graph.entry, "sup");
assert!(cfg.graph.nodes.len() >= 3, "expected at least 3 nodes");
}
#[test]
fn parses_and_validates_parallel_graph() {
let cfg =
GraphConfig::from_json(&test_fixtures::parallel_json()).expect("valid parallel graph");
assert_eq!(cfg.schema_version, 2);
assert_eq!(cfg.graph.entry, "entry");
assert!(cfg.graph.nodes.len() >= 5, "expected at least 5 nodes");
}
#[test]
fn approval_kind_serde_tag_and_validate() {
let n: NodeKind = serde_json::from_value(serde_json::json!({
"kind":"approval","title":"Send refund","mode":"always"
}))
.unwrap();
assert_eq!(n.kind_name(), "approval");
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "gate",
"nodes": [
{"id": "gate", "kind": "approval", "title": "Send refund"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "gate", "to": "respond", "branch": "approved"}
]
});
GraphConfig::from_json(&v.to_string()).expect("valid approval graph");
}
#[test]
fn v2_supervisor_kind_in_v1_doc_rejected() {
let v = serde_json::json!({
"schemaVersion": 1,
"entry": "sup",
"nodes": [
{"id": "sup", "kind": "supervisor", "systemPrompt": "route",
"model": "gpt-4o-mini",
"routes": [
{"branch": "a", "description": "A"},
{"branch": "b", "description": "B"}
]},
{"id": "respond_a", "kind": "respond"},
{"id": "respond_b", "kind": "respond"}
],
"edges": [
{"from": "sup", "to": "respond_a", "branch": "a"},
{"from": "sup", "to": "respond_b", "branch": "b"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("supervisor") && msg.contains("schemaVersion 2")),
"expected Invalid with 'supervisor requires schemaVersion 2', got {err:?}"
);
}
#[test]
fn v2_parallel_kind_in_v1_doc_rejected() {
let v = serde_json::json!({
"schemaVersion": 1,
"entry": "fan",
"nodes": [
{"id": "fan", "kind": "parallel"},
{"id": "meet", "kind": "join"},
{"id": "a", "kind": "respond"},
{"id": "b", "kind": "respond"}
],
"edges": [
{"from": "fan", "to": "a", "branch": "a"},
{"from": "fan", "to": "b", "branch": "b"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("parallel") && msg.contains("schemaVersion 2")),
"expected Invalid with 'parallel requires schemaVersion 2', got {err:?}"
);
}
#[test]
fn v2_join_kind_in_v1_doc_rejected() {
let v = serde_json::json!({
"schemaVersion": 1,
"entry": "meet",
"nodes": [
{"id": "meet", "kind": "join"}
],
"edges": []
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("join") && msg.contains("schemaVersion 2")),
"expected Invalid with 'join requires schemaVersion 2', got {err:?}"
);
}
#[test]
fn supervisor_fewer_than_two_routes_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "sup",
"nodes": [
{"id": "sup", "kind": "supervisor", "systemPrompt": "route",
"model": "gpt-4o-mini",
"routes": [{"branch": "a", "description": "A"}]},
{"id": "resp", "kind": "respond"}
],
"edges": [
{"from": "sup", "to": "resp", "branch": "a"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("at least 2 routes")),
"got {err:?}"
);
}
#[test]
fn supervisor_duplicate_route_labels_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "sup",
"nodes": [
{"id": "sup", "kind": "supervisor", "systemPrompt": "route",
"model": "gpt-4o-mini",
"routes": [
{"branch": "a", "description": "A"},
{"branch": "a", "description": "A duplicate"}
]},
{"id": "resp_a1", "kind": "respond"},
{"id": "resp_a2", "kind": "respond"}
],
"edges": [
{"from": "sup", "to": "resp_a1", "branch": "a"},
{"from": "sup", "to": "resp_a2", "branch": "a"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("duplicate route branch label")),
"got {err:?}"
);
}
#[test]
fn supervisor_route_without_matching_edge_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "sup",
"nodes": [
{"id": "sup", "kind": "supervisor", "systemPrompt": "route",
"model": "gpt-4o-mini",
"routes": [
{"branch": "billing", "description": "Billing"},
{"branch": "tech", "description": "Tech"}
]},
{"id": "resp_billing", "kind": "respond"}
],
"edges": [
{"from": "sup", "to": "resp_billing", "branch": "billing"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("no matching outgoing edge")),
"got {err:?}"
);
}
#[test]
fn supervisor_extra_outgoing_edge_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "sup",
"nodes": [
{"id": "sup", "kind": "supervisor", "systemPrompt": "route",
"model": "gpt-4o-mini",
"routes": [
{"branch": "billing", "description": "Billing"},
{"branch": "tech", "description": "Tech"}
]},
{"id": "resp_billing", "kind": "respond"},
{"id": "resp_tech", "kind": "respond"},
{"id": "resp_extra", "kind": "respond"}
],
"edges": [
{"from": "sup", "to": "resp_billing", "branch": "billing"},
{"from": "sup", "to": "resp_tech", "branch": "tech"},
{"from": "sup", "to": "resp_extra", "branch": "unknown_branch"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("undeclared branch label")),
"got {err:?}"
);
}
#[test]
fn parallel_single_branch_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "fan",
"nodes": [
{"id": "fan", "kind": "parallel"},
{"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
{"id": "meet", "kind": "join"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "fan", "to": "agent_a", "branch": "a"},
{"from": "agent_a", "to": "meet"},
{"from": "meet", "to": "respond"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("at least 2 outgoing edges")),
"got {err:?}"
);
}
#[test]
fn parallel_duplicate_branch_labels_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "fan",
"nodes": [
{"id": "fan", "kind": "parallel"},
{"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
{"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
{"id": "meet", "kind": "join"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "fan", "to": "agent_a", "branch": "same"},
{"from": "fan", "to": "agent_b", "branch": "same"},
{"from": "agent_a", "to": "meet"},
{"from": "agent_b", "to": "meet"},
{"from": "meet", "to": "respond"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("duplicate branch label")),
"got {err:?}"
);
}
#[test]
fn parallel_branches_sharing_node_before_join_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "fan",
"nodes": [
{"id": "fan", "kind": "parallel"},
{"id": "agent_shared", "kind": "agent", "systemPrompt": "shared", "model": "gpt-4o-mini"},
{"id": "meet", "kind": "join"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "fan", "to": "agent_shared", "branch": "a"},
{"from": "fan", "to": "agent_shared", "branch": "b"},
{"from": "agent_shared", "to": "meet"},
{"from": "meet", "to": "respond"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("share node") || msg.contains("node-disjoint")),
"got {err:?}"
);
}
#[test]
fn nested_parallel_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "outer",
"nodes": [
{"id": "outer", "kind": "parallel"},
{"id": "inner", "kind": "parallel"},
{"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
{"id": "inner_a", "kind": "agent", "systemPrompt": "ia", "model": "gpt-4o-mini"},
{"id": "inner_b", "kind": "agent", "systemPrompt": "ib", "model": "gpt-4o-mini"},
{"id": "inner_join", "kind": "join"},
{"id": "outer_join", "kind": "join"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "outer", "to": "agent_a", "branch": "a"},
{"from": "outer", "to": "inner", "branch": "b"},
{"from": "inner", "to": "inner_a", "branch": "x"},
{"from": "inner", "to": "inner_b", "branch": "y"},
{"from": "inner_a", "to": "inner_join"},
{"from": "inner_b", "to": "inner_join"},
{"from": "inner_join", "to": "outer_join"},
{"from": "agent_a", "to": "outer_join"},
{"from": "outer_join", "to": "respond"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("nested parallel")),
"got {err:?}"
);
}
#[test]
fn respond_inside_parallel_branch_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "fan",
"nodes": [
{"id": "fan", "kind": "parallel"},
{"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
{"id": "early_respond", "kind": "respond"},
{"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
{"id": "meet", "kind": "join"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "fan", "to": "agent_a", "branch": "a"},
{"from": "fan", "to": "agent_b", "branch": "b"},
{"from": "agent_a", "to": "early_respond"},
{"from": "agent_b", "to": "meet"},
{"from": "meet", "to": "respond"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(
matches!(&err, GraphError::Invalid(msg) if msg.contains("respond") && msg.contains("branch")),
"got {err:?}"
);
}
#[test]
fn join_with_one_incoming_edge_rejected() {
let v = serde_json::json!({
"schemaVersion": 2,
"entry": "fan",
"nodes": [
{"id": "fan", "kind": "parallel"},
{"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
{"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
{"id": "meet", "kind": "join"},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "fan", "to": "agent_a", "branch": "a"},
{"from": "fan", "to": "agent_b", "branch": "b"},
{"from": "agent_a", "to": "meet"},
{"from": "meet", "to": "respond"}
]
});
let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
assert!(matches!(&err, GraphError::Invalid(_)), "got {err:?}");
}
#[test]
fn v1_triage_fixture_still_parses_after_v2_changes() {
let cfg = GraphConfig::from_json(&test_fixtures::triage_json())
.expect("v1 triage must still parse");
assert_eq!(cfg.schema_version, 1);
}
#[test]
fn schema_version_2_is_accepted_for_v2_graphs() {
GraphConfig::from_json(&test_fixtures::supervisor_json()).expect("v2 graph must parse");
}
#[test]
fn agent_node_without_provider_deserializes_to_none() {
let json = serde_json::json!({
"id": "agent",
"kind": "agent",
"systemPrompt": "You help users.",
"model": "gpt-4o-mini"
});
let node: Node = serde_json::from_value(json).expect("should deserialize");
match node.kind {
NodeKind::Agent { provider, .. } => {
assert_eq!(provider, None, "absent provider must deserialize to None");
}
other => panic!("expected Agent, got {other:?}"),
}
}
#[test]
fn agent_node_with_provider_deserializes_to_some() {
let json = serde_json::json!({
"id": "agent",
"kind": "agent",
"systemPrompt": "You help users.",
"model": "claude-3-5-sonnet",
"provider": "anthropic"
});
let node: Node = serde_json::from_value(json).expect("should deserialize");
match node.kind {
NodeKind::Agent { provider, .. } => {
assert_eq!(
provider,
Some("anthropic".to_string()),
"explicit provider must round-trip"
);
}
other => panic!("expected Agent, got {other:?}"),
}
}
#[test]
fn agent_node_provider_none_is_omitted_from_json() {
let node = Node {
id: "agent".to_string(),
kind: NodeKind::Agent {
system_prompt: "You help users.".to_string(),
model: "gpt-4o-mini".to_string(),
tools: vec![],
provider: None,
},
};
let value = serde_json::to_value(&node).expect("serialization must succeed");
assert!(
value.get("provider").is_none(),
"provider: None must be omitted from JSON, got: {value}"
);
}
}