Skip to main content

roder_api/
teams.rs

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}