Skip to main content

agent_teams/models/
team.rs

1//! Team configuration models compatible with Claude Code's
2//! `~/.claude/teams/{team-name}/config.json` format.
3//!
4//! Supports both the full native format (uses `"name"` key, includes
5//! `createdAt`, `leadAgentId`, `leadSessionId`, and rich member fields)
6//! and the simplified format (uses `"teamName"` key, minimal members).
7
8use serde::{Deserialize, Serialize};
9
10/// Top-level team configuration.
11///
12/// Accepts both `"teamName"` (our canonical key via `rename_all = "camelCase"`)
13/// and `"name"` (Claude Code's native key) during deserialization.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct TeamConfig {
17    /// Human-readable team name.
18    /// Serializes as `"teamName"`, deserializes from `"teamName"` or `"name"`.
19    #[serde(alias = "name")]
20    pub team_name: String,
21
22    /// Optional description/purpose.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub description: Option<String>,
25
26    /// Unix timestamp in milliseconds when the team was created.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub created_at: Option<u64>,
29
30    /// Fully qualified lead agent ID (e.g. `"team-lead@my-team"`).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub lead_agent_id: Option<String>,
33
34    /// Session UUID of the lead agent.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub lead_session_id: Option<String>,
37
38    /// Team members (lead + teammates).
39    #[serde(default)]
40    pub members: Vec<MemberUnion>,
41}
42
43/// A team member — either a lead or a teammate.
44///
45/// Uses `#[serde(untagged)]` with `TeammateMember` first so that the
46/// presence of `prompt` disambiguates teammates from the lead.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum MemberUnion {
50    /// A teammate (has `prompt` field).
51    Teammate(TeammateMember),
52    /// The team lead (no `prompt` field).
53    Lead(LeadMember),
54}
55
56impl MemberUnion {
57    /// Get the member name regardless of variant.
58    pub fn name(&self) -> &str {
59        match self {
60            MemberUnion::Lead(m) => &m.name,
61            MemberUnion::Teammate(m) => &m.name,
62        }
63    }
64
65    /// Get the agent ID regardless of variant.
66    pub fn agent_id(&self) -> &str {
67        match self {
68            MemberUnion::Lead(m) => &m.agent_id,
69            MemberUnion::Teammate(m) => &m.agent_id,
70        }
71    }
72
73    /// Get the agent type regardless of variant.
74    pub fn agent_type(&self) -> &str {
75        match self {
76            MemberUnion::Lead(m) => &m.agent_type,
77            MemberUnion::Teammate(m) => &m.agent_type,
78        }
79    }
80
81    /// Get the model, if set.
82    pub fn model(&self) -> Option<&str> {
83        match self {
84            MemberUnion::Lead(m) => m.model.as_deref(),
85            MemberUnion::Teammate(m) => m.model.as_deref(),
86        }
87    }
88
89    /// Get the working directory, if set.
90    pub fn cwd(&self) -> Option<&str> {
91        match self {
92            MemberUnion::Lead(m) => m.cwd.as_deref(),
93            MemberUnion::Teammate(m) => m.cwd.as_deref(),
94        }
95    }
96
97    /// Returns `true` if this is a teammate (not a lead).
98    pub fn is_teammate(&self) -> bool {
99        matches!(self, MemberUnion::Teammate(_))
100    }
101}
102
103/// Team lead member — no `prompt` field.
104///
105/// Includes optional fields from Claude Code's native format that are
106/// absent in the simplified format.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct LeadMember {
110    pub name: String,
111    pub agent_id: String,
112    pub agent_type: String,
113
114    /// Model identifier (e.g. `"claude-opus-4-6"`).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub model: Option<String>,
117
118    /// Unix timestamp (ms) when the member joined the team.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub joined_at: Option<u64>,
121
122    /// Tmux pane ID, or `""` for lead, `"in-process"` for in-process agents.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub tmux_pane_id: Option<String>,
125
126    /// Working directory path.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub cwd: Option<String>,
129
130    /// Event subscriptions (currently unused, always `[]`).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub subscriptions: Option<Vec<serde_json::Value>>,
133}
134
135/// Teammate member — distinguished by having a `prompt` field.
136///
137/// Contains all native Claude Code fields including those only present
138/// on teammates (`color`, `planModeRequired`, `backendType`).
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct TeammateMember {
142    pub name: String,
143    pub agent_id: String,
144    pub agent_type: String,
145
146    /// The initial prompt / system instruction for this teammate.
147    pub prompt: String,
148
149    /// Model identifier (e.g. `"claude-opus-4-6"`).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub model: Option<String>,
152
153    /// UI color hint: `"blue"`, `"green"`, `"yellow"`, etc.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub color: Option<String>,
156
157    /// Whether this agent must submit plans for lead approval before executing.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub plan_mode_required: Option<bool>,
160
161    /// Unix timestamp (ms) when the member joined the team.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub joined_at: Option<u64>,
164
165    /// Tmux pane ID or `"in-process"` for in-process agents.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub tmux_pane_id: Option<String>,
168
169    /// Working directory path.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub cwd: Option<String>,
172
173    /// Event subscriptions (currently unused, always `[]`).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub subscriptions: Option<Vec<serde_json::Value>>,
176
177    /// Backend execution type: `"in-process"`, etc.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub backend_type: Option<String>,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn serde_round_trip_team_config() {
188        let config = TeamConfig {
189            team_name: "test-team".into(),
190            description: Some("A test team".into()),
191            created_at: None,
192            lead_agent_id: None,
193            lead_session_id: None,
194            members: vec![
195                MemberUnion::Lead(LeadMember {
196                    name: "lead".into(),
197                    agent_id: "lead-001".into(),
198                    agent_type: "team-lead".into(),
199                    model: None,
200                    joined_at: None,
201                    tmux_pane_id: None,
202                    cwd: None,
203                    subscriptions: None,
204                }),
205                MemberUnion::Teammate(TeammateMember {
206                    name: "worker-1".into(),
207                    agent_id: "w1-001".into(),
208                    agent_type: "general-purpose".into(),
209                    prompt: "You are a helpful coding assistant.".into(),
210                    model: None,
211                    color: None,
212                    plan_mode_required: None,
213                    joined_at: None,
214                    tmux_pane_id: None,
215                    cwd: None,
216                    subscriptions: None,
217                    backend_type: None,
218                }),
219            ],
220        };
221
222        let json = serde_json::to_string_pretty(&config).unwrap();
223        let parsed: TeamConfig = serde_json::from_str(&json).unwrap();
224
225        assert_eq!(parsed.team_name, "test-team");
226        assert_eq!(parsed.members.len(), 2);
227        assert!(!parsed.members[0].is_teammate());
228        assert!(parsed.members[1].is_teammate());
229    }
230
231    #[test]
232    fn deserialize_simplified_format() {
233        // The simplified format uses "teamName" — this is our canonical key.
234        let json = r#"{
235            "teamName": "my-project",
236            "description": "Working on feature X",
237            "members": [
238                {
239                    "name": "team-lead",
240                    "agentId": "abc-123",
241                    "agentType": "researcher"
242                },
243                {
244                    "name": "coder",
245                    "agentId": "def-456",
246                    "agentType": "general-purpose",
247                    "prompt": "Implement the feature"
248                }
249            ]
250        }"#;
251
252        let config: TeamConfig = serde_json::from_str(json).unwrap();
253        assert_eq!(config.team_name, "my-project");
254        assert!(config.created_at.is_none());
255        assert!(config.lead_agent_id.is_none());
256        assert_eq!(config.members[1].name(), "coder");
257        assert!(config.members[1].is_teammate());
258    }
259
260    #[test]
261    fn deserialize_native_format_with_name_key() {
262        // Real Claude Code native format uses "name" at the top level.
263        let json = r#"{
264            "name": "proxy-analysis",
265            "description": "Three @teammates analyzing agent-teams",
266            "createdAt": 1770836587799,
267            "leadAgentId": "team-lead@proxy-analysis",
268            "leadSessionId": "a2485d01-5a05-4089-9dd4-32a061a1a1c8",
269            "members": [
270                {
271                    "agentId": "team-lead@proxy-analysis",
272                    "name": "team-lead",
273                    "agentType": "team-lead",
274                    "model": "claude-opus-4-6",
275                    "joinedAt": 1770836587799,
276                    "tmuxPaneId": "",
277                    "cwd": "/Users/test/project",
278                    "subscriptions": []
279                },
280                {
281                    "agentId": "cc-writer@proxy-analysis",
282                    "name": "cc-writer",
283                    "agentType": "general-purpose",
284                    "model": "claude-opus-4-6",
285                    "prompt": "You are @cc-writer...",
286                    "color": "blue",
287                    "planModeRequired": false,
288                    "joinedAt": 1770836740207,
289                    "tmuxPaneId": "in-process",
290                    "cwd": "/Users/test/project",
291                    "subscriptions": [],
292                    "backendType": "in-process"
293                }
294            ]
295        }"#;
296
297        let config: TeamConfig = serde_json::from_str(json).unwrap();
298
299        // Top-level fields
300        assert_eq!(config.team_name, "proxy-analysis");
301        assert_eq!(config.created_at, Some(1770836587799));
302        assert_eq!(config.lead_agent_id.as_deref(), Some("team-lead@proxy-analysis"));
303        assert_eq!(config.lead_session_id.as_deref(), Some("a2485d01-5a05-4089-9dd4-32a061a1a1c8"));
304
305        // Lead member
306        let lead = &config.members[0];
307        assert!(!lead.is_teammate());
308        assert_eq!(lead.name(), "team-lead");
309        assert_eq!(lead.model(), Some("claude-opus-4-6"));
310        assert_eq!(lead.cwd(), Some("/Users/test/project"));
311
312        // Teammate member
313        let teammate = &config.members[1];
314        assert!(teammate.is_teammate());
315        assert_eq!(teammate.name(), "cc-writer");
316        assert_eq!(teammate.model(), Some("claude-opus-4-6"));
317        if let MemberUnion::Teammate(tm) = teammate {
318            assert_eq!(tm.color.as_deref(), Some("blue"));
319            assert_eq!(tm.plan_mode_required, Some(false));
320            assert_eq!(tm.backend_type.as_deref(), Some("in-process"));
321            assert_eq!(tm.tmux_pane_id.as_deref(), Some("in-process"));
322        } else {
323            panic!("Expected Teammate variant");
324        }
325    }
326
327    #[test]
328    fn accessors_work_for_both_variants() {
329        let lead = MemberUnion::Lead(LeadMember {
330            name: "lead".into(),
331            agent_id: "lead@team".into(),
332            agent_type: "team-lead".into(),
333            model: Some("claude-opus-4-6".into()),
334            joined_at: Some(1000),
335            tmux_pane_id: Some("".into()),
336            cwd: Some("/tmp".into()),
337            subscriptions: None,
338        });
339
340        let teammate = MemberUnion::Teammate(TeammateMember {
341            name: "worker".into(),
342            agent_id: "worker@team".into(),
343            agent_type: "general-purpose".into(),
344            prompt: "Do work".into(),
345            model: Some("claude-sonnet-4-5-20250929".into()),
346            color: Some("green".into()),
347            plan_mode_required: Some(true),
348            joined_at: Some(2000),
349            tmux_pane_id: Some("in-process".into()),
350            cwd: Some("/home".into()),
351            subscriptions: Some(vec![]),
352            backend_type: Some("in-process".into()),
353        });
354
355        assert_eq!(lead.name(), "lead");
356        assert_eq!(lead.model(), Some("claude-opus-4-6"));
357        assert_eq!(lead.cwd(), Some("/tmp"));
358        assert!(!lead.is_teammate());
359
360        assert_eq!(teammate.name(), "worker");
361        assert_eq!(teammate.model(), Some("claude-sonnet-4-5-20250929"));
362        assert_eq!(teammate.cwd(), Some("/home"));
363        assert!(teammate.is_teammate());
364    }
365}