roder-api 0.2.1

Agentic software development tools and SDKs for Roder.
Documentation
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

use crate::events::{ThreadId, TurnId};
use crate::policy_mode::PolicyMode;

pub type TeamId = String;
pub type TeamMemberId = String;
pub type TeamTaskId = String;
pub type TeamMessageId = String;

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AgentTeamDisplayMode {
    #[default]
    Auto,
    #[serde(alias = "in-process", alias = "inprocess")]
    InProcess,
    Tmux,
    #[serde(alias = "iterm")]
    Iterm2,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AgentTeamConfig {
    pub enabled: bool,
    pub display_mode: AgentTeamDisplayMode,
    pub default_teammate_model: Option<String>,
    pub require_plan_approval: bool,
    pub max_teammates: usize,
    pub split_panes: AgentTeamSplitPaneConfig,
}

impl Default for AgentTeamConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            display_mode: AgentTeamDisplayMode::Auto,
            default_teammate_model: Some("lead".to_string()),
            require_plan_approval: false,
            max_teammates: 5,
            split_panes: AgentTeamSplitPaneConfig::default(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AgentTeamSplitPaneConfig {
    pub reuse_existing_tmux_session: bool,
    pub tmux_command: String,
    pub iterm2_command: String,
}

impl Default for AgentTeamSplitPaneConfig {
    fn default() -> Self {
        Self {
            reuse_existing_tmux_session: true,
            tmux_command: "tmux".to_string(),
            iterm2_command: "it2".to_string(),
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum TeamMemberRole {
    Lead,
    Teammate,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum TeamMemberStatus {
    Idle,
    Running,
    Blocked,
    Completed,
    Failed,
    Interrupted,
    Closed,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TeamMemberDescriptor {
    pub id: TeamMemberId,
    pub role: TeamMemberRole,
    pub name: String,
    #[serde(default)]
    pub task_name: Option<String>,
    #[serde(default)]
    pub agent_path: Option<String>,
    pub thread_id: ThreadId,
    #[serde(default)]
    pub parent_thread_id: Option<ThreadId>,
    pub current_turn_id: Option<TurnId>,
    pub model_provider: Option<String>,
    pub model: Option<String>,
    pub policy_mode: PolicyMode,
    pub status: TeamMemberStatus,
    #[serde(default)]
    pub final_message: Option<String>,
    #[serde(default)]
    pub terminal_error: Option<String>,
    pub pane_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TeamMailboxMessage {
    pub id: TeamMessageId,
    pub team_id: TeamId,
    pub from_member_id: Option<TeamMemberId>,
    pub to_member_id: TeamMemberId,
    #[serde(default)]
    pub kind: TeamMailboxMessageKind,
    pub text: String,
    #[serde(default)]
    pub delivered: bool,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TeamMailboxMessageKind {
    #[default]
    Message,
    NewTask,
    FinalAnswer,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum TeamTaskStatus {
    Pending,
    InProgress,
    Completed,
    Failed,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TeamTaskDescriptor {
    pub id: TeamTaskId,
    pub title: String,
    pub status: TeamTaskStatus,
    pub assignee_member_id: Option<TeamMemberId>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn team_display_mode_accepts_snake_case_and_hyphenated_values() {
        let snake: AgentTeamDisplayMode = serde_json::from_str("\"in_process\"").unwrap();
        let hyphen: AgentTeamDisplayMode = serde_json::from_str("\"in-process\"").unwrap();

        assert_eq!(snake, AgentTeamDisplayMode::InProcess);
        assert_eq!(hyphen, AgentTeamDisplayMode::InProcess);
        assert_eq!(
            serde_json::to_string(&AgentTeamDisplayMode::Tmux).unwrap(),
            "\"tmux\""
        );
    }

    #[test]
    fn agent_team_config_defaults_to_disabled_auto() {
        let config = AgentTeamConfig::default();

        assert!(!config.enabled);
        assert_eq!(config.display_mode, AgentTeamDisplayMode::Auto);
        assert_eq!(config.max_teammates, 5);
        assert!(config.split_panes.reuse_existing_tmux_session);
    }

    #[test]
    fn legacy_team_state_fields_deserialize_with_safe_defaults() {
        let member: TeamMemberDescriptor = serde_json::from_value(serde_json::json!({
            "id": "member-1",
            "role": "teammate",
            "name": "Reviewer",
            "threadId": "thread-1",
            "currentTurnId": null,
            "modelProvider": "codex",
            "model": "gpt-5.6-sol",
            "policyMode": "default",
            "status": "idle",
            "paneId": null
        }))
        .unwrap();
        assert_eq!(member.task_name, None);
        assert_eq!(member.agent_path, None);
        assert_eq!(member.parent_thread_id, None);
        assert_eq!(member.final_message, None);
        assert_eq!(member.terminal_error, None);

        let message: TeamMailboxMessage = serde_json::from_value(serde_json::json!({
            "id": "message-1",
            "teamId": "team-1",
            "fromMemberId": null,
            "toMemberId": "member-1",
            "text": "Review this",
            "timestamp": "2026-07-10T00:00:00Z"
        }))
        .unwrap();
        assert_eq!(message.kind, TeamMailboxMessageKind::Message);
        assert!(!message.delivered);

        assert_eq!(
            serde_json::to_string(&TeamMailboxMessageKind::Message).unwrap(),
            "\"MESSAGE\""
        );
        assert_eq!(
            serde_json::to_string(&TeamMailboxMessageKind::NewTask).unwrap(),
            "\"NEW_TASK\""
        );
        assert_eq!(
            serde_json::to_string(&TeamMailboxMessageKind::FinalAnswer).unwrap(),
            "\"FINAL_ANSWER\""
        );
    }
}