Skip to main content

envoy/message/
types.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{EnvoyError, Result};
4
5/// Content part — exactly one variant per part (adapted from A2A).
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum PartContent {
9    Text(String),
10    Data(serde_json::Value),
11    Url(String),
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Part {
16    #[serde(flatten)]
17    pub content: PartContent,
18}
19
20/// Message envelope shared by all message types.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MessageEnvelope {
23    pub message_id: String,
24    #[serde(rename = "type")]
25    pub msg_type: MessageType,
26    pub from: String,
27    pub to: String,
28    #[serde(default)]
29    pub task_id: Option<String>,
30    #[serde(default)]
31    pub context_id: Option<String>,
32    pub timestamp: String,
33    pub sequence_id: i64,
34    pub parts: Vec<Part>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(rename_all = "snake_case")]
39pub enum MessageType {
40    Direct,
41    Handoff,
42    Heartbeat,
43    System,
44}
45
46impl MessageType {
47    pub fn as_str(&self) -> &'static str {
48        match self {
49            Self::Direct => "direct",
50            Self::Handoff => "handoff",
51            Self::Heartbeat => "heartbeat",
52            Self::System => "system",
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
59pub enum CompletionStatus {
60    Done,
61    DoneWithConcerns,
62    Blocked,
63    NeedsContext,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WhatWasDone {
68    pub scope: String,
69    pub change: String,
70    pub verified: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct WhatIsStubbed {
75    pub location: String,
76    pub reason: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct VerificationState {
81    pub tests_passing: i64,
82    pub tests_failing: i64,
83    pub quality_gate: QualityGateResult,
84    pub cargo_check_passed: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct QualityGateResult {
89    pub passed: bool,
90    pub blocking: i64,
91    pub warnings: i64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct MagellanTracePayload {
96    pub files_changed: Vec<String>,
97    pub symbols_added: Vec<String>,
98    pub symbols_removed: Vec<String>,
99    #[serde(default)]
100    pub refs_in: serde_json::Map<String, serde_json::Value>,
101    #[serde(default)]
102    pub refs_out: serde_json::Map<String, serde_json::Value>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HandoffData {
107    pub completion_status: CompletionStatus,
108    #[serde(default)]
109    pub blocked_reason: Option<String>,
110    pub context_remaining_pct: u8,
111    pub what_was_done: Vec<WhatWasDone>,
112    pub what_is_stubbed: Vec<WhatIsStubbed>,
113    pub remaining_work: Vec<String>,
114    pub verification_state: VerificationState,
115    pub magellan_trace: MagellanTracePayload,
116    pub grounded_queries_used: Vec<String>,
117}
118
119impl HandoffData {
120    pub fn validate(&self) -> Result<()> {
121        if self.completion_status == CompletionStatus::Blocked && self.blocked_reason.is_none() {
122            return Err(EnvoyError::InvalidMessage(
123                "blocked_reason is required when status is BLOCKED".into(),
124            ));
125        }
126        if self.context_remaining_pct > 100 {
127            return Err(EnvoyError::InvalidMessage(
128                "context_remaining_pct must be 0-100".into(),
129            ));
130        }
131        Ok(())
132    }
133}
134
135impl MessageEnvelope {
136    pub const MAX_BODY_SIZE: usize = 1_048_576; // 1 MB
137    pub const MAX_PARTS: usize = 20;
138
139    pub fn validate(&self) -> Result<()> {
140        if self.parts.is_empty() {
141            return Err(EnvoyError::InvalidMessage(
142                "at least one part required".into(),
143            ));
144        }
145        if self.parts.len() > Self::MAX_PARTS {
146            return Err(EnvoyError::TooManyParts(self.parts.len()));
147        }
148        for part in &self.parts {
149            if let PartContent::Text(ref text) = &part.content {
150                if text.len() > Self::MAX_BODY_SIZE {
151                    return Err(EnvoyError::MessageTooLarge(text.len()));
152                }
153            }
154        }
155        Ok(())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn serialize_message_envelope() {
165        let msg = MessageEnvelope {
166            message_id: "m-001".into(),
167            msg_type: MessageType::Direct,
168            from: "id1".into(),
169            to: "id2".into(),
170            task_id: Some("t-001".into()),
171            context_id: Some("c-001".into()),
172            timestamp: "2026-05-05T21:00:00Z".into(),
173            sequence_id: 1,
174            parts: vec![
175                Part {
176                    content: PartContent::Text("hello".into()),
177                },
178                Part {
179                    content: PartContent::Data(serde_json::json!({"status": "working"})),
180                },
181            ],
182        };
183
184        let json = serde_json::to_string(&msg).unwrap();
185        let back: MessageEnvelope = serde_json::from_str(&json).unwrap();
186        assert_eq!(back.message_id, "m-001");
187        assert_eq!(back.msg_type, MessageType::Direct);
188        assert_eq!(back.parts.len(), 2);
189    }
190
191    #[test]
192    fn serialize_handoff_data() {
193        let handoff = HandoffData {
194            completion_status: CompletionStatus::Blocked,
195            blocked_reason: Some("need access to sqlitegraph internal API".into()),
196            context_remaining_pct: 28,
197            what_was_done: vec![WhatWasDone {
198                scope: "src/engine.rs".into(),
199                change: "added publish()".into(),
200                verified: true,
201            }],
202            what_is_stubbed: vec![WhatIsStubbed {
203                location: "src/http.rs".into(),
204                reason: "context too low".into(),
205            }],
206            remaining_work: vec!["Implement HTTP server".into()],
207            verification_state: VerificationState {
208                tests_passing: 11,
209                tests_failing: 0,
210                quality_gate: QualityGateResult {
211                    passed: true,
212                    blocking: 0,
213                    warnings: 2,
214                },
215                cargo_check_passed: true,
216            },
217            magellan_trace: MagellanTracePayload {
218                files_changed: vec!["src/engine.rs".into()],
219                symbols_added: vec!["fn publish".into()],
220                symbols_removed: vec![],
221                refs_in: Default::default(),
222                refs_out: Default::default(),
223            },
224            grounded_queries_used: vec!["magellan find --name Engine".into()],
225        };
226
227        let json = serde_json::to_string_pretty(&handoff).unwrap();
228        let back: HandoffData = serde_json::from_str(&json).unwrap();
229        assert_eq!(back.completion_status, CompletionStatus::Blocked);
230        assert_eq!(back.context_remaining_pct, 28);
231        assert!(back.validate().is_ok());
232    }
233
234    #[test]
235    fn handoff_blocked_requires_reason() {
236        let handoff = HandoffData {
237            completion_status: CompletionStatus::Blocked,
238            blocked_reason: None,
239            context_remaining_pct: 50,
240            what_was_done: vec![],
241            what_is_stubbed: vec![],
242            remaining_work: vec![],
243            verification_state: VerificationState {
244                tests_passing: 0,
245                tests_failing: 0,
246                quality_gate: QualityGateResult {
247                    passed: true,
248                    blocking: 0,
249                    warnings: 0,
250                },
251                cargo_check_passed: true,
252            },
253            magellan_trace: MagellanTracePayload {
254                files_changed: vec![],
255                symbols_added: vec![],
256                symbols_removed: vec![],
257                refs_in: Default::default(),
258                refs_out: Default::default(),
259            },
260            grounded_queries_used: vec![],
261        };
262        assert!(handoff.validate().is_err());
263    }
264
265    #[test]
266    fn message_empty_parts_rejected() {
267        let msg = MessageEnvelope {
268            message_id: "m-001".into(),
269            msg_type: MessageType::Direct,
270            from: "id1".into(),
271            to: "id2".into(),
272            task_id: None,
273            context_id: None,
274            timestamp: "now".into(),
275            sequence_id: 1,
276            parts: vec![],
277        };
278        assert!(msg.validate().is_err());
279    }
280
281    #[test]
282    fn message_too_many_parts_rejected() {
283        let parts: Vec<Part> = (0..25)
284            .map(|_| Part {
285                content: PartContent::Text("x".into()),
286            })
287            .collect();
288        let msg = MessageEnvelope {
289            message_id: "m-001".into(),
290            msg_type: MessageType::Direct,
291            from: "id1".into(),
292            to: "id2".into(),
293            task_id: None,
294            context_id: None,
295            timestamp: "now".into(),
296            sequence_id: 1,
297            parts,
298        };
299        assert!(msg.validate().is_err());
300    }
301}