use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InboxMessage {
#[serde(default)]
pub id: String,
pub from: String,
#[serde(default)]
pub to: String,
#[serde(alias = "text")]
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
pub timestamp: DateTime<Utc>,
#[serde(default)]
pub read: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl InboxMessage {
pub fn new(from: impl Into<String>, to: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
from: from.into(),
to: to.into(),
content: content.into(),
summary: None,
timestamp: Utc::now(),
read: false,
color: None,
}
}
pub fn from_structured(
from: impl Into<String>,
to: impl Into<String>,
msg: &StructuredMessage,
) -> serde_json::Result<Self> {
let content = serde_json::to_string(msg)?;
let summary = Some(msg.summary());
Ok(Self {
id: uuid::Uuid::new_v4().to_string(),
from: from.into(),
to: to.into(),
content,
summary,
timestamp: Utc::now(),
read: false,
color: None,
})
}
pub fn try_as_structured(&self) -> Option<StructuredMessage> {
serde_json::from_str(&self.content).ok()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StructuredMessage {
TaskAssignment {
#[serde(alias = "taskId")]
task_id: String,
subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(alias = "assignedBy")]
assigned_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
ShutdownRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
ShutdownApproved {
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
},
IdleNotification {
#[serde(alias = "from")]
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(alias = "lastTaskId")]
last_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(alias = "idleReason")]
idle_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
PlanApprovalRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
plan: String,
},
PlanApprovalResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
approved: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
feedback: Option<String>,
},
}
impl StructuredMessage {
pub fn summary(&self) -> String {
match self {
StructuredMessage::TaskAssignment { subject, .. } => {
format!("Task assigned: {subject}")
}
StructuredMessage::ShutdownRequest { .. } => "Shutdown requested".into(),
StructuredMessage::ShutdownApproved { .. } => "Shutdown approved".into(),
StructuredMessage::IdleNotification { agent, .. } => {
format!("{agent} is idle")
}
StructuredMessage::PlanApprovalRequest { .. } => "Plan approval requested".into(),
StructuredMessage::PlanApprovalResponse { approved, .. } => {
if *approved {
"Plan approved".into()
} else {
"Plan rejected".into()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_round_trip_structured_message() {
let msg = StructuredMessage::TaskAssignment {
task_id: "1".into(),
subject: "Fix bug".into(),
description: Some("Critical auth issue".into()),
assigned_by: None,
timestamp: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""type":"task_assignment"#));
let parsed: StructuredMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, StructuredMessage::TaskAssignment { .. }));
}
#[test]
fn inbox_message_structured_round_trip() {
let structured = StructuredMessage::ShutdownRequest {
request_id: Some("req-1".into()),
reason: Some("All done".into()),
};
let msg = InboxMessage::from_structured("lead", "worker", &structured).unwrap();
assert!(msg.summary.is_some());
let parsed = msg.try_as_structured().unwrap();
assert!(matches!(parsed, StructuredMessage::ShutdownRequest { .. }));
}
#[test]
fn deserialize_all_structured_variants() {
let variants = vec![
r#"{"type":"task_assignment","task_id":"1","subject":"Do thing"}"#,
r#"{"type":"shutdown_request"}"#,
r#"{"type":"shutdown_approved"}"#,
r#"{"type":"idle_notification","agent":"worker-1"}"#,
r#"{"type":"plan_approval_request","plan":"Step 1: ..."}"#,
r#"{"type":"plan_approval_response","approved":true}"#,
];
for json in variants {
let msg: StructuredMessage = serde_json::from_str(json).unwrap();
let _ = msg.summary(); }
}
#[test]
fn deserialize_native_inbox_message_with_text_key() {
let json = r#"{
"from": "team-lead",
"text": "Hi! I see you're working on Task #1.",
"timestamp": "2026-02-11T08:27:54.622Z",
"read": true,
"summary": "Task sequence guidance",
"color": "blue"
}"#;
let msg: InboxMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.from, "team-lead");
assert_eq!(msg.content, "Hi! I see you're working on Task #1.");
assert_eq!(msg.summary.as_deref(), Some("Task sequence guidance"));
assert_eq!(msg.color.as_deref(), Some("blue"));
assert!(msg.read);
assert_eq!(msg.id, "");
assert_eq!(msg.to, "");
}
#[test]
fn deserialize_native_task_assignment_protocol() {
let json = r#"{
"type": "task_assignment",
"taskId": "1",
"subject": "Set up project structure",
"description": "Create all directories...",
"assignedBy": "team-lead",
"timestamp": "2026-02-11T08:27:04.754Z"
}"#;
let msg: StructuredMessage = serde_json::from_str(json).unwrap();
match msg {
StructuredMessage::TaskAssignment {
task_id,
subject,
description,
assigned_by,
timestamp,
} => {
assert_eq!(task_id, "1");
assert_eq!(subject, "Set up project structure");
assert!(description.is_some());
assert_eq!(assigned_by.as_deref(), Some("team-lead"));
assert_eq!(timestamp.as_deref(), Some("2026-02-11T08:27:04.754Z"));
}
_ => panic!("Expected TaskAssignment"),
}
}
#[test]
fn deserialize_native_idle_notification() {
let json = r#"{
"type": "idle_notification",
"from": "cc-writer",
"timestamp": "2026-02-11T19:08:12.345Z",
"idleReason": "available"
}"#;
let msg: StructuredMessage = serde_json::from_str(json).unwrap();
match msg {
StructuredMessage::IdleNotification {
agent,
idle_reason,
timestamp,
..
} => {
assert_eq!(agent, "cc-writer");
assert_eq!(idle_reason.as_deref(), Some("available"));
assert!(timestamp.is_some());
}
_ => panic!("Expected IdleNotification"),
}
}
#[test]
fn deserialize_native_json_in_json_inbox() {
let json = r#"{
"from": "gemini-proxy",
"text": "{\"type\":\"task_assignment\",\"taskId\":\"3\",\"subject\":\"Gemini proxy review\",\"assignedBy\":\"gemini-proxy\",\"timestamp\":\"2026-02-11T19:06:06.765Z\"}",
"timestamp": "2026-02-11T19:06:06.765Z",
"color": "yellow",
"read": false
}"#;
let msg: InboxMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.from, "gemini-proxy");
assert_eq!(msg.color.as_deref(), Some("yellow"));
assert!(!msg.read);
let structured = msg.try_as_structured().unwrap();
match structured {
StructuredMessage::TaskAssignment { task_id, subject, assigned_by, .. } => {
assert_eq!(task_id, "3");
assert_eq!(subject, "Gemini proxy review");
assert_eq!(assigned_by.as_deref(), Some("gemini-proxy"));
}
_ => panic!("Expected TaskAssignment"),
}
}
}