Skip to main content

agent_teams/models/
message.rs

1//! Message models for the file-based inbox system.
2//!
3//! Compatible with Claude Code's
4//! `~/.claude/teams/{team-name}/inboxes/{agent-name}.json` format.
5//!
6//! Claude Code's native format uses `"text"` for the content field and has
7//! no `"id"` or `"to"` fields (recipient is implicit from file path).
8//! Our library extends the format with `"id"` and `"to"` for convenience,
9//! and accepts both `"text"` and `"content"` during deserialization.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14/// A message stored in an agent's inbox file.
15///
16/// The `content` field serializes as `"content"` but also accepts `"text"`
17/// (Claude Code's native key name) during deserialization.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct InboxMessage {
21    /// Unique message ID (library extension — absent in native format).
22    /// Defaults to empty string when parsing native messages without an ID.
23    #[serde(default)]
24    pub id: String,
25
26    /// Sender agent name.
27    pub from: String,
28
29    /// Recipient agent name (library extension — absent in native format;
30    /// the recipient is implicit from the inbox file name).
31    /// Defaults to empty string when parsing native messages.
32    #[serde(default)]
33    pub to: String,
34
35    /// Plain-text content (may also carry structured JSON).
36    /// Serializes as `"content"`, deserializes from `"content"` or `"text"`.
37    #[serde(alias = "text")]
38    pub content: String,
39
40    /// Summary for UI preview (5-10 word description).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub summary: Option<String>,
43
44    /// ISO 8601 timestamp.
45    pub timestamp: DateTime<Utc>,
46
47    /// Whether the message has been read.
48    #[serde(default)]
49    pub read: bool,
50
51    /// Sender's UI color hint (e.g. `"blue"`, `"green"`, `"yellow"`).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub color: Option<String>,
54}
55
56impl InboxMessage {
57    /// Create a new plain-text message.
58    pub fn new(from: impl Into<String>, to: impl Into<String>, content: impl Into<String>) -> Self {
59        Self {
60            id: uuid::Uuid::new_v4().to_string(),
61            from: from.into(),
62            to: to.into(),
63            content: content.into(),
64            summary: None,
65            timestamp: Utc::now(),
66            read: false,
67            color: None,
68        }
69    }
70
71    /// Create a message from a structured payload.
72    pub fn from_structured(
73        from: impl Into<String>,
74        to: impl Into<String>,
75        msg: &StructuredMessage,
76    ) -> serde_json::Result<Self> {
77        let content = serde_json::to_string(msg)?;
78        let summary = Some(msg.summary());
79        Ok(Self {
80            id: uuid::Uuid::new_v4().to_string(),
81            from: from.into(),
82            to: to.into(),
83            content,
84            summary,
85            timestamp: Utc::now(),
86            read: false,
87            color: None,
88        })
89    }
90
91    /// Try to parse the content as a structured message.
92    pub fn try_as_structured(&self) -> Option<StructuredMessage> {
93        serde_json::from_str(&self.content).ok()
94    }
95}
96
97/// Structured message types used by the agent teams protocol.
98///
99/// Serialized with `#[serde(tag = "type", rename_all = "snake_case")]`.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(tag = "type", rename_all = "snake_case")]
102pub enum StructuredMessage {
103    /// Assign a task to a teammate.
104    ///
105    /// Native format uses camelCase (`taskId`, `assignedBy`).
106    TaskAssignment {
107        #[serde(alias = "taskId")]
108        task_id: String,
109        subject: String,
110        #[serde(default, skip_serializing_if = "Option::is_none")]
111        description: Option<String>,
112        /// Who assigned this task (native format field).
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        #[serde(alias = "assignedBy")]
115        assigned_by: Option<String>,
116        /// Assignment timestamp (native format field).
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        timestamp: Option<String>,
119    },
120
121    /// Request a teammate to shut down.
122    ShutdownRequest {
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        request_id: Option<String>,
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        reason: Option<String>,
127    },
128
129    /// Acknowledge a shutdown request.
130    ShutdownApproved {
131        #[serde(default, skip_serializing_if = "Option::is_none")]
132        request_id: Option<String>,
133    },
134
135    /// Teammate idle notification.
136    ///
137    /// Native format may use `"from"` instead of `"agent"`, and includes
138    /// `"idleReason"` with values like `"available"`, `"working"`, `"blocked"`.
139    IdleNotification {
140        /// Agent name. Also accepts `"from"` (native format).
141        #[serde(alias = "from")]
142        agent: String,
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        #[serde(alias = "lastTaskId")]
145        last_task_id: Option<String>,
146        /// Idle reason from native format.
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        #[serde(alias = "idleReason")]
149        idle_reason: Option<String>,
150        /// Timestamp from native format.
151        #[serde(default, skip_serializing_if = "Option::is_none")]
152        timestamp: Option<String>,
153    },
154
155    /// Request plan approval from lead.
156    PlanApprovalRequest {
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        request_id: Option<String>,
159        plan: String,
160    },
161
162    /// Response to a plan approval request.
163    PlanApprovalResponse {
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        request_id: Option<String>,
166        approved: bool,
167        #[serde(default, skip_serializing_if = "Option::is_none")]
168        feedback: Option<String>,
169    },
170}
171
172impl StructuredMessage {
173    /// A short human-readable summary of this message.
174    pub fn summary(&self) -> String {
175        match self {
176            StructuredMessage::TaskAssignment { subject, .. } => {
177                format!("Task assigned: {subject}")
178            }
179            StructuredMessage::ShutdownRequest { .. } => "Shutdown requested".into(),
180            StructuredMessage::ShutdownApproved { .. } => "Shutdown approved".into(),
181            StructuredMessage::IdleNotification { agent, .. } => {
182                format!("{agent} is idle")
183            }
184            StructuredMessage::PlanApprovalRequest { .. } => "Plan approval requested".into(),
185            StructuredMessage::PlanApprovalResponse { approved, .. } => {
186                if *approved {
187                    "Plan approved".into()
188                } else {
189                    "Plan rejected".into()
190                }
191            }
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn serde_round_trip_structured_message() {
202        let msg = StructuredMessage::TaskAssignment {
203            task_id: "1".into(),
204            subject: "Fix bug".into(),
205            description: Some("Critical auth issue".into()),
206            assigned_by: None,
207            timestamp: None,
208        };
209
210        let json = serde_json::to_string(&msg).unwrap();
211        assert!(json.contains(r#""type":"task_assignment"#));
212
213        let parsed: StructuredMessage = serde_json::from_str(&json).unwrap();
214        assert!(matches!(parsed, StructuredMessage::TaskAssignment { .. }));
215    }
216
217    #[test]
218    fn inbox_message_structured_round_trip() {
219        let structured = StructuredMessage::ShutdownRequest {
220            request_id: Some("req-1".into()),
221            reason: Some("All done".into()),
222        };
223
224        let msg = InboxMessage::from_structured("lead", "worker", &structured).unwrap();
225        assert!(msg.summary.is_some());
226
227        let parsed = msg.try_as_structured().unwrap();
228        assert!(matches!(parsed, StructuredMessage::ShutdownRequest { .. }));
229    }
230
231    #[test]
232    fn deserialize_all_structured_variants() {
233        let variants = vec![
234            r#"{"type":"task_assignment","task_id":"1","subject":"Do thing"}"#,
235            r#"{"type":"shutdown_request"}"#,
236            r#"{"type":"shutdown_approved"}"#,
237            r#"{"type":"idle_notification","agent":"worker-1"}"#,
238            r#"{"type":"plan_approval_request","plan":"Step 1: ..."}"#,
239            r#"{"type":"plan_approval_response","approved":true}"#,
240        ];
241
242        for json in variants {
243            let msg: StructuredMessage = serde_json::from_str(json).unwrap();
244            let _ = msg.summary(); // Should not panic
245        }
246    }
247
248    #[test]
249    fn deserialize_native_inbox_message_with_text_key() {
250        // Real Claude Code inbox message uses "text" not "content",
251        // and has no "id" or "to" fields.
252        let json = r#"{
253            "from": "team-lead",
254            "text": "Hi! I see you're working on Task #1.",
255            "timestamp": "2026-02-11T08:27:54.622Z",
256            "read": true,
257            "summary": "Task sequence guidance",
258            "color": "blue"
259        }"#;
260
261        let msg: InboxMessage = serde_json::from_str(json).unwrap();
262        assert_eq!(msg.from, "team-lead");
263        assert_eq!(msg.content, "Hi! I see you're working on Task #1.");
264        assert_eq!(msg.summary.as_deref(), Some("Task sequence guidance"));
265        assert_eq!(msg.color.as_deref(), Some("blue"));
266        assert!(msg.read);
267        // id and to default to empty strings when absent
268        assert_eq!(msg.id, "");
269        assert_eq!(msg.to, "");
270    }
271
272    #[test]
273    fn deserialize_native_task_assignment_protocol() {
274        // Real native task_assignment uses camelCase field names.
275        let json = r#"{
276            "type": "task_assignment",
277            "taskId": "1",
278            "subject": "Set up project structure",
279            "description": "Create all directories...",
280            "assignedBy": "team-lead",
281            "timestamp": "2026-02-11T08:27:04.754Z"
282        }"#;
283
284        let msg: StructuredMessage = serde_json::from_str(json).unwrap();
285        match msg {
286            StructuredMessage::TaskAssignment {
287                task_id,
288                subject,
289                description,
290                assigned_by,
291                timestamp,
292            } => {
293                assert_eq!(task_id, "1");
294                assert_eq!(subject, "Set up project structure");
295                assert!(description.is_some());
296                assert_eq!(assigned_by.as_deref(), Some("team-lead"));
297                assert_eq!(timestamp.as_deref(), Some("2026-02-11T08:27:04.754Z"));
298            }
299            _ => panic!("Expected TaskAssignment"),
300        }
301    }
302
303    #[test]
304    fn deserialize_native_idle_notification() {
305        // Real native idle_notification uses "from" and "idleReason".
306        let json = r#"{
307            "type": "idle_notification",
308            "from": "cc-writer",
309            "timestamp": "2026-02-11T19:08:12.345Z",
310            "idleReason": "available"
311        }"#;
312
313        let msg: StructuredMessage = serde_json::from_str(json).unwrap();
314        match msg {
315            StructuredMessage::IdleNotification {
316                agent,
317                idle_reason,
318                timestamp,
319                ..
320            } => {
321                assert_eq!(agent, "cc-writer");
322                assert_eq!(idle_reason.as_deref(), Some("available"));
323                assert!(timestamp.is_some());
324            }
325            _ => panic!("Expected IdleNotification"),
326        }
327    }
328
329    #[test]
330    fn deserialize_native_json_in_json_inbox() {
331        // Real inbox: text field contains serialized JSON protocol message.
332        let json = r#"{
333            "from": "gemini-proxy",
334            "text": "{\"type\":\"task_assignment\",\"taskId\":\"3\",\"subject\":\"Gemini proxy review\",\"assignedBy\":\"gemini-proxy\",\"timestamp\":\"2026-02-11T19:06:06.765Z\"}",
335            "timestamp": "2026-02-11T19:06:06.765Z",
336            "color": "yellow",
337            "read": false
338        }"#;
339
340        let msg: InboxMessage = serde_json::from_str(json).unwrap();
341        assert_eq!(msg.from, "gemini-proxy");
342        assert_eq!(msg.color.as_deref(), Some("yellow"));
343        assert!(!msg.read);
344
345        // Parse inner protocol message
346        let structured = msg.try_as_structured().unwrap();
347        match structured {
348            StructuredMessage::TaskAssignment { task_id, subject, assigned_by, .. } => {
349                assert_eq!(task_id, "3");
350                assert_eq!(subject, "Gemini proxy review");
351                assert_eq!(assigned_by.as_deref(), Some("gemini-proxy"));
352            }
353            _ => panic!("Expected TaskAssignment"),
354        }
355    }
356}