use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubagentProfile {
pub id: String,
pub display_name: String,
#[serde(default)]
pub description: String,
pub system_prompt: String,
#[serde(default)]
pub tools: ToolPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_hint: Option<ModelHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_responsibility: Option<String>,
#[serde(default)]
pub ui: UiHint,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum ToolPolicy {
#[default]
Inherit,
Allowlist {
#[serde(default)]
allow: Vec<String>,
},
Denylist {
#[serde(default)]
deny: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ModelHint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct UiHint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_policy_default_is_inherit() {
assert_eq!(ToolPolicy::default(), ToolPolicy::Inherit);
}
#[test]
fn deserializes_inherit_policy() {
let json = r#"{"mode":"inherit"}"#;
let policy: ToolPolicy = serde_json::from_str(json).unwrap();
assert_eq!(policy, ToolPolicy::Inherit);
}
#[test]
fn deserializes_allowlist_policy() {
let json = r#"{"mode":"allowlist","allow":["Read","Grep"]}"#;
let policy: ToolPolicy = serde_json::from_str(json).unwrap();
match policy {
ToolPolicy::Allowlist { allow } => assert_eq!(allow, vec!["Read", "Grep"]),
other => panic!("expected allowlist, got {other:?}"),
}
}
#[test]
fn deserializes_denylist_policy() {
let json = r#"{"mode":"denylist","deny":["Edit","Write"]}"#;
let policy: ToolPolicy = serde_json::from_str(json).unwrap();
match policy {
ToolPolicy::Denylist { deny } => assert_eq!(deny, vec!["Edit", "Write"]),
other => panic!("expected denylist, got {other:?}"),
}
}
#[test]
fn deserializes_full_profile() {
let json = r#"{
"id": "researcher",
"display_name": "Researcher",
"description": "Read-only investigation specialist",
"system_prompt": "You are a researcher.",
"tools": { "mode": "allowlist", "allow": ["Read", "Grep", "Glob"] },
"model_hint": { "tier": "chat" },
"default_responsibility": "Investigate the assigned topic",
"ui": { "icon": "🔬", "color": "blue" }
}"#;
let profile: SubagentProfile = serde_json::from_str(json).unwrap();
assert_eq!(profile.id, "researcher");
assert_eq!(profile.display_name, "Researcher");
assert_eq!(profile.ui.icon.as_deref(), Some("🔬"));
assert_eq!(profile.model_hint.unwrap().tier.as_deref(), Some("chat"));
}
#[test]
fn omits_optional_fields_when_serializing() {
let profile = SubagentProfile {
id: "minimal".into(),
display_name: "Minimal".into(),
description: String::new(),
system_prompt: "hi".into(),
tools: ToolPolicy::Inherit,
model_hint: None,
default_responsibility: None,
ui: UiHint::default(),
};
let v = serde_json::to_value(&profile).unwrap();
assert!(v.get("model_hint").is_none());
assert!(v.get("default_responsibility").is_none());
}
}