1use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct InboxMessage {
21 #[serde(default)]
24 pub id: String,
25
26 pub from: String,
28
29 #[serde(default)]
33 pub to: String,
34
35 #[serde(alias = "text")]
38 pub content: String,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub summary: Option<String>,
43
44 pub timestamp: DateTime<Utc>,
46
47 #[serde(default)]
49 pub read: bool,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub color: Option<String>,
54}
55
56impl InboxMessage {
57 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 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 pub fn try_as_structured(&self) -> Option<StructuredMessage> {
93 serde_json::from_str(&self.content).ok()
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(tag = "type", rename_all = "snake_case")]
102pub enum StructuredMessage {
103 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 #[serde(default, skip_serializing_if = "Option::is_none")]
114 #[serde(alias = "assignedBy")]
115 assigned_by: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 timestamp: Option<String>,
119 },
120
121 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 ShutdownApproved {
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 request_id: Option<String>,
133 },
134
135 IdleNotification {
140 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
148 #[serde(alias = "idleReason")]
149 idle_reason: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 timestamp: Option<String>,
153 },
154
155 PlanApprovalRequest {
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 request_id: Option<String>,
159 plan: String,
160 },
161
162 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 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(); }
246 }
247
248 #[test]
249 fn deserialize_native_inbox_message_with_text_key() {
250 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 assert_eq!(msg.id, "");
269 assert_eq!(msg.to, "");
270 }
271
272 #[test]
273 fn deserialize_native_task_assignment_protocol() {
274 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 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 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 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}