返回 CodeWhale
agent_mail.rs
根目录 / crates / protocol / src / agent_mail.rs
1 //! Canonical protocol contract for durable communication between Codewhale tasks.
2 //!
3 //! Agent Mail is distinct from same-session subagent control messages. An envelope
4 //! is persisted by the runtime, scoped to an owner and workspace, and projected
5 //! into a destination turn only at an explicit safe boundary. The summary is the
6 //! complete model-visible payload: runtimes must sanitize it before constructing
7 //! an envelope, and this module enforces the wire-size and control-character
8 //! boundary.
9
10 use std::error::Error;
11 use std::fmt;
12
13 use chrono::{DateTime, Utc};
14 use serde::{Deserialize, Deserializer, Serialize};
15 use uuid::Uuid;
16
17 pub const AGENT_MAIL_SCHEMA_VERSION: u32 = 1;
18
19 pub const AGENT_MAIL_EVENT_QUEUED: &str = "agent_mail.queued";
20 pub const AGENT_MAIL_EVENT_DELIVERING: &str = "agent_mail.delivering";
21 pub const AGENT_MAIL_EVENT_DELIVERED: &str = "agent_mail.delivered";
22 pub const AGENT_MAIL_EVENT_READ: &str = "agent_mail.read";
23 pub const AGENT_MAIL_EVENT_DELIVERY_FAILED: &str = "agent_mail.delivery_failed";
24 pub const AGENT_MAIL_EVENT_CANCELED: &str = "agent_mail.canceled";
25
26 pub const MAX_AGENT_MAIL_MESSAGE_ID_BYTES: usize = 80;
27 pub const MAX_AGENT_MAIL_OPAQUE_ID_BYTES: usize = 128;
28 pub const MAX_AGENT_MAIL_DISPLAY_LABEL_BYTES: usize = 64;
29 pub const MAX_AGENT_MAIL_SUMMARY_BYTES: usize = 2_048;
30 pub const MAX_AGENT_MAIL_EVIDENCE_REFS: usize = 8;
31 pub const MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES: usize = 96;
32 pub const MAX_AGENT_MAIL_HOPS: u8 = 4;
33 pub const MAX_AGENT_MAIL_DELIVERY_ATTEMPTS: u8 = 8;
34 pub const MAX_AGENT_MAIL_FAILURE_MESSAGE_BYTES: usize = 256;
35
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub struct AgentMailValidationError {
38 pub field: &'static str,
39 pub message: String,
40 }
41
42 impl AgentMailValidationError {
43 fn new(field: &'static str, message: impl Into<String>) -> Self {
44 Self {
45 field,
46 message: message.into(),
47 }
48 }
49 }
50
51 impl fmt::Display for AgentMailValidationError {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "invalid Agent Mail {}: {}", self.field, self.message)
54 }
55 }
56
57 impl Error for AgentMailValidationError {}
58
59 /// Stable, caller-supplied idempotency key for an Agent Mail envelope.
60 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
61 #[serde(transparent)]
62 pub struct AgentMailMessageId(String);
63
64 impl AgentMailMessageId {
65 #[must_use]
66 pub fn new() -> Self {
67 Self(format!("mail_{}", Uuid::new_v4().simple()))
68 }
69
70 pub fn parse(value: impl Into<String>) -> Result<Self, AgentMailValidationError> {
71 let value = value.into();
72 validate_message_id(&value)?;
73 Ok(Self(value))
74 }
75
76 #[must_use]
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80
81 #[must_use]
82 pub fn into_string(self) -> String {
83 self.0
84 }
85 }
86
87 impl Default for AgentMailMessageId {
88 fn default() -> Self {
89 Self::new()
90 }
91 }
92
93 impl fmt::Display for AgentMailMessageId {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str(&self.0)
96 }
97 }
98
99 impl TryFrom<String> for AgentMailMessageId {
100 type Error = AgentMailValidationError;
101
102 fn try_from(value: String) -> Result<Self, Self::Error> {
103 Self::parse(value)
104 }
105 }
106
107 impl TryFrom<&str> for AgentMailMessageId {
108 type Error = AgentMailValidationError;
109
110 fn try_from(value: &str) -> Result<Self, Self::Error> {
111 Self::parse(value)
112 }
113 }
114
115 impl<'de> Deserialize<'de> for AgentMailMessageId {
116 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
117 where
118 D: Deserializer<'de>,
119 {
120 let value = String::deserialize(deserializer)?;
121 Self::parse(value).map_err(serde::de::Error::custom)
122 }
123 }
124
125 /// Durable ownership and routing scope resolved by the receiving runtime.
126 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127 pub struct AgentMailAddress {
128 pub owner_id: String,
129 pub workspace_id: String,
130 pub thread_id: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub task_id: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub session_id: Option<String>,
135 }
136
137 impl AgentMailAddress {
138 pub fn validate(&self) -> Result<(), AgentMailValidationError> {
139 validate_opaque_id("address.owner_id", &self.owner_id)?;
140 validate_opaque_id("address.workspace_id", &self.workspace_id)?;
141 validate_opaque_id("address.thread_id", &self.thread_id)?;
142 if let Some(task_id) = &self.task_id {
143 validate_opaque_id("address.task_id", task_id)?;
144 }
145 if let Some(session_id) = &self.session_id {
146 validate_opaque_id("address.session_id", session_id)?;
147 }
148 if self.task_id.is_none() && self.session_id.is_none() {
149 return Err(AgentMailValidationError::new(
150 "address",
151 "task_id or session_id is required",
152 ));
153 }
154 Ok(())
155 }
156 }
157
158 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159 pub struct AgentMailSender {
160 /// Runtime-authorized stable identity; never a free-form transcript name.
161 pub identity: String,
162 pub display_label: String,
163 }
164
165 impl AgentMailSender {
166 pub fn validate(&self) -> Result<(), AgentMailValidationError> {
167 validate_opaque_id("sender.identity", &self.identity)?;
168 validate_bounded_text(
169 "sender.display_label",
170 &self.display_label,
171 MAX_AGENT_MAIL_DISPLAY_LABEL_BYTES,
172 false,
173 )
174 }
175 }
176
177 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
178 #[serde(rename_all = "snake_case")]
179 pub enum AgentMailDeliveryMode {
180 QueueOnly,
181 WakeAtSafeBoundary,
182 }
183
184 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
185 #[serde(rename_all = "snake_case")]
186 pub enum AgentMailEvidenceKind {
187 RuntimeEvent,
188 TurnItem,
189 ArtifactReceipt,
190 }
191
192 /// Bounded pointer to evidence already authorized by the destination runtime.
193 ///
194 /// `reference_id` is deliberately opaque: paths and URLs are not valid evidence
195 /// references and must not be smuggled through this contract.
196 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
197 pub struct AgentMailEvidenceRef {
198 pub kind: AgentMailEvidenceKind,
199 pub reference_id: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub label: Option<String>,
202 }
203
204 impl AgentMailEvidenceRef {
205 pub fn validate(&self) -> Result<(), AgentMailValidationError> {
206 validate_opaque_id("evidence.reference_id", &self.reference_id)?;
207 if let Some(label) = &self.label {
208 validate_bounded_text(
209 "evidence.label",
210 label,
211 MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES,
212 false,
213 )?;
214 }
215 Ok(())
216 }
217 }
218
219 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
220 #[serde(rename_all = "snake_case")]
221 pub enum AgentMailStatus {
222 Queued,
223 Delivering,
224 Delivered,
225 Read,
226 Failed,
227 /// Explicitly withdrawn while queued (#6176). Terminal: delivery and the
228 /// wake pump never claim it, and re-cancel is an idempotent no-op.
229 Canceled,
230 }
231
232 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
233 #[serde(rename_all = "snake_case")]
234 pub enum AgentMailFailureCode {
235 AuthorizationDenied,
236 DestinationUnavailable,
237 DeliveryRejected,
238 Persistence,
239 AttemptLimit,
240 InvalidEnvelope,
241 }
242
243 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244 pub struct AgentMailFailureReceipt {
245 pub code: AgentMailFailureCode,
246 pub message: String,
247 pub retryable: bool,
248 pub failed_at: DateTime<Utc>,
249 }
250
251 impl AgentMailFailureReceipt {
252 pub fn validate(&self) -> Result<(), AgentMailValidationError> {
253 validate_bounded_text(
254 "failure.message",
255 &self.message,
256 MAX_AGENT_MAIL_FAILURE_MESSAGE_BYTES,
257 false,
258 )
259 }
260 }
261
262 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263 pub struct AgentMailEnvelope {
264 #[serde(default = "default_agent_mail_schema_version")]
265 pub schema_version: u32,
266 pub message_id: AgentMailMessageId,
267 pub source: AgentMailAddress,
268 pub destination: AgentMailAddress,
269 pub sender: AgentMailSender,
270 /// Sanitized, bounded content presented to the destination task and UI.
271 pub summary: String,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub evidence: Vec<AgentMailEvidenceRef>,
274 pub delivery_mode: AgentMailDeliveryMode,
275 /// Explicit loop-breaking decision. It must agree with `delivery_mode`.
276 pub trigger_turn: bool,
277 pub hop_count: u8,
278 pub status: AgentMailStatus,
279 pub created_at: DateTime<Utc>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub delivered_at: Option<DateTime<Utc>>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub read_at: Option<DateTime<Utc>>,
284 #[serde(default)]
285 pub attempt_count: u8,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub failure: Option<AgentMailFailureReceipt>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub delivery_turn_id: Option<String>,
290 }
291
292 impl AgentMailEnvelope {
293 pub fn validate(&self) -> Result<(), AgentMailValidationError> {
294 if self.schema_version != AGENT_MAIL_SCHEMA_VERSION {
295 return Err(AgentMailValidationError::new(
296 "schema_version",
297 format!("expected {AGENT_MAIL_SCHEMA_VERSION}"),
298 ));
299 }
300 self.source.validate()?;
301 self.destination.validate()?;
302 self.sender.validate()?;
303 validate_summary_and_delivery(
304 &self.summary,
305 &self.evidence,
306 self.delivery_mode,
307 self.trigger_turn,
308 self.hop_count,
309 )?;
310 if self.attempt_count > MAX_AGENT_MAIL_DELIVERY_ATTEMPTS {
311 return Err(AgentMailValidationError::new(
312 "attempt_count",
313 format!("must be at most {MAX_AGENT_MAIL_DELIVERY_ATTEMPTS}"),
314 ));
315 }
316 if let Some(turn_id) = &self.delivery_turn_id {
317 validate_opaque_id("delivery_turn_id", turn_id)?;
318 }
319
320 match self.status {
321 AgentMailStatus::Queued => {
322 require_absent(self.delivered_at.is_some(), "delivered_at", "queued")?;
323 require_absent(self.read_at.is_some(), "read_at", "queued")?;
324 require_absent(self.failure.is_some(), "failure", "queued")?;
325 require_absent(
326 self.delivery_turn_id.is_some(),
327 "delivery_turn_id",
328 "queued",
329 )?;
330 }
331 AgentMailStatus::Delivering => {
332 if self.attempt_count == 0 {
333 return Err(AgentMailValidationError::new(
334 "attempt_count",
335 "delivering mail requires at least one attempt",
336 ));
337 }
338 require_absent(self.delivered_at.is_some(), "delivered_at", "delivering")?;
339 require_absent(self.read_at.is_some(), "read_at", "delivering")?;
340 require_absent(self.failure.is_some(), "failure", "delivering")?;
341 }
342 AgentMailStatus::Delivered => self.validate_delivered(false)?,
343 AgentMailStatus::Read => self.validate_delivered(true)?,
344 AgentMailStatus::Canceled => {
345 // Canceled mail never started delivery: same absences as
346 // queued. Attempts stay 0 — cancel is only accepted while
347 // queued, before any delivery claim.
348 require_absent(self.delivered_at.is_some(), "delivered_at", "canceled")?;
349 require_absent(self.read_at.is_some(), "read_at", "canceled")?;
350 require_absent(self.failure.is_some(), "failure", "canceled")?;
351 require_absent(
352 self.delivery_turn_id.is_some(),
353 "delivery_turn_id",
354 "canceled",
355 )?;
356 }
357 AgentMailStatus::Failed => {
358 if self.attempt_count == 0 {
359 return Err(AgentMailValidationError::new(
360 "attempt_count",
361 "failed mail requires at least one attempt",
362 ));
363 }
364 let failure = self.failure.as_ref().ok_or_else(|| {
365 AgentMailValidationError::new("failure", "failed mail requires a receipt")
366 })?;
367 failure.validate()?;
368 if failure.failed_at < self.created_at {
369 return Err(AgentMailValidationError::new(
370 "failure.failed_at",
371 "cannot precede created_at",
372 ));
373 }
374 require_absent(self.delivered_at.is_some(), "delivered_at", "failed")?;
375 require_absent(self.read_at.is_some(), "read_at", "failed")?;
376 }
377 }
378 Ok(())
379 }
380
381 fn validate_delivered(&self, read: bool) -> Result<(), AgentMailValidationError> {
382 let delivered_at = self.delivered_at.as_ref().ok_or_else(|| {
383 AgentMailValidationError::new("delivered_at", "delivered mail requires a timestamp")
384 })?;
385 if delivered_at < &self.created_at {
386 return Err(AgentMailValidationError::new(
387 "delivered_at",
388 "cannot precede created_at",
389 ));
390 }
391 if self.delivery_turn_id.is_none() {
392 return Err(AgentMailValidationError::new(
393 "delivery_turn_id",
394 "delivered mail requires a destination turn",
395 ));
396 }
397 if self.attempt_count == 0 {
398 return Err(AgentMailValidationError::new(
399 "attempt_count",
400 "delivered mail requires at least one attempt",
401 ));
402 }
403 require_absent(self.failure.is_some(), "failure", "delivered")?;
404 match (read, self.read_at.as_ref()) {
405 (true, Some(read_at)) if read_at >= delivered_at => Ok(()),
406 (true, Some(_)) => Err(AgentMailValidationError::new(
407 "read_at",
408 "cannot precede delivered_at",
409 )),
410 (true, None) => Err(AgentMailValidationError::new(
411 "read_at",
412 "read mail requires a timestamp",
413 )),
414 (false, Some(_)) => Err(AgentMailValidationError::new(
415 "read_at",
416 "delivered mail cannot have read_at before entering read status",
417 )),
418 (false, None) => Ok(()),
419 }
420 }
421
422 /// Compares only immutable delivery intent, ignoring lifecycle state.
423 #[must_use]
424 pub fn is_idempotent_replay_of(&self, other: &Self) -> bool {
425 self.schema_version == other.schema_version
426 && self.message_id == other.message_id
427 && self.source == other.source
428 && self.destination == other.destination
429 && self.sender == other.sender
430 && self.summary == other.summary
431 && self.evidence == other.evidence
432 && self.delivery_mode == other.delivery_mode
433 && self.trigger_turn == other.trigger_turn
434 && self.hop_count == other.hop_count
435 }
436
437 /// Checks whether a replayed API request describes this persisted message.
438 #[must_use]
439 pub fn matches_send_request(&self, request: &AgentMailSendRequest) -> bool {
440 self.message_id == request.message_id
441 && self.source.thread_id == request.source_thread_id
442 && self.destination.thread_id == request.destination_thread_id
443 && self.sender == request.sender
444 && self.summary == request.summary
445 && self.evidence == request.evidence
446 && self.delivery_mode == request.delivery_mode
447 && self.trigger_turn == request.trigger_turn
448 && self.hop_count == request.hop_count
449 }
450 }
451
452 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
453 pub struct AgentMailSendRequest {
454 pub message_id: AgentMailMessageId,
455 pub source_thread_id: String,
456 pub destination_thread_id: String,
457 pub sender: AgentMailSender,
458 /// Runtime-sanitized before the request becomes a persisted envelope.
459 pub summary: String,
460 #[serde(default, skip_serializing_if = "Vec::is_empty")]
461 pub evidence: Vec<AgentMailEvidenceRef>,
462 pub delivery_mode: AgentMailDeliveryMode,
463 pub trigger_turn: bool,
464 #[serde(default)]
465 pub hop_count: u8,
466 }
467
468 impl AgentMailSendRequest {
469 pub fn validate(&self) -> Result<(), AgentMailValidationError> {
470 validate_opaque_id("source_thread_id", &self.source_thread_id)?;
471 validate_opaque_id("destination_thread_id", &self.destination_thread_id)?;
472 self.sender.validate()?;
473 validate_summary_and_delivery(
474 &self.summary,
475 &self.evidence,
476 self.delivery_mode,
477 self.trigger_turn,
478 self.hop_count,
479 )
480 }
481 }
482
483 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
484 pub struct AgentMailSendResponse {
485 pub envelope: AgentMailEnvelope,
486 /// True when the message id and immutable intent already existed.
487 pub idempotent_replay: bool,
488 }
489
490 /// Canonical payload placed in every Agent Mail runtime event.
491 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
492 pub struct AgentMailEventPayload {
493 pub mail: AgentMailEnvelope,
494 }
495
496 fn default_agent_mail_schema_version() -> u32 {
497 AGENT_MAIL_SCHEMA_VERSION
498 }
499
500 fn validate_message_id(value: &str) -> Result<(), AgentMailValidationError> {
501 if !value.starts_with("mail_") {
502 return Err(AgentMailValidationError::new(
503 "message_id",
504 "must start with mail_",
505 ));
506 }
507 if value.len() > MAX_AGENT_MAIL_MESSAGE_ID_BYTES {
508 return Err(AgentMailValidationError::new(
509 "message_id",
510 format!("must be at most {MAX_AGENT_MAIL_MESSAGE_ID_BYTES} bytes"),
511 ));
512 }
513 if !value
514 .bytes()
515 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
516 {
517 return Err(AgentMailValidationError::new(
518 "message_id",
519 "contains unsupported characters",
520 ));
521 }
522 if value.len() == "mail_".len() {
523 return Err(AgentMailValidationError::new(
524 "message_id",
525 "requires an id after mail_",
526 ));
527 }
528 Ok(())
529 }
530
531 fn validate_opaque_id(field: &'static str, value: &str) -> Result<(), AgentMailValidationError> {
532 if value.is_empty() {
533 return Err(AgentMailValidationError::new(field, "must not be empty"));
534 }
535 if value.len() > MAX_AGENT_MAIL_OPAQUE_ID_BYTES {
536 return Err(AgentMailValidationError::new(
537 field,
538 format!("must be at most {MAX_AGENT_MAIL_OPAQUE_ID_BYTES} bytes"),
539 ));
540 }
541 if value == "."
542 || value.contains("..")
543 || !value.bytes().all(|byte| {
544 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'@')
545 })
546 {
547 return Err(AgentMailValidationError::new(
548 field,
549 "must be an opaque id, not a path or URL",
550 ));
551 }
552 Ok(())
553 }
554
555 fn validate_bounded_text(
556 field: &'static str,
557 value: &str,
558 max_bytes: usize,
559 allow_line_breaks: bool,
560 ) -> Result<(), AgentMailValidationError> {
561 if value.is_empty() {
562 return Err(AgentMailValidationError::new(field, "must not be empty"));
563 }
564 if value.len() > max_bytes {
565 return Err(AgentMailValidationError::new(
566 field,
567 format!("must be at most {max_bytes} bytes"),
568 ));
569 }
570 if value.trim() != value {
571 return Err(AgentMailValidationError::new(
572 field,
573 "must not have leading or trailing whitespace",
574 ));
575 }
576 let has_forbidden_control = value
577 .chars()
578 .any(|ch| ch.is_control() && !(allow_line_breaks && matches!(ch, '\n' | '\t')));
579 if has_forbidden_control {
580 return Err(AgentMailValidationError::new(
581 field,
582 "contains unsupported control characters",
583 ));
584 }
585 Ok(())
586 }
587
588 fn validate_summary_and_delivery(
589 summary: &str,
590 evidence: &[AgentMailEvidenceRef],
591 delivery_mode: AgentMailDeliveryMode,
592 trigger_turn: bool,
593 hop_count: u8,
594 ) -> Result<(), AgentMailValidationError> {
595 validate_bounded_text("summary", summary, MAX_AGENT_MAIL_SUMMARY_BYTES, true)?;
596 if evidence.len() > MAX_AGENT_MAIL_EVIDENCE_REFS {
597 return Err(AgentMailValidationError::new(
598 "evidence",
599 format!("must contain at most {MAX_AGENT_MAIL_EVIDENCE_REFS} references"),
600 ));
601 }
602 for reference in evidence {
603 reference.validate()?;
604 }
605 if hop_count > MAX_AGENT_MAIL_HOPS {
606 return Err(AgentMailValidationError::new(
607 "hop_count",
608 format!("must be at most {MAX_AGENT_MAIL_HOPS}"),
609 ));
610 }
611 let expected_trigger = matches!(delivery_mode, AgentMailDeliveryMode::WakeAtSafeBoundary);
612 if trigger_turn != expected_trigger {
613 return Err(AgentMailValidationError::new(
614 "trigger_turn",
615 "must be false for queue_only and true for wake_at_safe_boundary",
616 ));
617 }
618 Ok(())
619 }
620
621 fn require_absent(
622 present: bool,
623 field: &'static str,
624 status: &'static str,
625 ) -> Result<(), AgentMailValidationError> {
626 if present {
627 return Err(AgentMailValidationError::new(
628 field,
629 format!("must be absent while status is {status}"),
630 ));
631 }
632 Ok(())
633 }
634
635 #[cfg(test)]
636 mod tests {
637 use super::*;
638
639 fn address(thread_id: &str) -> AgentMailAddress {
640 AgentMailAddress {
641 owner_id: "acct_local".into(),
642 workspace_id: "ws_123".into(),
643 thread_id: thread_id.into(),
644 task_id: Some(format!("task_{thread_id}")),
645 session_id: None,
646 }
647 }
648
649 fn queued_envelope() -> AgentMailEnvelope {
650 AgentMailEnvelope {
651 schema_version: AGENT_MAIL_SCHEMA_VERSION,
652 message_id: AgentMailMessageId::parse("mail_123").unwrap(),
653 source: address("thr_a"),
654 destination: address("thr_b"),
655 sender: AgentMailSender {
656 identity: "agent_a".into(),
657 display_label: "Agent A".into(),
658 },
659 summary: "A bounded handoff".into(),
660 evidence: vec![AgentMailEvidenceRef {
661 kind: AgentMailEvidenceKind::RuntimeEvent,
662 reference_id: "evt_42".into(),
663 label: Some("Build receipt".into()),
664 }],
665 delivery_mode: AgentMailDeliveryMode::QueueOnly,
666 trigger_turn: false,
667 hop_count: 0,
668 status: AgentMailStatus::Queued,
669 created_at: Utc::now(),
670 delivered_at: None,
671 read_at: None,
672 attempt_count: 0,
673 failure: None,
674 delivery_turn_id: None,
675 }
676 }
677
678 #[test]
679 fn queued_envelope_roundtrips_with_canonical_event_names() {
680 let envelope = queued_envelope();
681 envelope.validate().unwrap();
682 let value = serde_json::to_value(AgentMailEventPayload {
683 mail: envelope.clone(),
684 })
685 .unwrap();
686 let decoded: AgentMailEventPayload = serde_json::from_value(value).unwrap();
687 assert_eq!(decoded.mail, envelope);
688 assert_eq!(AGENT_MAIL_EVENT_QUEUED, "agent_mail.queued");
689 assert_eq!(
690 AGENT_MAIL_EVENT_DELIVERY_FAILED,
691 "agent_mail.delivery_failed"
692 );
693 }
694
695 #[test]
696 fn rejects_bounds_controls_paths_and_excess_hops() {
697 assert!(AgentMailMessageId::parse("../../secret").is_err());
698 let mut envelope = queued_envelope();
699 envelope.summary = format!("ok\0{}", "x".repeat(MAX_AGENT_MAIL_SUMMARY_BYTES));
700 assert!(envelope.validate().is_err());
701
702 let mut envelope = queued_envelope();
703 envelope.evidence[0].reference_id = "/tmp/transcript".into();
704 assert!(envelope.validate().is_err());
705
706 let mut envelope = queued_envelope();
707 envelope.hop_count = MAX_AGENT_MAIL_HOPS + 1;
708 assert!(envelope.validate().is_err());
709 }
710
711 #[test]
712 fn requires_task_or_session_and_consistent_wake_control() {
713 let mut envelope = queued_envelope();
714 envelope.destination.task_id = None;
715 assert!(envelope.validate().is_err());
716
717 let mut envelope = queued_envelope();
718 envelope.trigger_turn = true;
719 assert!(envelope.validate().is_err());
720 envelope.delivery_mode = AgentMailDeliveryMode::WakeAtSafeBoundary;
721 assert!(envelope.validate().is_ok());
722 }
723
724 #[test]
725 fn lifecycle_fields_are_validated() {
726 let mut envelope = queued_envelope();
727 envelope.status = AgentMailStatus::Delivered;
728 envelope.attempt_count = 1;
729 envelope.delivered_at = Some(envelope.created_at);
730 envelope.delivery_turn_id = Some("turn_1".into());
731 assert!(envelope.validate().is_ok());
732
733 envelope.status = AgentMailStatus::Read;
734 assert!(envelope.validate().is_err());
735 envelope.read_at = envelope.delivered_at;
736 assert!(envelope.validate().is_ok());
737 }
738
739 #[test]
740 fn canceled_envelope_validates_without_delivery_fields() {
741 let mut envelope = queued_envelope();
742 envelope.status = AgentMailStatus::Canceled;
743 assert!(envelope.validate().is_ok());
744 envelope.delivery_turn_id = Some("turn_1".into());
745 assert!(envelope.validate().is_err());
746 assert_eq!(AGENT_MAIL_EVENT_CANCELED, "agent_mail.canceled");
747 }
748
749 #[test]
750 fn replay_equivalence_ignores_delivery_state_but_not_intent() {
751 let queued = queued_envelope();
752 let mut delivered = queued.clone();
753 delivered.status = AgentMailStatus::Delivered;
754 delivered.attempt_count = 1;
755 delivered.created_at += chrono::Duration::seconds(1);
756 delivered.delivered_at = Some(delivered.created_at);
757 delivered.delivery_turn_id = Some("turn_1".into());
758 assert!(queued.is_idempotent_replay_of(&delivered));
759
760 delivered.summary = "Different intent under the same id".into();
761 assert!(!queued.is_idempotent_replay_of(&delivered));
762 }
763 }
764
764 lines RUST