1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5use crate::policy_mode::PolicyMode;
6
7pub type TeamId = String;
8pub type TeamMemberId = String;
9pub type TeamTaskId = String;
10pub type TeamMessageId = String;
11
12#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
13#[serde(rename_all = "snake_case")]
14pub enum AgentTeamDisplayMode {
15 #[default]
16 Auto,
17 #[serde(alias = "in-process", alias = "inprocess")]
18 InProcess,
19 Tmux,
20 #[serde(alias = "iterm")]
21 Iterm2,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "camelCase")]
26pub struct AgentTeamConfig {
27 pub enabled: bool,
28 pub display_mode: AgentTeamDisplayMode,
29 pub default_teammate_model: Option<String>,
30 pub require_plan_approval: bool,
31 pub max_teammates: usize,
32 pub split_panes: AgentTeamSplitPaneConfig,
33}
34
35impl Default for AgentTeamConfig {
36 fn default() -> Self {
37 Self {
38 enabled: false,
39 display_mode: AgentTeamDisplayMode::Auto,
40 default_teammate_model: Some("lead".to_string()),
41 require_plan_approval: false,
42 max_teammates: 5,
43 split_panes: AgentTeamSplitPaneConfig::default(),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "camelCase")]
50pub struct AgentTeamSplitPaneConfig {
51 pub reuse_existing_tmux_session: bool,
52 pub tmux_command: String,
53 pub iterm2_command: String,
54}
55
56impl Default for AgentTeamSplitPaneConfig {
57 fn default() -> Self {
58 Self {
59 reuse_existing_tmux_session: true,
60 tmux_command: "tmux".to_string(),
61 iterm2_command: "it2".to_string(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
67#[serde(rename_all = "snake_case")]
68pub enum TeamMemberRole {
69 Lead,
70 Teammate,
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
74#[serde(rename_all = "snake_case")]
75pub enum TeamMemberStatus {
76 Idle,
77 Running,
78 Blocked,
79 Completed,
80 Failed,
81 Interrupted,
82 Closed,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(rename_all = "camelCase")]
87pub struct TeamMemberDescriptor {
88 pub id: TeamMemberId,
89 pub role: TeamMemberRole,
90 pub name: String,
91 #[serde(default)]
92 pub task_name: Option<String>,
93 #[serde(default)]
94 pub agent_path: Option<String>,
95 pub thread_id: ThreadId,
96 #[serde(default)]
97 pub parent_thread_id: Option<ThreadId>,
98 pub current_turn_id: Option<TurnId>,
99 pub model_provider: Option<String>,
100 pub model: Option<String>,
101 pub policy_mode: PolicyMode,
102 pub status: TeamMemberStatus,
103 #[serde(default)]
104 pub final_message: Option<String>,
105 #[serde(default)]
106 pub terminal_error: Option<String>,
107 pub pane_id: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[serde(rename_all = "camelCase")]
112pub struct TeamMailboxMessage {
113 pub id: TeamMessageId,
114 pub team_id: TeamId,
115 pub from_member_id: Option<TeamMemberId>,
116 pub to_member_id: TeamMemberId,
117 #[serde(default)]
118 pub kind: TeamMailboxMessageKind,
119 pub text: String,
120 #[serde(default)]
121 pub delivered: bool,
122 #[serde(with = "time::serde::rfc3339")]
123 pub timestamp: OffsetDateTime,
124}
125
126#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
127#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
128pub enum TeamMailboxMessageKind {
129 #[default]
130 Message,
131 NewTask,
132 FinalAnswer,
133}
134
135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
136#[serde(rename_all = "snake_case")]
137pub enum TeamTaskStatus {
138 Pending,
139 InProgress,
140 Completed,
141 Failed,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "camelCase")]
146pub struct TeamTaskDescriptor {
147 pub id: TeamTaskId,
148 pub title: String,
149 pub status: TeamTaskStatus,
150 pub assignee_member_id: Option<TeamMemberId>,
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn team_display_mode_accepts_snake_case_and_hyphenated_values() {
159 let snake: AgentTeamDisplayMode = serde_json::from_str("\"in_process\"").unwrap();
160 let hyphen: AgentTeamDisplayMode = serde_json::from_str("\"in-process\"").unwrap();
161
162 assert_eq!(snake, AgentTeamDisplayMode::InProcess);
163 assert_eq!(hyphen, AgentTeamDisplayMode::InProcess);
164 assert_eq!(
165 serde_json::to_string(&AgentTeamDisplayMode::Tmux).unwrap(),
166 "\"tmux\""
167 );
168 }
169
170 #[test]
171 fn agent_team_config_defaults_to_disabled_auto() {
172 let config = AgentTeamConfig::default();
173
174 assert!(!config.enabled);
175 assert_eq!(config.display_mode, AgentTeamDisplayMode::Auto);
176 assert_eq!(config.max_teammates, 5);
177 assert!(config.split_panes.reuse_existing_tmux_session);
178 }
179
180 #[test]
181 fn legacy_team_state_fields_deserialize_with_safe_defaults() {
182 let member: TeamMemberDescriptor = serde_json::from_value(serde_json::json!({
183 "id": "member-1",
184 "role": "teammate",
185 "name": "Reviewer",
186 "threadId": "thread-1",
187 "currentTurnId": null,
188 "modelProvider": "codex",
189 "model": "gpt-5.6-sol",
190 "policyMode": "default",
191 "status": "idle",
192 "paneId": null
193 }))
194 .unwrap();
195 assert_eq!(member.task_name, None);
196 assert_eq!(member.agent_path, None);
197 assert_eq!(member.parent_thread_id, None);
198 assert_eq!(member.final_message, None);
199 assert_eq!(member.terminal_error, None);
200
201 let message: TeamMailboxMessage = serde_json::from_value(serde_json::json!({
202 "id": "message-1",
203 "teamId": "team-1",
204 "fromMemberId": null,
205 "toMemberId": "member-1",
206 "text": "Review this",
207 "timestamp": "2026-07-10T00:00:00Z"
208 }))
209 .unwrap();
210 assert_eq!(message.kind, TeamMailboxMessageKind::Message);
211 assert!(!message.delivered);
212
213 assert_eq!(
214 serde_json::to_string(&TeamMailboxMessageKind::Message).unwrap(),
215 "\"MESSAGE\""
216 );
217 assert_eq!(
218 serde_json::to_string(&TeamMailboxMessageKind::NewTask).unwrap(),
219 "\"NEW_TASK\""
220 );
221 assert_eq!(
222 serde_json::to_string(&TeamMailboxMessageKind::FinalAnswer).unwrap(),
223 "\"FINAL_ANSWER\""
224 );
225 }
226}