use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamConfig {
#[serde(alias = "name")]
pub team_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lead_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lead_session_id: Option<String>,
#[serde(default)]
pub members: Vec<MemberUnion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MemberUnion {
Teammate(TeammateMember),
Lead(LeadMember),
}
impl MemberUnion {
pub fn name(&self) -> &str {
match self {
MemberUnion::Lead(m) => &m.name,
MemberUnion::Teammate(m) => &m.name,
}
}
pub fn agent_id(&self) -> &str {
match self {
MemberUnion::Lead(m) => &m.agent_id,
MemberUnion::Teammate(m) => &m.agent_id,
}
}
pub fn agent_type(&self) -> &str {
match self {
MemberUnion::Lead(m) => &m.agent_type,
MemberUnion::Teammate(m) => &m.agent_type,
}
}
pub fn model(&self) -> Option<&str> {
match self {
MemberUnion::Lead(m) => m.model.as_deref(),
MemberUnion::Teammate(m) => m.model.as_deref(),
}
}
pub fn cwd(&self) -> Option<&str> {
match self {
MemberUnion::Lead(m) => m.cwd.as_deref(),
MemberUnion::Teammate(m) => m.cwd.as_deref(),
}
}
pub fn is_teammate(&self) -> bool {
matches!(self, MemberUnion::Teammate(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeadMember {
pub name: String,
pub agent_id: String,
pub agent_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub joined_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmux_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscriptions: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TeammateMember {
pub name: String,
pub agent_id: String,
pub agent_type: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_mode_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub joined_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmux_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscriptions: Option<Vec<serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_type: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip_team_config() {
let config = TeamConfig {
team_name: "test-team".into(),
description: Some("A test team".into()),
created_at: None,
lead_agent_id: None,
lead_session_id: None,
members: vec![
MemberUnion::Lead(LeadMember {
name: "lead".into(),
agent_id: "lead-001".into(),
agent_type: "team-lead".into(),
model: None,
joined_at: None,
tmux_pane_id: None,
cwd: None,
subscriptions: None,
}),
MemberUnion::Teammate(TeammateMember {
name: "worker-1".into(),
agent_id: "w1-001".into(),
agent_type: "general-purpose".into(),
prompt: "You are a helpful coding assistant.".into(),
model: None,
color: None,
plan_mode_required: None,
joined_at: None,
tmux_pane_id: None,
cwd: None,
subscriptions: None,
backend_type: None,
}),
],
};
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: TeamConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.team_name, "test-team");
assert_eq!(parsed.members.len(), 2);
assert!(!parsed.members[0].is_teammate());
assert!(parsed.members[1].is_teammate());
}
#[test]
fn deserialize_simplified_format() {
let json = r#"{
"teamName": "my-project",
"description": "Working on feature X",
"members": [
{
"name": "team-lead",
"agentId": "abc-123",
"agentType": "researcher"
},
{
"name": "coder",
"agentId": "def-456",
"agentType": "general-purpose",
"prompt": "Implement the feature"
}
]
}"#;
let config: TeamConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.team_name, "my-project");
assert!(config.created_at.is_none());
assert!(config.lead_agent_id.is_none());
assert_eq!(config.members[1].name(), "coder");
assert!(config.members[1].is_teammate());
}
#[test]
fn deserialize_native_format_with_name_key() {
let json = r#"{
"name": "proxy-analysis",
"description": "Three @teammates analyzing agent-teams",
"createdAt": 1770836587799,
"leadAgentId": "team-lead@proxy-analysis",
"leadSessionId": "a2485d01-5a05-4089-9dd4-32a061a1a1c8",
"members": [
{
"agentId": "team-lead@proxy-analysis",
"name": "team-lead",
"agentType": "team-lead",
"model": "claude-opus-4-6",
"joinedAt": 1770836587799,
"tmuxPaneId": "",
"cwd": "/Users/test/project",
"subscriptions": []
},
{
"agentId": "cc-writer@proxy-analysis",
"name": "cc-writer",
"agentType": "general-purpose",
"model": "claude-opus-4-6",
"prompt": "You are @cc-writer...",
"color": "blue",
"planModeRequired": false,
"joinedAt": 1770836740207,
"tmuxPaneId": "in-process",
"cwd": "/Users/test/project",
"subscriptions": [],
"backendType": "in-process"
}
]
}"#;
let config: TeamConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.team_name, "proxy-analysis");
assert_eq!(config.created_at, Some(1770836587799));
assert_eq!(config.lead_agent_id.as_deref(), Some("team-lead@proxy-analysis"));
assert_eq!(config.lead_session_id.as_deref(), Some("a2485d01-5a05-4089-9dd4-32a061a1a1c8"));
let lead = &config.members[0];
assert!(!lead.is_teammate());
assert_eq!(lead.name(), "team-lead");
assert_eq!(lead.model(), Some("claude-opus-4-6"));
assert_eq!(lead.cwd(), Some("/Users/test/project"));
let teammate = &config.members[1];
assert!(teammate.is_teammate());
assert_eq!(teammate.name(), "cc-writer");
assert_eq!(teammate.model(), Some("claude-opus-4-6"));
if let MemberUnion::Teammate(tm) = teammate {
assert_eq!(tm.color.as_deref(), Some("blue"));
assert_eq!(tm.plan_mode_required, Some(false));
assert_eq!(tm.backend_type.as_deref(), Some("in-process"));
assert_eq!(tm.tmux_pane_id.as_deref(), Some("in-process"));
} else {
panic!("Expected Teammate variant");
}
}
#[test]
fn accessors_work_for_both_variants() {
let lead = MemberUnion::Lead(LeadMember {
name: "lead".into(),
agent_id: "lead@team".into(),
agent_type: "team-lead".into(),
model: Some("claude-opus-4-6".into()),
joined_at: Some(1000),
tmux_pane_id: Some("".into()),
cwd: Some("/tmp".into()),
subscriptions: None,
});
let teammate = MemberUnion::Teammate(TeammateMember {
name: "worker".into(),
agent_id: "worker@team".into(),
agent_type: "general-purpose".into(),
prompt: "Do work".into(),
model: Some("claude-sonnet-4-5-20250929".into()),
color: Some("green".into()),
plan_mode_required: Some(true),
joined_at: Some(2000),
tmux_pane_id: Some("in-process".into()),
cwd: Some("/home".into()),
subscriptions: Some(vec![]),
backend_type: Some("in-process".into()),
});
assert_eq!(lead.name(), "lead");
assert_eq!(lead.model(), Some("claude-opus-4-6"));
assert_eq!(lead.cwd(), Some("/tmp"));
assert!(!lead.is_teammate());
assert_eq!(teammate.name(), "worker");
assert_eq!(teammate.model(), Some("claude-sonnet-4-5-20250929"));
assert_eq!(teammate.cwd(), Some("/home"));
assert!(teammate.is_teammate());
}
}