1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct TeamConfig {
17 #[serde(alias = "name")]
20 pub team_name: String,
21
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub description: Option<String>,
25
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub created_at: Option<u64>,
29
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub lead_agent_id: Option<String>,
33
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub lead_session_id: Option<String>,
37
38 #[serde(default)]
40 pub members: Vec<MemberUnion>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum MemberUnion {
50 Teammate(TeammateMember),
52 Lead(LeadMember),
54}
55
56impl MemberUnion {
57 pub fn name(&self) -> &str {
59 match self {
60 MemberUnion::Lead(m) => &m.name,
61 MemberUnion::Teammate(m) => &m.name,
62 }
63 }
64
65 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 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 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 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 pub fn is_teammate(&self) -> bool {
99 matches!(self, MemberUnion::Teammate(_))
100 }
101}
102
103#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub model: Option<String>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub joined_at: Option<u64>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub tmux_pane_id: Option<String>,
125
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub cwd: Option<String>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub subscriptions: Option<Vec<serde_json::Value>>,
133}
134
135#[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 pub prompt: String,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub model: Option<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub color: Option<String>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub plan_mode_required: Option<bool>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub joined_at: Option<u64>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub tmux_pane_id: Option<String>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub cwd: Option<String>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub subscriptions: Option<Vec<serde_json::Value>>,
176
177 #[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 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 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 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 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 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}