#![warn(missing_docs)]
use mlua_flow_ir::Node as FlowNode;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
pub const CURRENT_SCHEMA_VERSION: &str = "0.1.0";
fn default_schema_version() -> semver::Version {
current_schema_version()
}
pub fn current_schema_version() -> semver::Version {
semver::Version::parse(CURRENT_SCHEMA_VERSION)
.expect("CURRENT_SCHEMA_VERSION must be valid semver")
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(transparent)]
pub struct BlueprintId(String);
impl BlueprintId {
pub const MAIN: &'static str = "main";
pub fn main() -> Self {
Self(Self::MAIN.to_string())
}
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Display for BlueprintId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for BlueprintId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for BlueprintId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[cfg(test)]
mod blueprint_id_tests {
use super::*;
#[test]
fn blueprint_id_field_schema_stays_a_plain_inline_string() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
let id = &v["properties"]["id"];
assert_eq!(id["type"], "string", "id must stay a plain string: {id}");
assert!(id.get("$ref").is_none(), "id must not become a $ref: {id}");
}
#[test]
fn blueprint_id_serde_is_transparent() {
let id = BlueprintId::new("coding");
assert_eq!(
serde_json::to_value(&id).unwrap(),
serde_json::json!("coding")
);
let back: BlueprintId = serde_json::from_value(serde_json::json!("coding")).unwrap();
assert_eq!(back, id);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Blueprint {
#[serde(default = "default_schema_version")]
#[schemars(with = "String")]
pub schema_version: semver::Version,
#[schemars(with = "String")]
pub id: BlueprintId,
#[schemars(with = "Value")]
pub flow: FlowNode,
#[serde(default)]
pub agents: Vec<AgentDef>,
#[serde(default)]
pub operators: Vec<OperatorDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metas: Vec<MetaDef>,
#[serde(default)]
pub hints: CompilerHints,
#[serde(default)]
pub strategy: CompilerStrategy,
#[serde(default)]
pub metadata: BlueprintMetadata,
#[serde(default)]
pub spawner_hints: SpawnerHints,
#[serde(default = "default_global_agent_kind")]
pub default_agent_kind: AgentKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_operator_kind: Option<OperatorKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<Value>")]
pub default_init_ctx: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<Value>")]
pub default_agent_ctx: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_context_policy: Option<ContextPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_placement: Option<ProjectionPlacementSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub audits: Vec<AuditDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub degradation_policy: Option<DegradationPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runners: Vec<RunnerDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_runner: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DegradationPolicy {
Warn,
Fail,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AuditDef {
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steps: Option<Vec<String>>,
#[serde(default)]
pub mode: AuditMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AuditMode {
#[default]
Async,
Sync,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ContextPolicy {
#[serde(default)]
pub include: Option<Vec<String>>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub steps: Option<Vec<String>>,
#[serde(default)]
pub steps_exclude: Vec<String>,
}
impl ContextPolicy {
pub fn allows(&self, name: &str) -> bool {
if self.exclude.iter().any(|excluded| excluded == name) {
return false;
}
match &self.include {
Some(list) => list.iter().any(|included| included == name),
None => true,
}
}
pub fn allows_step(&self, name: &str) -> bool {
if self.steps_exclude.iter().any(|excluded| excluded == name) {
return false;
}
match &self.steps {
Some(list) => list.iter().any(|included| included == name),
None => true,
}
}
}
pub fn default_global_agent_kind() -> AgentKind {
AgentKind::Operator
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SpawnerHints {
#[serde(default)]
pub layers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AgentDef {
pub name: String,
pub kind: AgentKind,
#[serde(default)]
pub spec: Value,
#[serde(default)]
pub profile: Option<AgentProfile>,
#[serde(default)]
pub meta: Option<AgentMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner: Option<Runner>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<VerdictContract>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AgentProfile {
#[serde(default)]
pub system_prompt: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub extras: Value,
#[serde(default)]
pub version_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_binding: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AgentKind {
Lua,
RustFn,
AgentBlock,
Subprocess,
Operator,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VerdictContract {
pub channel: VerdictChannel,
pub values: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum VerdictChannel {
Body,
Part,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
pub enum Runner {
WsClaudeCode {
variant: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tools: Vec<String>,
},
AgentBlockInProcess {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tools: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RunnerDef {
pub name: String,
pub runner: Runner,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkerModel {
pub runner: Runner,
pub agent: AgentDef,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RunnerResolveError {
#[error(
"agent '{agent}' runner_ref '{ref_name}' does not match any RunnerDef.name in \
Blueprint.runners (defined: {available:?})"
)]
UnknownRunnerRef {
agent: String,
ref_name: String,
available: Vec<String>,
},
#[error(
"default_runner '{ref_name}' does not match any RunnerDef.name in Blueprint.runners \
(defined: {available:?})"
)]
UnknownDefaultRunner {
ref_name: String,
available: Vec<String>,
},
}
pub fn resolve_runner(
bp: &Blueprint,
agent: &AgentDef,
) -> Result<Option<Runner>, RunnerResolveError> {
if let Some(runner) = &agent.runner {
return Ok(Some(runner.clone()));
}
if let Some(ref_name) = &agent.runner_ref {
return match bp.runners.iter().find(|def| &def.name == ref_name) {
Some(def) => Ok(Some(def.runner.clone())),
None => Err(RunnerResolveError::UnknownRunnerRef {
agent: agent.name.clone(),
ref_name: ref_name.clone(),
available: bp.runners.iter().map(|d| d.name.clone()).collect(),
}),
};
}
if let Some(variant) = agent
.profile
.as_ref()
.and_then(|p| p.worker_binding.as_ref())
{
let tools = agent
.profile
.as_ref()
.map(|p| p.tools.clone())
.unwrap_or_default();
return Ok(Some(Runner::WsClaudeCode {
variant: variant.clone(),
tools,
}));
}
if let Some(ref_name) = &bp.default_runner {
return match bp.runners.iter().find(|def| &def.name == ref_name) {
Some(def) => Ok(Some(def.runner.clone())),
None => Err(RunnerResolveError::UnknownDefaultRunner {
ref_name: ref_name.clone(),
available: bp.runners.iter().map(|d| d.name.clone()).collect(),
}),
};
}
Ok(None)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OperatorKind {
MainAi,
#[default]
Automate,
Composite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OperatorDef {
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub kind: Option<OperatorKind>,
#[serde(default)]
pub spec: Value,
#[serde(default)]
pub profile: Option<AgentProfile>,
#[serde(default)]
pub meta: Option<AgentMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MetaDef {
pub name: String,
pub ctx: Value,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProjectionPlacementSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AgentMeta {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<Value>")]
pub ctx: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_policy: Option<ContextPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CompilerHints {
#[serde(default)]
pub per_agent: HashMap<String, Value>,
#[serde(default)]
pub global: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CompilerStrategy {
#[serde(default = "default_true")]
pub strict_refs: bool,
#[serde(default = "default_true")]
pub strict_kind: bool,
}
fn default_true() -> bool {
true
}
impl Default for CompilerStrategy {
fn default() -> Self {
Self {
strict_refs: true,
strict_kind: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BlueprintMetadata {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub origin: BlueprintOrigin,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_name_alias: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_run_ttl_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BlueprintOrigin {
#[default]
Inline,
File {
path: String,
},
Algo {
session_id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_default_parses() {
let v = default_schema_version();
assert_eq!(v.to_string(), "0.1.0");
}
#[test]
fn current_schema_version_const_matches() {
assert_eq!(CURRENT_SCHEMA_VERSION, "0.1.0");
}
#[test]
fn blueprint_json_schema_exports_key_properties() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
let props = v["properties"].as_object().expect("object schema");
for key in [
"schema_version",
"id",
"flow",
"agents",
"operators",
"metas",
"hints",
"strategy",
"metadata",
"spawner_hints",
"default_agent_kind",
"default_operator_kind",
"default_init_ctx",
"default_agent_ctx",
"default_context_policy",
"projection_placement",
"audits",
"runners",
"default_runner",
] {
assert!(props.contains_key(key), "missing property: {key}");
}
assert_eq!(v["properties"]["schema_version"]["type"], "string");
let dump = v.to_string();
assert!(dump.contains("agent_block"), "AgentKind variants in schema");
assert!(dump.contains("main_ai"), "OperatorKind variants in schema");
assert!(dump.contains("AgentDef"), "AgentDef definition in schema");
}
#[test]
fn agent_profile_worker_binding_roundtrips_when_some() {
let profile = AgentProfile {
worker_binding: Some("mse-worker-coder".to_string()),
..Default::default()
};
let json = serde_json::to_value(&profile).expect("serializes");
assert_eq!(json["worker_binding"], "mse-worker-coder");
let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.worker_binding.as_deref(), Some("mse-worker-coder"));
}
#[test]
fn agent_profile_worker_binding_omitted_when_none() {
let profile = AgentProfile::default();
let json = serde_json::to_value(&profile).expect("serializes");
assert!(
json.as_object().unwrap().get("worker_binding").is_none(),
"worker_binding key must be absent when None: {json}"
);
let back: AgentProfile = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.worker_binding, None);
}
fn minimal_bp(default_init_ctx: Option<Value>) -> Blueprint {
Blueprint {
schema_version: current_schema_version(),
id: "bp-init-ctx-ut".into(),
flow: FlowNode::Seq { children: vec![] },
agents: vec![],
operators: vec![],
metas: vec![],
hints: Default::default(),
strategy: Default::default(),
metadata: Default::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
default_init_ctx,
default_agent_ctx: None,
default_context_policy: None,
projection_placement: None,
audits: vec![],
degradation_policy: None,
runners: vec![],
default_runner: None,
}
}
#[test]
fn blueprint_default_init_ctx_roundtrips_when_some() {
let bp = minimal_bp(Some(serde_json::json!({ "seeded": true })));
let json = serde_json::to_string(&bp).expect("serializes");
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(
back.default_init_ctx,
Some(serde_json::json!({ "seeded": true }))
);
assert_eq!(bp, back);
}
#[test]
fn blueprint_default_init_ctx_omitted_when_none() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object().unwrap().get("default_init_ctx").is_none(),
"default_init_ctx key must be absent when None: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.default_init_ctx, None);
assert_eq!(bp, back);
}
#[test]
fn blueprint_json_schema_exports_default_init_ctx_as_nullable_value() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
assert!(
v["properties"]["default_init_ctx"].is_object(),
"default_init_ctx must appear in the exported schema: {v}"
);
}
#[test]
fn blueprint_default_agent_ctx_and_context_policy_roundtrip_when_some() {
let mut bp = minimal_bp(None);
bp.default_agent_ctx = Some(serde_json::json!({ "org_conventions": "x" }));
bp.default_context_policy = Some(ContextPolicy {
include: Some(vec!["project_root".to_string()]),
exclude: vec!["work_dir".to_string()],
..Default::default()
});
let json = serde_json::to_string(&bp).expect("serializes");
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(bp, back);
assert_eq!(
back.default_agent_ctx,
Some(serde_json::json!({ "org_conventions": "x" }))
);
assert_eq!(
back.default_context_policy,
Some(ContextPolicy {
include: Some(vec!["project_root".to_string()]),
exclude: vec!["work_dir".to_string()],
..Default::default()
})
);
}
#[test]
fn blueprint_default_agent_ctx_and_context_policy_omitted_when_none() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
let obj = json.as_object().unwrap();
assert!(
obj.get("default_agent_ctx").is_none(),
"default_agent_ctx key must be absent when None: {json}"
);
assert!(
obj.get("default_context_policy").is_none(),
"default_context_policy key must be absent when None: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.default_agent_ctx, None);
assert_eq!(back.default_context_policy, None);
assert_eq!(bp, back);
}
#[test]
fn blueprint_json_schema_exports_agent_ctx_and_context_policy() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
assert!(
v["properties"]["default_agent_ctx"].is_object(),
"default_agent_ctx must appear in the exported schema: {v}"
);
assert!(
v["properties"]["default_context_policy"].is_object(),
"default_context_policy must appear in the exported schema: {v}"
);
}
#[test]
fn blueprint_projection_placement_roundtrips_when_some() {
let mut bp = minimal_bp(None);
bp.projection_placement = Some(ProjectionPlacementSpec {
root: Some("project_root".to_string()),
dir_template: Some("custom/{task_id}/out".to_string()),
});
let json = serde_json::to_string(&bp).expect("serializes");
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(bp, back);
assert_eq!(
back.projection_placement,
Some(ProjectionPlacementSpec {
root: Some("project_root".to_string()),
dir_template: Some("custom/{task_id}/out".to_string()),
})
);
}
#[test]
fn blueprint_projection_placement_omitted_when_none() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object()
.unwrap()
.get("projection_placement")
.is_none(),
"projection_placement key must be absent when None: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.projection_placement, None);
assert_eq!(bp, back);
}
#[test]
fn blueprint_json_schema_exports_projection_placement() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
assert!(
v["properties"]["projection_placement"].is_object(),
"projection_placement must appear in the exported schema: {v}"
);
}
#[test]
fn agent_meta_ctx_and_context_policy_roundtrip_when_some() {
let meta = AgentMeta {
ctx: Some(serde_json::json!({ "k": "v" })),
context_policy: Some(ContextPolicy {
include: None,
exclude: vec!["run_id".to_string()],
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_value(&meta).expect("serializes");
let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, meta);
}
#[test]
fn agent_meta_ctx_and_context_policy_omitted_when_none() {
let meta = AgentMeta::default();
let json = serde_json::to_value(&meta).expect("serializes");
let obj = json.as_object().unwrap();
assert!(
obj.get("ctx").is_none(),
"ctx key must be absent when None: {json}"
);
assert!(
obj.get("context_policy").is_none(),
"context_policy key must be absent when None: {json}"
);
}
#[test]
fn agent_meta_json_schema_exports_ctx_context_policy_and_meta_ref() {
let schema = schemars::schema_for!(AgentMeta);
let v = serde_json::to_value(&schema).expect("schema serializes");
let props = v["properties"].as_object().expect("object schema");
for key in [
"description",
"version",
"tags",
"ctx",
"context_policy",
"meta_ref",
"projection_name",
] {
assert!(props.contains_key(key), "missing property: {key}");
}
}
#[test]
fn meta_def_roundtrips_through_json() {
let def = MetaDef {
name: "heavy-scan".to_string(),
ctx: serde_json::json!({ "work_dir": "/x" }),
};
let json = serde_json::to_value(&def).expect("serializes");
let back: MetaDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, def);
}
#[test]
fn blueprint_metas_omitted_when_empty() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object().unwrap().get("metas").is_none(),
"metas key must be absent when empty: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert!(back.metas.is_empty());
assert_eq!(bp, back);
}
#[test]
fn blueprint_metas_roundtrips_when_non_empty() {
let mut bp = minimal_bp(None);
bp.metas = vec![MetaDef {
name: "heavy-scan".to_string(),
ctx: serde_json::json!({ "work_dir": "/x" }),
}];
let json = serde_json::to_string(&bp).expect("serializes");
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(bp, back);
assert_eq!(back.metas.len(), 1);
assert_eq!(back.metas[0].name, "heavy-scan");
}
#[test]
fn blueprint_json_schema_exports_metas() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
assert!(
v["properties"]["metas"].is_object(),
"metas must appear in the exported schema: {v}"
);
let dump = v.to_string();
assert!(dump.contains("MetaDef"), "MetaDef definition in schema");
}
#[test]
fn agent_meta_meta_ref_roundtrips_when_some() {
let meta = AgentMeta {
meta_ref: Some("heavy-scan".to_string()),
..Default::default()
};
let json = serde_json::to_value(&meta).expect("serializes");
assert_eq!(json["meta_ref"], "heavy-scan");
let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, meta);
}
#[test]
fn agent_meta_meta_ref_omitted_when_none() {
let meta = AgentMeta::default();
let json = serde_json::to_value(&meta).expect("serializes");
assert!(
json.as_object().unwrap().get("meta_ref").is_none(),
"meta_ref key must be absent when None: {json}"
);
let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.meta_ref, None);
}
#[test]
fn agent_meta_projection_name_roundtrips_when_some() {
let meta = AgentMeta {
projection_name: Some("plan".to_string()),
..Default::default()
};
let json = serde_json::to_value(&meta).expect("serializes");
assert_eq!(json["projection_name"], "plan");
let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, meta);
}
#[test]
fn agent_meta_projection_name_omitted_when_none() {
let meta = AgentMeta::default();
let json = serde_json::to_value(&meta).expect("serializes");
assert!(
json.as_object().unwrap().get("projection_name").is_none(),
"projection_name key must be absent when None: {json}"
);
let back: AgentMeta = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.projection_name, None);
assert_eq!(back, meta);
}
#[test]
fn agent_meta_rejects_unknown_field_with_projection_name_present() {
let json = serde_json::json!({
"projection_name": "plan",
"not_a_real_field": true
});
let err = serde_json::from_value::<AgentMeta>(json).unwrap_err();
assert!(
err.to_string().contains("not_a_real_field")
|| err.to_string().contains("unknown field"),
"expected an unknown-field rejection, got: {err}"
);
}
#[test]
fn context_policy_default_allows_everything() {
let policy = ContextPolicy::default();
assert!(policy.allows("project_root"));
assert!(policy.allows("anything"));
}
#[test]
fn context_policy_include_only_allows_listed_names() {
let policy = ContextPolicy {
include: Some(vec!["project_root".to_string()]),
exclude: vec![],
..Default::default()
};
assert!(policy.allows("project_root"));
assert!(!policy.allows("work_dir"));
}
#[test]
fn context_policy_exclude_wins_over_include() {
let policy = ContextPolicy {
include: Some(vec!["project_root".to_string()]),
exclude: vec!["project_root".to_string()],
..Default::default()
};
assert!(!policy.allows("project_root"));
}
#[test]
fn context_policy_roundtrips_through_json() {
let policy = ContextPolicy {
include: Some(vec!["a".to_string(), "b".to_string()]),
exclude: vec!["c".to_string()],
..Default::default()
};
let json = serde_json::to_value(&policy).expect("serializes");
let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, policy);
}
#[test]
fn context_policy_default_roundtrips_as_empty_object() {
let policy = ContextPolicy::default();
let json = serde_json::to_value(&policy).expect("serializes");
assert_eq!(
json,
serde_json::json!({
"include": null,
"exclude": [],
"steps": null,
"steps_exclude": [],
})
);
let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, policy);
}
#[test]
fn context_policy_steps_default_allows_every_step() {
let policy = ContextPolicy::default();
assert!(policy.allows_step("planner"));
assert!(policy.allows_step("anything"));
}
#[test]
fn context_policy_steps_include_only_allows_listed_names() {
let policy = ContextPolicy {
steps: Some(vec!["planner".to_string()]),
..Default::default()
};
assert!(policy.allows_step("planner"));
assert!(!policy.allows_step("coder"));
}
#[test]
fn context_policy_steps_empty_list_allows_none() {
let policy = ContextPolicy {
steps: Some(vec![]),
..Default::default()
};
assert!(!policy.allows_step("planner"));
}
#[test]
fn context_policy_steps_exclude_wins_over_steps() {
let policy = ContextPolicy {
steps: Some(vec!["planner".to_string()]),
steps_exclude: vec!["planner".to_string()],
..Default::default()
};
assert!(!policy.allows_step("planner"));
}
#[test]
fn context_policy_steps_roundtrips_through_json() {
let policy = ContextPolicy {
steps: Some(vec!["planner".to_string(), "coder".to_string()]),
steps_exclude: vec!["reviewer".to_string()],
..Default::default()
};
let json = serde_json::to_value(&policy).expect("serializes");
let back: ContextPolicy = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, policy);
}
#[test]
fn blueprint_audits_omitted_when_empty() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object().unwrap().get("audits").is_none(),
"audits key must be absent when empty: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert!(back.audits.is_empty());
assert_eq!(bp, back);
}
#[test]
fn blueprint_audits_roundtrips_when_non_empty() {
let mut bp = minimal_bp(None);
bp.audits = vec![AuditDef {
agent: "auditor".to_string(),
steps: Some(vec!["worker".to_string()]),
mode: AuditMode::Sync,
}];
let json = serde_json::to_string(&bp).expect("serializes");
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(bp, back);
assert_eq!(back.audits.len(), 1);
assert_eq!(back.audits[0].agent, "auditor");
assert_eq!(back.audits[0].mode, AuditMode::Sync);
}
#[test]
fn audit_def_steps_none_and_mode_default_when_omitted() {
let json = serde_json::json!({ "agent": "auditor" });
let def: AuditDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(def.steps, None);
assert_eq!(def.mode, AuditMode::Async);
}
#[test]
fn audit_def_rejects_unknown_field() {
let json = serde_json::json!({ "agent": "auditor", "not_a_real_field": true });
let err = serde_json::from_value::<AuditDef>(json).unwrap_err();
assert!(
err.to_string().contains("not_a_real_field")
|| err.to_string().contains("unknown field"),
"expected an unknown-field rejection, got: {err}"
);
}
#[test]
fn audit_mode_serializes_snake_case() {
assert_eq!(
serde_json::to_value(AuditMode::Async).unwrap(),
serde_json::json!("async")
);
assert_eq!(
serde_json::to_value(AuditMode::Sync).unwrap(),
serde_json::json!("sync")
);
}
#[test]
fn blueprint_json_schema_exports_audits_and_audit_def() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
assert!(
v["properties"]["audits"].is_object(),
"audits must appear in the exported schema: {v}"
);
let dump = v.to_string();
assert!(dump.contains("AuditDef"), "AuditDef definition in schema");
}
#[test]
fn blueprint_without_degradation_policy_deserializes_to_none() {
let json = serde_json::json!({
"schema_version": current_schema_version(),
"id": "no-degradation-policy-ut",
"flow": { "kind": "seq", "children": [] },
});
let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
assert_eq!(bp.degradation_policy, None);
}
#[test]
fn blueprint_degradation_policy_omitted_when_none() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object()
.unwrap()
.get("degradation_policy")
.is_none(),
"degradation_policy key must be absent when None: {json}"
);
}
#[test]
fn blueprint_degradation_policy_warn_and_fail_roundtrip() {
for (label, expected) in [
("warn", DegradationPolicy::Warn),
("fail", DegradationPolicy::Fail),
] {
let mut bp = minimal_bp(None);
bp.degradation_policy = Some(expected);
let json = serde_json::to_string(&bp).expect("serializes");
assert!(json.contains(&format!("\"degradation_policy\":\"{label}\"")));
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back.degradation_policy, Some(expected));
}
}
#[test]
fn degradation_policy_rejects_unknown_variant() {
let json = serde_json::json!({
"schema_version": current_schema_version(),
"id": "degradation-policy-unknown-variant-ut",
"flow": { "kind": "seq", "children": [] },
"degradation_policy": "ignore",
});
let err = serde_json::from_value::<Blueprint>(json).unwrap_err();
assert!(
err.to_string().contains("unknown variant"),
"expected an unknown-variant rejection, got: {err}"
);
}
fn agent_with_runner(
name: &str,
profile: Option<AgentProfile>,
runner: Option<Runner>,
runner_ref: Option<String>,
) -> AgentDef {
AgentDef {
name: name.to_string(),
kind: AgentKind::RustFn,
spec: serde_json::json!({ "fn_id": name }),
profile,
meta: None,
runner,
runner_ref,
verdict: None,
}
}
fn ws_runner(variant: &str, tools: Vec<&str>) -> Runner {
Runner::WsClaudeCode {
variant: variant.to_string(),
tools: tools.into_iter().map(str::to_string).collect(),
}
}
fn agent_block_runner(tools: Vec<&str>) -> Runner {
Runner::AgentBlockInProcess {
tools: tools.into_iter().map(str::to_string).collect(),
}
}
#[test]
fn blueprint_without_runners_or_default_runner_deserializes_to_defaults() {
let json = serde_json::json!({
"schema_version": current_schema_version(),
"id": "no-runners-ut",
"flow": { "kind": "seq", "children": [] },
});
let bp: Blueprint = serde_json::from_value(json).expect("deserializes");
assert!(bp.runners.is_empty());
assert_eq!(bp.default_runner, None);
}
#[test]
fn blueprint_runners_omitted_when_empty() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object().unwrap().get("runners").is_none(),
"runners key must be absent when empty: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert!(back.runners.is_empty());
assert_eq!(bp, back);
}
#[test]
fn blueprint_runners_roundtrips_when_non_empty() {
let mut bp = minimal_bp(None);
bp.runners = vec![RunnerDef {
name: "claude-worker".to_string(),
runner: ws_runner("mse-worker-coder", vec!["Read", "Grep"]),
}];
let json = serde_json::to_string(&bp).expect("serializes");
let back: Blueprint = serde_json::from_str(&json).expect("deserializes");
assert_eq!(bp, back);
assert_eq!(back.runners.len(), 1);
assert_eq!(back.runners[0].name, "claude-worker");
}
#[test]
fn blueprint_default_runner_roundtrips_when_some() {
let mut bp = minimal_bp(None);
bp.default_runner = Some("claude-worker".to_string());
let json = serde_json::to_value(&bp).expect("serializes");
assert_eq!(json["default_runner"], "claude-worker");
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, bp);
}
#[test]
fn blueprint_default_runner_omitted_when_none() {
let bp = minimal_bp(None);
let json = serde_json::to_value(&bp).expect("serializes");
assert!(
json.as_object().unwrap().get("default_runner").is_none(),
"default_runner key must be absent when None: {json}"
);
let back: Blueprint = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, bp);
}
#[test]
fn blueprint_json_schema_exports_runners_and_default_runner() {
let schema = schemars::schema_for!(Blueprint);
let v = serde_json::to_value(&schema).expect("schema serializes");
assert!(
v["properties"]["runners"].is_object(),
"runners must appear in the exported schema: {v}"
);
assert!(
v["properties"]["default_runner"].is_object(),
"default_runner must appear in the exported schema: {v}"
);
let dump = v.to_string();
assert!(dump.contains("RunnerDef"), "RunnerDef definition in schema");
assert!(dump.contains("Runner"), "Runner definition in schema");
}
#[test]
fn agent_def_runner_and_runner_ref_omitted_when_none() {
let agent = agent_with_runner("scout", None, None, None);
let json = serde_json::to_value(&agent).expect("serializes");
let obj = json.as_object().unwrap();
assert!(
obj.get("runner").is_none(),
"runner key must be absent when None: {json}"
);
assert!(
obj.get("runner_ref").is_none(),
"runner_ref key must be absent when None: {json}"
);
let back: AgentDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, agent);
}
#[test]
fn agent_def_runner_inline_roundtrips_when_some() {
let agent = agent_with_runner("coder", None, Some(agent_block_runner(vec!["Bash"])), None);
let json = serde_json::to_string(&agent).expect("serializes");
let back: AgentDef = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, agent);
}
#[test]
fn agent_def_runner_ref_roundtrips_when_some() {
let agent = agent_with_runner("coder", None, None, Some("claude-worker".to_string()));
let json = serde_json::to_value(&agent).expect("serializes");
assert_eq!(json["runner_ref"], "claude-worker");
let back: AgentDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, agent);
}
#[test]
fn agent_def_json_schema_exports_runner_and_runner_ref() {
let schema = schemars::schema_for!(AgentDef);
let v = serde_json::to_value(&schema).expect("schema serializes");
let props = v["properties"].as_object().expect("object schema");
for key in ["runner", "runner_ref"] {
assert!(props.contains_key(key), "missing property: {key}");
}
}
#[test]
fn runner_ws_claude_code_roundtrips_through_json_and_tags_backend() {
let runner = ws_runner("mse-worker-coder", vec!["Read", "Grep"]);
let json = serde_json::to_value(&runner).expect("serializes");
assert_eq!(json["backend"], "ws_claude_code");
assert_eq!(json["variant"], "mse-worker-coder");
assert_eq!(json["tools"], serde_json::json!(["Read", "Grep"]));
let back: Runner = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, runner);
}
#[test]
fn runner_agent_block_in_process_roundtrips_through_json_and_tags_backend() {
let runner = agent_block_runner(vec!["Bash"]);
let json = serde_json::to_value(&runner).expect("serializes");
assert_eq!(json["backend"], "agent_block_in_process");
assert_eq!(json["tools"], serde_json::json!(["Bash"]));
let back: Runner = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, runner);
}
#[test]
fn runner_tools_omitted_when_empty() {
let runner = ws_runner("mse-worker-coder", vec![]);
let json = serde_json::to_value(&runner).expect("serializes");
assert!(
json.as_object().unwrap().get("tools").is_none(),
"tools key must be absent when empty: {json}"
);
let back: Runner = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, runner);
}
#[test]
fn runner_rejects_unknown_field() {
let json = serde_json::json!({
"backend": "ws_claude_code",
"variant": "x",
"not_a_real_field": true,
});
let err = serde_json::from_value::<Runner>(json).unwrap_err();
assert!(
err.to_string().contains("not_a_real_field")
|| err.to_string().contains("unknown field"),
"expected an unknown-field rejection, got: {err}"
);
}
#[test]
fn runner_def_roundtrips_through_json() {
let def = RunnerDef {
name: "claude-worker".to_string(),
runner: ws_runner("mse-worker-coder", vec!["Read"]),
};
let json = serde_json::to_value(&def).expect("serializes");
let back: RunnerDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, def);
}
#[test]
fn worker_model_roundtrips_through_json() {
let model = WorkerModel {
runner: agent_block_runner(vec!["Bash"]),
agent: agent_with_runner("coder", None, None, None),
};
let json = serde_json::to_value(&model).expect("serializes");
let back: WorkerModel = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, model);
}
#[test]
fn resolve_runner_inline_wins_over_everything() {
let inline = agent_block_runner(vec!["Bash"]);
let profile = AgentProfile {
worker_binding: Some("legacy-variant".to_string()),
tools: vec!["Read".to_string()],
..Default::default()
};
let agent = agent_with_runner(
"coder",
Some(profile),
Some(inline.clone()),
Some("registry-entry".to_string()),
);
let mut bp = minimal_bp(None);
bp.default_runner = Some("registry-entry".to_string());
bp.runners = vec![RunnerDef {
name: "registry-entry".to_string(),
runner: ws_runner("other-variant", vec![]),
}];
bp.agents = vec![agent.clone()];
let resolved = resolve_runner(&bp, &agent).expect("resolves");
assert_eq!(resolved, Some(inline));
}
#[test]
fn resolve_runner_runner_ref_wins_over_legacy_fallback() {
let profile = AgentProfile {
worker_binding: Some("legacy-variant".to_string()),
tools: vec!["Read".to_string()],
..Default::default()
};
let registry_runner = ws_runner("registry-variant", vec!["Grep"]);
let agent = agent_with_runner(
"coder",
Some(profile),
None,
Some("registry-entry".to_string()),
);
let mut bp = minimal_bp(None);
bp.runners = vec![RunnerDef {
name: "registry-entry".to_string(),
runner: registry_runner.clone(),
}];
bp.agents = vec![agent.clone()];
let resolved = resolve_runner(&bp, &agent).expect("resolves");
assert_eq!(resolved, Some(registry_runner));
}
#[test]
fn resolve_runner_legacy_fallback_wins_over_default_runner() {
let profile = AgentProfile {
worker_binding: Some("legacy-variant".to_string()),
tools: vec!["Read".to_string(), "Grep".to_string()],
..Default::default()
};
let agent = agent_with_runner("coder", Some(profile), None, None);
let mut bp = minimal_bp(None);
bp.default_runner = Some("registry-entry".to_string());
bp.runners = vec![RunnerDef {
name: "registry-entry".to_string(),
runner: agent_block_runner(vec!["Bash"]),
}];
bp.agents = vec![agent.clone()];
let resolved = resolve_runner(&bp, &agent).expect("resolves");
assert_eq!(
resolved,
Some(ws_runner("legacy-variant", vec!["Read", "Grep"]))
);
}
#[test]
fn resolve_runner_default_runner_alone_when_no_agent_level_declaration() {
let agent = agent_with_runner("coder", None, None, None);
let mut bp = minimal_bp(None);
bp.default_runner = Some("registry-entry".to_string());
bp.runners = vec![RunnerDef {
name: "registry-entry".to_string(),
runner: agent_block_runner(vec!["Bash"]),
}];
bp.agents = vec![agent.clone()];
let resolved = resolve_runner(&bp, &agent).expect("resolves");
assert_eq!(resolved, Some(agent_block_runner(vec!["Bash"])));
}
#[test]
fn resolve_runner_none_when_nothing_declared_through_any_tier() {
let agent = agent_with_runner("coder", None, None, None);
let bp = minimal_bp(None);
let resolved = resolve_runner(&bp, &agent).expect("resolves");
assert_eq!(resolved, None);
}
#[test]
fn resolve_runner_unknown_runner_ref_errs() {
let agent = agent_with_runner("coder", None, None, Some("no-such-entry".to_string()));
let mut bp = minimal_bp(None);
bp.runners = vec![RunnerDef {
name: "registry-entry".to_string(),
runner: agent_block_runner(vec![]),
}];
bp.agents = vec![agent.clone()];
let err = resolve_runner(&bp, &agent).expect_err("unresolved runner_ref");
assert_eq!(
err,
RunnerResolveError::UnknownRunnerRef {
agent: "coder".to_string(),
ref_name: "no-such-entry".to_string(),
available: vec!["registry-entry".to_string()],
}
);
}
#[test]
fn resolve_runner_unknown_default_runner_errs() {
let agent = agent_with_runner("coder", None, None, None);
let mut bp = minimal_bp(None);
bp.default_runner = Some("no-such-entry".to_string());
bp.runners = vec![RunnerDef {
name: "registry-entry".to_string(),
runner: agent_block_runner(vec![]),
}];
bp.agents = vec![agent.clone()];
let err = resolve_runner(&bp, &agent).expect_err("unresolved default_runner");
assert_eq!(
err,
RunnerResolveError::UnknownDefaultRunner {
ref_name: "no-such-entry".to_string(),
available: vec!["registry-entry".to_string()],
}
);
}
#[test]
fn verdict_contract_roundtrips_body_channel() {
let json = serde_json::json!({"channel": "body", "values": ["PASS", "BLOCKED"]});
let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
assert_eq!(contract.channel, VerdictChannel::Body);
assert_eq!(
contract.values,
vec!["PASS".to_string(), "BLOCKED".to_string()]
);
assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
}
#[test]
fn verdict_contract_roundtrips_part_channel() {
let json = serde_json::json!({"channel": "part", "values": ["ALLOW"]});
let contract: VerdictContract = serde_json::from_value(json.clone()).expect("deserializes");
assert_eq!(contract.channel, VerdictChannel::Part);
assert_eq!(serde_json::to_value(&contract).expect("serializes"), json);
}
#[test]
fn agent_def_verdict_omitted_when_none() {
let agent = agent_with_runner("gate", None, None, None);
let json = serde_json::to_value(&agent).expect("serializes");
assert!(
json.as_object().unwrap().get("verdict").is_none(),
"verdict key must be absent when None: {json}"
);
let back: AgentDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.verdict, None);
}
#[test]
fn agent_def_verdict_roundtrips_when_some() {
let mut agent = agent_with_runner("gate", None, None, None);
agent.verdict = Some(VerdictContract {
channel: VerdictChannel::Body,
values: vec!["PASS".to_string(), "BLOCKED".to_string()],
});
let json = serde_json::to_value(&agent).expect("serializes");
let back: AgentDef = serde_json::from_value(json).expect("deserializes");
assert_eq!(back.verdict, agent.verdict);
}
#[test]
fn existing_verdict_loop_sample_deserializes_with_verdict_omitted() {
const SAMPLE: &str =
include_str!("../../mlua-swarm-cli/src/mcp/resources/samples/02-verdict-loop.json");
let bp: Blueprint = serde_json::from_str(SAMPLE).expect("sample deserializes");
assert_eq!(bp.agents.len(), 6);
assert!(
bp.agents.iter().all(|a| a.verdict.is_none()),
"no agent in the sample declares a verdict contract"
);
}
}